diff --git a/README.md b/README.md index 18c7c44..39ff985 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ To create a GitHub App and a GitHub App Installation: - Variables: Read and write - Organization permissions - Administration: Read and write + - Members: Read and write - Where can this GitHub App be installed?: _choose what suits you best_ - **Create GitHub App** - _your app_ @@ -132,6 +133,9 @@ Create the configuration file: ```yaml --- organization: # OPTIONAL + members: # OPTIONAL, DEFAULT none + admins: # REQUIRED; GitHub usernames + - octocat environments: # OPTIONAL, DEFAULT none; added to every repository release: deployment_branches: # OPTIONAL, DEFAULT every branch @@ -172,6 +176,12 @@ repositories: default-branch: ~ # opts out of the organization ruleset ``` +### Members + +`organization.members.admins` lists the organization owners. Only owners are managed: other members, teams and outside collaborators stay unmanaged. A listed user who is not a member yet is invited and stays pending until they accept. Every owner has `prevent_destroy`, so removing one from the list fails the plan; demote or remove an owner explicitly (`removed` block or `terraform state rm`). Existing memberships are imported, which needs the Terraform workspace named after the organization. + +Members need the App's organization Members permission, see [GitHub App](#github-app). + ### Environments `organization.environments` is added to every repository's `environments`. A repository environment of the same name replaces the organization one wholesale (no key-level merge), `~` opts the repository out of it, other names are repository-only. diff --git a/terraform/main.tf b/terraform/main.tf index f0306df..2821a00 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -2,11 +2,14 @@ locals { config = try(yamldecode(file(var.config)), {}) allowed_top_level_keys = ["organization", "repositories"] - allowed_organization_keys = ["environments", "rulesets"] + allowed_organization_keys = ["members", "environments", "rulesets"] + allowed_members_keys = ["admins"] allowed_repository_keys = ["name", "description", "homepage_url", "topics", "is_template", "template", "environments", "rulesets"] allowed_environment_keys = ["deployment_branches", "variables", "secrets", "reviewers"] allowed_ruleset_keys = ["bypass_apps"] + organization_admins = try(toset(local.config.organization.members.admins), toset([])) + organization_environments = try({ for name, environment in local.config.organization.environments : name => environment }, {}) organization_rulesets = try({ for name, ruleset in local.config.organization.rulesets : name => ruleset }, {}) @@ -31,3 +34,21 @@ module "repository" { repository = each.value } + +resource "github_membership" "admin" { + for_each = local.organization_admins + + username = each.value + role = "admin" + + lifecycle { + prevent_destroy = true + } +} + +# Keep until every workspace has applied this; the workspace is named after the organization. +import { + for_each = local.organization_admins + to = github_membership.admin[each.key] + id = "${terraform.workspace}:${each.key}" +} diff --git a/terraform/tests/fixtures/members-admins-blank.yaml b/terraform/tests/fixtures/members-admins-blank.yaml new file mode 100644 index 0000000..9b0c8de --- /dev/null +++ b/terraform/tests/fixtures/members-admins-blank.yaml @@ -0,0 +1,8 @@ +--- +organization: + members: + admins: + - bruzina + - " " +repositories: + - name: foo diff --git a/terraform/tests/fixtures/members-admins-duplicate-case.yaml b/terraform/tests/fixtures/members-admins-duplicate-case.yaml new file mode 100644 index 0000000..6b33bcc --- /dev/null +++ b/terraform/tests/fixtures/members-admins-duplicate-case.yaml @@ -0,0 +1,8 @@ +--- +organization: + members: + admins: + - bruzina + - Bruzina +repositories: + - name: foo diff --git a/terraform/tests/fixtures/members-admins-empty.yaml b/terraform/tests/fixtures/members-admins-empty.yaml new file mode 100644 index 0000000..a50491d --- /dev/null +++ b/terraform/tests/fixtures/members-admins-empty.yaml @@ -0,0 +1,6 @@ +--- +organization: + members: + admins: [] +repositories: + - name: foo diff --git a/terraform/tests/fixtures/members-admins-map.yaml b/terraform/tests/fixtures/members-admins-map.yaml new file mode 100644 index 0000000..8244fc7 --- /dev/null +++ b/terraform/tests/fixtures/members-admins-map.yaml @@ -0,0 +1,7 @@ +--- +organization: + members: + admins: + bruzina: admin +repositories: + - name: foo diff --git a/terraform/tests/fixtures/members-null.yaml b/terraform/tests/fixtures/members-null.yaml new file mode 100644 index 0000000..77dc8c4 --- /dev/null +++ b/terraform/tests/fixtures/members-null.yaml @@ -0,0 +1,5 @@ +--- +organization: + members: ~ +repositories: + - name: foo diff --git a/terraform/tests/fixtures/members-unknown-key.yaml b/terraform/tests/fixtures/members-unknown-key.yaml new file mode 100644 index 0000000..dad4a85 --- /dev/null +++ b/terraform/tests/fixtures/members-unknown-key.yaml @@ -0,0 +1,9 @@ +--- +organization: + members: + admins: + - bruzina + owners: + - octocat +repositories: + - name: foo diff --git a/terraform/tests/fixtures/members.yaml b/terraform/tests/fixtures/members.yaml new file mode 100644 index 0000000..60674ab --- /dev/null +++ b/terraform/tests/fixtures/members.yaml @@ -0,0 +1,8 @@ +--- +organization: + members: + admins: + - bruzina + - octocat +repositories: + - name: foo diff --git a/terraform/tests/members.tftest.hcl b/terraform/tests/members.tftest.hcl new file mode 100644 index 0000000..32c764e --- /dev/null +++ b/terraform/tests/members.tftest.hcl @@ -0,0 +1,94 @@ +mock_provider "github" {} + +override_resource { + target = github_membership.admin +} + +run "members" { + command = plan + + variables { + config = "tests/fixtures/members.yaml" + } + + assert { + condition = keys(github_membership.admin) == ["bruzina", "octocat"] && alltrue([for m in github_membership.admin : m.role == "admin"]) + error_message = "Expected admins bruzina and octocat." + } +} + +run "no_members" { + command = plan + + variables { + config = "tests/fixtures/one-repository.yaml" + } + + assert { + condition = length(github_membership.admin) == 0 + error_message = "Expected no memberships." + } +} + +run "members_null" { + command = plan + + variables { + config = "tests/fixtures/members-null.yaml" + } + + assert { + condition = length(github_membership.admin) == 0 + error_message = "Expected no memberships." + } +} + +run "members_unknown_key" { + command = plan + + variables { + config = "tests/fixtures/members-unknown-key.yaml" + } + + expect_failures = [var.config] +} + +run "members_admins_empty" { + command = plan + + variables { + config = "tests/fixtures/members-admins-empty.yaml" + } + + expect_failures = [var.config] +} + +run "members_admins_map" { + command = plan + + variables { + config = "tests/fixtures/members-admins-map.yaml" + } + + expect_failures = [var.config] +} + +run "members_admins_blank" { + command = plan + + variables { + config = "tests/fixtures/members-admins-blank.yaml" + } + + expect_failures = [var.config] +} + +run "members_admins_duplicate_case" { + command = plan + + variables { + config = "tests/fixtures/members-admins-duplicate-case.yaml" + } + + expect_failures = [var.config] +} diff --git a/terraform/tests/test-yaml.tftest.hcl b/terraform/tests/test-yaml.tftest.hcl index c792cdd..c696680 100644 --- a/terraform/tests/test-yaml.tftest.hcl +++ b/terraform/tests/test-yaml.tftest.hcl @@ -6,6 +6,10 @@ mock_provider "github" { } } +override_resource { + target = github_membership.admin +} + run "test_yaml" { command = apply @@ -13,6 +17,11 @@ run "test_yaml" { config = "../test.yaml" } + assert { + condition = keys(github_membership.admin) == ["bruzina"] + error_message = "Expected admin bruzina." + } + assert { condition = keys(module.repository) == [".github", "template", "template-use"] error_message = "Expected repositories .github, template, template-use." diff --git a/terraform/variables.tf b/terraform/variables.tf index 0434d7e..8e30390 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -37,6 +37,10 @@ variable "config" { condition = try(yamldecode(file(var.config)).organization == null, true) || try(length(setsubtract(keys(yamldecode(file(var.config)).organization), local.allowed_organization_keys)) == 0, false) error_message = "File ${var.config}: organization must be a map with key(s) ${join(", ", local.allowed_organization_keys)}; unknown: ${try(join(", ", setsubtract(keys(yamldecode(file(var.config)).organization), local.allowed_organization_keys)), "")}" } + validation { + condition = try(yamldecode(file(var.config)).organization.members == null, true) || try(length(setsubtract(keys(yamldecode(file(var.config)).organization.members), local.allowed_members_keys)) == 0 && length(concat(yamldecode(file(var.config)).organization.members.admins, [])) > 0 && alltrue([for u in yamldecode(file(var.config)).organization.members.admins : trimspace(u) != ""]) && length(distinct([for u in yamldecode(file(var.config)).organization.members.admins : lower(u)])) == length(yamldecode(file(var.config)).organization.members.admins), false) + error_message = "File ${var.config}: organization.members must be a map of ${join(", ", local.allowed_members_keys)}; admins must be a non-empty list of unique GitHub usernames." + } validation { condition = try(length(flatten([for s in concat([{ label = "organization", environments = try(yamldecode(file(var.config)).organization.environments, null), opt_out = false }], [for r in yamldecode(file(var.config)).repositories : { label = r.name, environments = try(r.environments, null), opt_out = true }]) : s.environments == null ? [] : !can(keys(s.environments)) ? ["${s.label}.environments"] : [for n, e in s.environments : "${s.label}.environments.${n}" if !(s.opt_out && e == null) && !try(length(setsubtract(keys(e), local.allowed_environment_keys)) == 0, false)]])) == 0, true) error_message = "Invalid environment(s) in ${var.config}: ${try(join(", ", flatten([for s in concat([{ label = "organization", environments = try(yamldecode(file(var.config)).organization.environments, null), opt_out = false }], [for r in yamldecode(file(var.config)).repositories : { label = r.name, environments = try(r.environments, null), opt_out = true }]) : s.environments == null ? [] : !can(keys(s.environments)) ? ["${s.label}.environments"] : [for n, e in s.environments : "${s.label}.environments.${n}" if !(s.opt_out && e == null) && !try(length(setsubtract(keys(e), local.allowed_environment_keys)) == 0, false)]])), "")}; each must be a map of ${join(", ", local.allowed_environment_keys)}, or ~ under a repository to opt out of an organization environment." diff --git a/test.yaml b/test.yaml index 50d8383..7996c3e 100644 --- a/test.yaml +++ b/test.yaml @@ -1,5 +1,8 @@ --- organization: + members: + admins: + - bruzina environments: release: deployment_branches: