├── ci ├── github ├── misc │ ├── tf-plan-to-diff.envsubst │ ├── tf-apply-to-diff.envsubst │ └── tf-plan-to-diff.sed └── make │ └── internal-targets.mk ├── _operations └── github │ ├── fictional-meme │ ├── tmp │ │ └── .gitkeep │ ├── .terraform.lock.hcl │ └── config.tf.json │ ├── supreme-octo-enigma │ ├── tmp │ │ └── .gitkeep │ ├── .terraform.lock.hcl │ └── config.tf.json │ └── .terraform_lockfile │ ├── config.tf.json │ └── .terraform.lock.hcl ├── cue.mod └── module.cue ├── config ├── org.fictional-meme.cue ├── org.supreme-octo-enigma.cue ├── policy.per-org.cue ├── employees.cue ├── manifest.admin.cue ├── manifest.cue ├── terraform.admin.cue ├── terraform.cue ├── policy.global.admin.cue ├── defaults.cue ├── target.terraform.cue ├── templates.cue ├── schema.cue └── github_actions.cue ├── .gitignore ├── .gitattributes ├── internal └── schemata │ ├── providers │ └── github │ │ ├── membership.cue │ │ ├── team_membership.cue │ │ ├── team_repository.cue │ │ ├── team.cue │ │ ├── repository_collaborators.cue │ │ ├── repository.cue │ │ └── organization_settings.cue │ └── terraform │ ├── resource.cue │ └── config.cue ├── CODEOWNERS ├── Makefile ├── docs ├── customising-this-repo.md └── managing-existing-resources.md ├── generate_tool.cue ├── .github └── workflows │ ├── detect-drift.main-branch.scheduled.yml │ ├── test.PR-branch.yml │ └── test-and-apply.main-branch.yml └── README.md /ci/github: -------------------------------------------------------------------------------- 1 | ../.github/workflows/ -------------------------------------------------------------------------------- /_operations/github/fictional-meme/tmp/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_operations/github/supreme-octo-enigma/tmp/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_operations/github/fictional-meme/.terraform.lock.hcl: -------------------------------------------------------------------------------- 1 | ../.terraform_lockfile/.terraform.lock.hcl -------------------------------------------------------------------------------- /cue.mod/module.cue: -------------------------------------------------------------------------------- 1 | module: "github.com/cue-examples/cue-terraform-github-config-experiment" 2 | -------------------------------------------------------------------------------- /_operations/github/supreme-octo-enigma/.terraform.lock.hcl: -------------------------------------------------------------------------------- 1 | ../.terraform_lockfile/.terraform.lock.hcl -------------------------------------------------------------------------------- /config/org.fictional-meme.cue: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | github: org: "fictional-meme": config: { 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_operations/github/*/.terraform/* 2 | /_operations/github/*/tmp/* 3 | 4 | FORCE 5 | !.gitkeep 6 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /.github/workflows/*.yml linguist-generated 2 | /github/org-**/config.tf.json linguist-generated 3 | -------------------------------------------------------------------------------- /config/org.supreme-octo-enigma.cue: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | github: org: "supreme-octo-enigma": config: { 4 | } 5 | -------------------------------------------------------------------------------- /ci/misc/tf-plan-to-diff.envsubst: -------------------------------------------------------------------------------- 1 | ## Terraform plan - proposed changes 2 | ## org: `$ORG` 3 | #### commit-id: `$COMMIT_ID` 4 | 5 | ```diff 6 | -------------------------------------------------------------------------------- /ci/misc/tf-apply-to-diff.envsubst: -------------------------------------------------------------------------------- 1 | # Terraform apply 2 | ## org: `$ORG` 3 | ## result: `$APPLY_STATUS` 4 | #### commit-id: `$COMMIT_ID` 5 | 6 | ```diff 7 | -------------------------------------------------------------------------------- /config/policy.per-org.cue: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // Policies which only apply to a single org 4 | 5 | // github?: org?: "CHANGEME"?: config?: { 6 | // resource?: { 7 | // ... constraints go here 8 | // } 9 | // } 10 | -------------------------------------------------------------------------------- /config/employees.cue: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // company employees and their GitHub logins 4 | 5 | company: { 6 | #Employee: login: github: string 7 | employees: { 8 | [_]: #Employee 9 | jonathan: login: github: "jpluscplusm" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /internal/schemata/providers/github/membership.cue: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import "github.com/cue-examples/cue-terraform-github-config-experiment/internal/schemata/terraform" 4 | 5 | #resources: { 6 | github_membership: { 7 | terraform.#resource 8 | username!: string 9 | role?: string 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /internal/schemata/terraform/resource.cue: -------------------------------------------------------------------------------- 1 | package terraform 2 | 3 | #resource: { 4 | for_each?: string 5 | lifecycle?: { 6 | create_before_destroy?: bool 7 | prevent_destroy?: bool 8 | ignore_changes?: [...string] 9 | replace_triggered_by?: [...string] 10 | } 11 | depends_on?: [...string] 12 | } 13 | -------------------------------------------------------------------------------- /_operations/github/.terraform_lockfile/config.tf.json: -------------------------------------------------------------------------------- 1 | { 2 | "terraform": { 3 | "required_providers": { 4 | "github": { 5 | "source": "integrations/github", 6 | "version": "5.25.1" 7 | } 8 | }, 9 | "required_version": "~> 1.4.6" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /internal/schemata/providers/github/team_membership.cue: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import "github.com/cue-examples/cue-terraform-github-config-experiment/internal/schemata/terraform" 4 | 5 | #resources: { 6 | github_team_membership: { 7 | terraform.#resource 8 | team_id!: string 9 | username!: string 10 | role?: string 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /internal/schemata/terraform/config.cue: -------------------------------------------------------------------------------- 1 | package terraform 2 | 3 | #Config: { 4 | resource?: {...} 5 | provider?: {...} 6 | variable?: {...} 7 | moved?: [...{...}] 8 | terraform!: { 9 | cloud!: { 10 | organization!: string 11 | workspaces!: {...} 12 | } 13 | required_providers!: {...} 14 | required_version!: string 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /internal/schemata/providers/github/team_repository.cue: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import "github.com/cue-examples/cue-terraform-github-config-experiment/internal/schemata/terraform" 4 | 5 | #resources: { 6 | github_team_repository: { 7 | terraform.#resource 8 | team_id!: string 9 | repository!: string 10 | permission?: string 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Order is important in this file: the **last** matching line wins. 2 | # cf. https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners#codeowners-syntax 3 | 4 | /.github/workflows/*.yml @myitcv @jpluscplusm 5 | /CODEOWNERS @myitcv @jpluscplusm 6 | *.admin.cue @myitcv @jpluscplusm # leave this line at the *bottom* of the file 7 | -------------------------------------------------------------------------------- /config/manifest.admin.cue: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // Admin-reviewable constraints over external dependencies' versions 4 | 5 | // Terraform, like Go, treats its major versions as sufficiently meaningful 6 | // that *actual* major version transitions are extremely rare. 7 | // A minor version bump is something which we would like admin visibility over. 8 | // A patch version bump is an acceptable non-admin-reviewable upgrade. 9 | versions: terraform: core: =~#"^1\.4\."# 10 | -------------------------------------------------------------------------------- /internal/schemata/providers/github/team.cue: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import "github.com/cue-examples/cue-terraform-github-config-experiment/internal/schemata/terraform" 4 | 5 | #resources: { 6 | github_team: { 7 | terraform.#resource 8 | name!: string 9 | description?: string 10 | privacy?: string 11 | parent_team_id?: string | null 12 | ldap_dn?: string 13 | create_default_maintainer?: bool 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /config/manifest.cue: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // SPOT for external dependencies' versions 4 | 5 | versions: { 6 | terraform: { 7 | core: "1.4.6" 8 | providers: github: "5.25.1" 9 | } 10 | github: { 11 | actions: { 12 | runner: "ubuntu-20.04" 13 | "actions/checkout": "v3" 14 | "cue-lang/setup-cue": "0be332bb74c8a2f07821389447ba3163e2da3bfb" 15 | "hashicorp/setup-terraform": "v2" 16 | "mshick/add-pr-comment": "v2.6.1" 17 | } 18 | } 19 | cue: "v0.6.0-beta.1" 20 | } 21 | -------------------------------------------------------------------------------- /config/terraform.admin.cue: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // Constraints on Terraform's runtime config, admin-reviewable if changed 4 | 5 | github: org: [orgName=_]: config: { 6 | provider: github: { 7 | // This is the scope/namespace under which GitHub API access operates. 8 | // Failure to set this to the name of the org being managed would be 9 | // **disastrous**! 10 | owner!: orgName 11 | } 12 | terraform: cloud: { 13 | organization!: "cue-terraform-github-config-experiment" 14 | workspaces: tags!: ["service:github", "org:\(orgName)"] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /ci/misc/tf-plan-to-diff.sed: -------------------------------------------------------------------------------- 1 | /^[[:space:]]{2}([-+~#])/ { s/^..// } # remove 2 leading spaces in front of "-"/"+"/"~"/"#" 2 | s@^-/\+@!@ # change a leading "-/+" to "!" 3 | /^~/ { s/^/@@ /; s/$/ @@/ } # when a leading "~" is present, add "@@" as line prefix and suffix 4 | /^#.*will be created/ { s/^#/+/ } # highlight a comment line 5 | /^#.*will be destroyed/ { s/^#/-/ } # ditto 6 | /^#.*must be replaced/ { s/^#/!/ } # ditto 7 | /^#.*will be updated in-place/ { s/^#/@@/; s/$/ @@/ } # ditto 8 | -------------------------------------------------------------------------------- /internal/schemata/providers/github/repository_collaborators.cue: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "list" 5 | "github.com/cue-examples/cue-terraform-github-config-experiment/internal/schemata/terraform" 6 | ) 7 | 8 | #resources: { 9 | github_repository_collaborators: { 10 | terraform.#resource 11 | repository!: string 12 | 13 | user?: list.MinItems(1) 14 | user?: [ ...{ 15 | username!: string 16 | permission?: "pull" | "push" | "maintain" | "triage" | "admin" 17 | }] 18 | 19 | team?: list.MinItems(1) 20 | team?: [ ...{ 21 | team_id!: string 22 | permission?: "pull" | "push" | "maintain" | "triage" | "admin" 23 | }] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /config/terraform.cue: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // Terraform's runtime config, templated into all orgs 4 | 5 | github: { 6 | org: [orgName=_]: config: { 7 | provider: github: { 8 | token: "${var.provider_github_token}" 9 | owner: orgName 10 | } 11 | variable: { 12 | provider_github_token: { 13 | sensitive: true 14 | type: "string" // '"string"', not 'string'. This is a TF type assertion, not CUE. 15 | } 16 | } 17 | terraform: github.terraform & { 18 | cloud: { 19 | organization: "cue-terraform-github-config-experiment" 20 | workspaces: tags: ["service:github", "org:\(orgName)"] 21 | } 22 | } 23 | } 24 | terraform: { 25 | required_providers: { 26 | github: { 27 | source: "integrations/github" 28 | version: versions.terraform.providers.github 29 | } 30 | } 31 | // The version of terraform installed inside CI is precisely pinned in 32 | // the GHA workflow file, from the same source used here. 33 | // This version constraint needs to be more relaxed ("~>" prefix), 34 | // otherwise a dev performing /any/ local terraform operations will need 35 | // *exactly* this version to be installed, which leads to poor DevX. 36 | required_version: "~> \(versions.terraform.core)" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /_operations/github/.terraform_lockfile/.terraform.lock.hcl: -------------------------------------------------------------------------------- 1 | # This file is maintained automatically by "terraform init". 2 | # Manual edits may be lost in future updates. 3 | 4 | provider "registry.terraform.io/integrations/github" { 5 | version = "5.25.1" 6 | constraints = "5.25.1" 7 | hashes = [ 8 | "h1:+7D9sNCO9Rk11+tmKELpuyR6p52C4sQLWE4ZU1cEOqk=", 9 | "h1:C8yJOmA8H3+e2EopUcTRdBaiMSUPOD5uduTSBa/i8fE=", 10 | "h1:FUB3DQgMVLTNMJwzXS8HWUXd44ZdrHtuDKx2FJ9ABNQ=", 11 | "h1:epBNElISklZEWSsyr18XAV1GxZcvT6DQSwfFu8b2dLQ=", 12 | "zh:06ac78e7a7ba44627abb0181b6808ad5f219f39234a32832c3e1dee08905e928", 13 | "zh:114e70c06e2f1c009071179573b2b4b4c3901bdb1704e192ad1c6551ddfdf6e8", 14 | "zh:39991a7ea20e5b0b7705356b1806064674ccd4b4fa6529b46f606e2892acc60c", 15 | "zh:5729ba50585e1ecb68f1e04834d843abd501245e28d3c957b7a1e77afbfa15f4", 16 | "zh:64a3b862957c3dfcec7dc9ff388eb6b523b26af560b7e06d3573069978184018", 17 | "zh:7614541276cafc106b7295d7252d1bb6677a5f69b511aba7984205510211d500", 18 | "zh:8200efc0c692f6b6b59805942f81e6ac27384d58fb0167096354e8caae81f4e7", 19 | "zh:868781725ee47d01c92eaeb305f3b08b15edfc16a5f1cc78fde3c87b00cb66aa", 20 | "zh:a304816fff34fda8c57cfe0e7488b5b80966c83c4a054b56bfc6ccfd24267147", 21 | "zh:a31db2c92b72c77a2e645a0738868e2ee9c80e1317d6138522b5989cd8c9c9c3", 22 | "zh:e8597b2239ac1052881db28521a789e9cb3fafc6375ecb2fca824a169fba5821", 23 | "zh:e8f25412bfa36124126952193e81713bfb6a4a16f37a7dd2825b99d1ed07f991", 24 | "zh:fcaa06621b7e21c3cb76219e49a1ffda971a60a7d0b0f4ee1a9c209077d214ee", 25 | "zh:fd39c18b45ae72e4ee40d79be4fdda3d4c6c37d3665b7d494b849c7d7a67e994", 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /config/policy.global.admin.cue: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/cue-examples/cue-terraform-github-config-experiment/internal/schemata/terraform" 5 | ) 6 | 7 | // Config policies that apply to all orgs, and are admin-reviewable if changed 8 | 9 | github: org: [orgName=_]: config: terraform.#Config & { 10 | 11 | resource: { 12 | 13 | github_repository?: [_]: { 14 | 15 | // repo resources all set prevent_destroy, so that no terraform plan 16 | // believes it's acceptable to destroy *and then recreate* a repo. 17 | // NB **THIS DOES NOT STOP A REPO BEING DELETED IF REMOVED FROM THE CONFIG!** 18 | lifecycle: prevent_destroy: true 19 | 20 | // repo resources must explicitly set their visibility, so that we can 21 | // definitely assert against it rather than accepting the github provider's 22 | // default setting. 23 | visibility!: "public" | "private" 24 | } 25 | 26 | github_repository_collaborators?: [_]: { 27 | 28 | // collaborator privs are capped at a maximum of "triage". 29 | let max_privs = "pull" | "push" | "triage" 30 | user?: [ ...{ 31 | permission!: max_privs 32 | }] 33 | // the only team we grant access to is the employee team, who needs to 34 | // have "maintain" access in the cue-lang org. 35 | // TODO: Refactor this sometime. 36 | team?: [ ...{ 37 | permission!: max_privs | "maintain" 38 | }] 39 | } 40 | 41 | github_organization_settings: self: { 42 | billing_email: "cue-terraform-github-config-experiment-controller+billing@cue.works" 43 | } 44 | 45 | github_membership: { 46 | // Orgs have these owner accounts as admin 47 | 48 | myitcv_owner: { 49 | username: "myitcv" 50 | role: "admin" 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /internal/schemata/providers/github/repository.cue: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import "github.com/cue-examples/cue-terraform-github-config-experiment/internal/schemata/terraform" 4 | 5 | #resources: { 6 | github_repository: { 7 | terraform.#resource 8 | name!: string 9 | description?: string 10 | homepage_url?: string 11 | private?: bool 12 | visibility?: string 13 | has_issues?: bool 14 | has_discussions?: bool 15 | has_projects?: bool 16 | has_wiki?: bool 17 | is_template?: bool 18 | allow_merge_commit?: bool 19 | allow_squash_merge?: bool 20 | allow_rebase_merge?: bool 21 | allow_auto_merge?: bool 22 | squash_merge_commit_title?: string 23 | squash_merge_commit_message?: string 24 | merge_commit_title?: string 25 | merge_commit_message?: string 26 | delete_branch_on_merge?: bool 27 | has_downloads?: bool 28 | auto_init?: bool 29 | gitignore_template?: string 30 | license_template?: string 31 | archived?: bool 32 | archive_on_destroy?: bool 33 | topics?: [ ...string] 34 | vulnerability_alerts?: bool 35 | ignore_vulnerability_alerts_during_read?: bool 36 | allow_update_branch?: bool 37 | 38 | pages?: { 39 | source!: { 40 | branch!: string 41 | path?: string 42 | } 43 | cname?: string 44 | } 45 | 46 | security_and_analysis?: { 47 | advanced_security?: { 48 | status!: "enabled" | "disabled" 49 | } 50 | secret_scanning?: { 51 | status!: "enabled" | "disabled" 52 | } 53 | secret_scanning_push_protection?: { 54 | status!: "enabled" | "disabled" 55 | } 56 | } 57 | 58 | template?: { 59 | owner!: string 60 | repository!: string 61 | include_all_branches?: bool 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /config/defaults.cue: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // Overridable global defaults 4 | 5 | github?: org?: [_]: config?: { 6 | _all_company_employees_are_org_members: _ | *true 7 | } 8 | 9 | github?: org?: [_]: config?: resource?: github_organization_settings?: self?: { 10 | default_repository_permission: _ | *"none" 11 | advanced_security_enabled_for_new_repositories: _ | *false 12 | dependabot_alerts_enabled_for_new_repositories: _ | *false 13 | dependabot_security_updates_enabled_for_new_repositories: _ | *false 14 | dependency_graph_enabled_for_new_repositories: _ | *false 15 | has_organization_projects: _ | *false 16 | has_repository_projects: _ | *false 17 | members_can_create_internal_repositories: _ | *false 18 | members_can_create_pages: _ | *false 19 | members_can_create_private_pages: _ | *false 20 | members_can_create_private_repositories: _ | *false 21 | members_can_create_public_pages: _ | *false 22 | members_can_create_public_repositories: _ | *false 23 | members_can_create_repositories: _ | *false 24 | members_can_fork_private_repositories: _ | *false 25 | secret_scanning_enabled_for_new_repositories: _ | *false 26 | secret_scanning_push_protection_enabled_for_new_repositories: _ | *false 27 | web_commit_signoff_required: _ | *false 28 | } 29 | 30 | github?: org?: [_]: config?: resource?: github_repository?: [_]: { 31 | visibility?: _ | *"private" 32 | has_discussions?: _ | *false 33 | has_downloads?: _ | *false 34 | has_issues?: _ | *false 35 | has_projects?: _ | *false 36 | has_wiki?: _ | *false 37 | allow_merge_commit?: _ | *false 38 | allow_squash_merge?: _ | *false 39 | allow_rebase_merge?: _ | *true 40 | delete_branch_on_merge?: _ | *true 41 | } 42 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: help 2 | FORCE: 3 | 4 | # Bash is required as we're performing output redirection ("2> >(tee file.err >&2)") that /bin/sh can't do. 5 | SHELL:=/bin/bash 6 | .SHELLFLAGS:=-euo pipefail -c 7 | 8 | DIR_OPS_ROOT:=_operations/github 9 | DIR_TF=$(DIR_OPS_ROOT)/$(ORG) 10 | CMD_CUE:=cue 11 | CMD_TF=terraform -chdir=$(DIR_TF) 12 | 13 | ################################################################ 14 | ## Developer targets ########################################### 15 | ################################################################ 16 | 17 | test-config: ## Run tests against static input config (no creds required locally) 18 | $(CMD_CUE) vet -c ./config:config 19 | # Config: OK ✅ 20 | 21 | clean: FORCE 22 | clean: ## Remove all .gitignored files 23 | git clean -dfX $(DIR_OPS_ROOT)/ 24 | 25 | generate: test-config 26 | generate: ## Regenerate all non-source files in the repository 27 | cue cmd gen_terraform 28 | cue cmd gen_ci 29 | 30 | trim: ## Run cue-trim on the non-GHA portions of the unified config 31 | # (github_actions.cue confuses cue-trim; bug is filed) 32 | rm -f config/github_actions.cue 33 | cue trim ./config:config 34 | git restore config/github_actions.cue 35 | 36 | check_clean_working_tree: FORCE 37 | check_clean_working_tree: ## Check that all git's tracked files are unchanged, and no untracked files exist 38 | test -z "$$(git status --porcelain)" \ 39 | || { git status; git diff; false; } 40 | 41 | lockfile_upgrade: generate 42 | lockfile_upgrade: lockfile_INTERNAL_init_upgrade 43 | lockfile_upgrade: lockfile_hash 44 | lockfile_upgrade: ## Upgrade providers to the latest versions permitted by `config/manifest.cue` 45 | 46 | lockfile_hash: ORG=.terraform_lockfile 47 | lockfile_hash: ## Place a full set of platform-specific hashes in terraform's lock file, without re-locking versions 48 | $(CMD_TF) providers lock \ 49 | -platform=linux_amd64 \ 50 | -platform=darwin_amd64 \ 51 | -platform=linux_arm64 \ 52 | -platform=darwin_arm64 53 | 54 | help: ## Show this help 55 | @egrep -h '^[^[:blank:]].*\s##\s' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-25s\033[0m %s\n", $$1, $$2}' 56 | 57 | include ci/make/internal-targets.mk 58 | -------------------------------------------------------------------------------- /internal/schemata/providers/github/organization_settings.cue: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import "github.com/cue-examples/cue-terraform-github-config-experiment/internal/schemata/terraform" 4 | 5 | #resources: { 6 | github_organization_settings: { 7 | terraform.#resource 8 | billing_email!: string 9 | company?: string 10 | blog?: string 11 | email?: string 12 | twitter_username?: string 13 | location?: string 14 | name?: string 15 | description?: string 16 | has_organization_projects?: bool 17 | has_repository_projects?: bool 18 | default_repository_permission?: string 19 | members_can_create_repositories?: bool 20 | members_can_create_public_repositories?: bool 21 | members_can_create_private_repositories?: bool 22 | members_can_create_internal_repositories?: bool 23 | members_can_create_pages?: bool 24 | members_can_create_public_pages?: bool 25 | members_can_create_private_pages?: bool 26 | members_can_fork_private_repositories?: bool 27 | web_commit_signoff_required?: bool 28 | advanced_security_enabled_for_new_repositories?: bool 29 | dependabot_alerts_enabled_for_new_repositories?: bool 30 | dependabot_security_updates_enabled_for_new_repositories?: bool 31 | dependency_graph_enabled_for_new_repositories?: bool 32 | secret_scanning_enabled_for_new_repositories?: bool 33 | secret_scanning_push_protection_enabled_for_new_repositories?: bool 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /config/target.terraform.cue: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "regexp" 5 | ) 6 | 7 | // Our config, dynamically mutated. 8 | // We do this so that our CUE config can be authored using all CUE (naming/etc) 9 | // features (e.g. periods in resource names), whilst providing Terraform with 10 | // input that meets its more onerous constraints. 11 | 12 | X=github: _ 13 | 14 | target: terraform: { 15 | github: org: { 16 | [_]: config: resource: [_]: #Identifier.valid 17 | for org_name, org_config in X.org { 18 | (org_name): config: { 19 | resource: { 20 | for resource_type, resource_instance in org_config.config.resource { 21 | (resource_type): { 22 | for resource_identifier, resource_config in resource_instance { 23 | let new_resource_identifier = {#Identifier.adapt & {#in: resource_identifier}}.#out 24 | (new_resource_identifier): resource_config 25 | } 26 | } 27 | } 28 | } 29 | for top_level_field, value in org_config.config 30 | if top_level_field != "resource" { 31 | (top_level_field): value 32 | } 33 | } 34 | } 35 | } 36 | 37 | #Identifier: { 38 | rules: { 39 | // https://developer.hashicorp.com/terraform/language/syntax/configuration#identifiers 40 | // "Identifiers can contain letters, digits, underscores (_), and hyphens (-)" 41 | // "The first character of an identifier must not be a digit" 42 | valid_initial_characters: "-a-zA-Z_" 43 | valid_characters: valid_initial_characters + "0-9" 44 | } 45 | valid: [and(valid_constraints)]: _ 46 | valid_constraints: [ 47 | =~"^[\(rules.valid_characters)]+$", 48 | =~"^[\(rules.valid_initial_characters)]", 49 | ] 50 | adapt: { 51 | #in: string 52 | 53 | // Replace every character that's not valid in a terraform identifier with a "_" 54 | let _a = regexp.ReplaceAllLiteral("[^\(rules.valid_characters)]", #in, "_") 55 | 56 | // Replace every character that's not valid at the start of an identifier with "_", then the character 57 | let _b = regexp.ReplaceAll("^([^\(rules.valid_initial_characters)])", _a, "_$1") 58 | 59 | // Replace "-" (despite its Terraform legality), to produce nicer CUE identifiers 60 | //let _c = regexp.ReplaceAllLiteral("-", _b, "_") 61 | 62 | // We currently emit _b, not _c, because Terraform is *already* managing 63 | // some resources with identifiers containing "-" characters. We'll have 64 | // to state-migrate them if we want to use _c, or Terraform will 65 | // destroy+recreate the underlying GitHub resources. Some of these 66 | // resources are repositories, which we mustn't delete. 67 | #out: _b 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /_operations/github/fictional-meme/config.tf.json: -------------------------------------------------------------------------------- 1 | { 2 | "resource": { 3 | "github_membership": { 4 | "myitcv_owner": { 5 | "username": "myitcv", 6 | "role": "admin" 7 | }, 8 | "jpluscplusm": { 9 | "username": "jpluscplusm", 10 | "role": "member" 11 | } 12 | }, 13 | "github_organization_settings": { 14 | "self": { 15 | "billing_email": "cue-terraform-github-config-experiment-controller+billing@cue.works", 16 | "default_repository_permission": "none", 17 | "advanced_security_enabled_for_new_repositories": false, 18 | "dependabot_alerts_enabled_for_new_repositories": false, 19 | "dependabot_security_updates_enabled_for_new_repositories": false, 20 | "dependency_graph_enabled_for_new_repositories": false, 21 | "has_organization_projects": false, 22 | "has_repository_projects": false, 23 | "members_can_create_internal_repositories": false, 24 | "members_can_create_pages": false, 25 | "members_can_create_private_pages": false, 26 | "members_can_create_private_repositories": false, 27 | "members_can_create_public_pages": false, 28 | "members_can_create_public_repositories": false, 29 | "members_can_create_repositories": false, 30 | "members_can_fork_private_repositories": false, 31 | "secret_scanning_enabled_for_new_repositories": false, 32 | "secret_scanning_push_protection_enabled_for_new_repositories": false, 33 | "web_commit_signoff_required": false 34 | } 35 | }, 36 | "github_team": { 37 | "company_employees": { 38 | "name": "Company Employees Team", 39 | "description": "All company employees [terraform-managed]", 40 | "privacy": "secret", 41 | "create_default_maintainer": false 42 | } 43 | }, 44 | "github_team_membership": { 45 | "members_company_employees_team_jpluscplusm": { 46 | "team_id": "${github_team.company_employees.id}", 47 | "username": "jpluscplusm", 48 | "role": "member" 49 | } 50 | } 51 | }, 52 | "terraform": { 53 | "cloud": { 54 | "organization": "cue-terraform-github-config-experiment", 55 | "workspaces": { 56 | "tags": [ 57 | "service:github", 58 | "org:fictional-meme" 59 | ] 60 | } 61 | }, 62 | "required_providers": { 63 | "github": { 64 | "source": "integrations/github", 65 | "version": "5.25.1" 66 | } 67 | }, 68 | "required_version": "~> 1.4.6" 69 | }, 70 | "provider": { 71 | "github": { 72 | "token": "${var.provider_github_token}", 73 | "owner": "fictional-meme" 74 | } 75 | }, 76 | "variable": { 77 | "provider_github_token": { 78 | "sensitive": true, 79 | "type": "string" 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /_operations/github/supreme-octo-enigma/config.tf.json: -------------------------------------------------------------------------------- 1 | { 2 | "resource": { 3 | "github_membership": { 4 | "myitcv_owner": { 5 | "username": "myitcv", 6 | "role": "admin" 7 | }, 8 | "jpluscplusm": { 9 | "username": "jpluscplusm", 10 | "role": "member" 11 | } 12 | }, 13 | "github_organization_settings": { 14 | "self": { 15 | "billing_email": "cue-terraform-github-config-experiment-controller+billing@cue.works", 16 | "default_repository_permission": "none", 17 | "advanced_security_enabled_for_new_repositories": false, 18 | "dependabot_alerts_enabled_for_new_repositories": false, 19 | "dependabot_security_updates_enabled_for_new_repositories": false, 20 | "dependency_graph_enabled_for_new_repositories": false, 21 | "has_organization_projects": false, 22 | "has_repository_projects": false, 23 | "members_can_create_internal_repositories": false, 24 | "members_can_create_pages": false, 25 | "members_can_create_private_pages": false, 26 | "members_can_create_private_repositories": false, 27 | "members_can_create_public_pages": false, 28 | "members_can_create_public_repositories": false, 29 | "members_can_create_repositories": false, 30 | "members_can_fork_private_repositories": false, 31 | "secret_scanning_enabled_for_new_repositories": false, 32 | "secret_scanning_push_protection_enabled_for_new_repositories": false, 33 | "web_commit_signoff_required": false 34 | } 35 | }, 36 | "github_team": { 37 | "company_employees": { 38 | "name": "Company Employees Team", 39 | "description": "All company employees [terraform-managed]", 40 | "privacy": "secret", 41 | "create_default_maintainer": false 42 | } 43 | }, 44 | "github_team_membership": { 45 | "members_company_employees_team_jpluscplusm": { 46 | "team_id": "${github_team.company_employees.id}", 47 | "username": "jpluscplusm", 48 | "role": "member" 49 | } 50 | } 51 | }, 52 | "terraform": { 53 | "cloud": { 54 | "organization": "cue-terraform-github-config-experiment", 55 | "workspaces": { 56 | "tags": [ 57 | "service:github", 58 | "org:supreme-octo-enigma" 59 | ] 60 | } 61 | }, 62 | "required_providers": { 63 | "github": { 64 | "source": "integrations/github", 65 | "version": "5.25.1" 66 | } 67 | }, 68 | "required_version": "~> 1.4.6" 69 | }, 70 | "provider": { 71 | "github": { 72 | "token": "${var.provider_github_token}", 73 | "owner": "supreme-octo-enigma" 74 | } 75 | }, 76 | "variable": { 77 | "provider_github_token": { 78 | "sensitive": true, 79 | "type": "string" 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /docs/customising-this-repo.md: -------------------------------------------------------------------------------- 1 | # Customising This Repo 2 | 3 | This is a basic guide to customising this repo, and setting up the initial 4 | state it expects to find inside GitHub Actions ("GHA"). 5 | 6 | 1. Fork this repo, **and reset its state back to the very first commit**: 7 | 8 | - `git reset --hard $(git rev-list --max-parents=0 HEAD)` 9 | 10 | 1. Manually create a GitHub machine user account for the system: 11 | 12 | - this will be the account that the system will operate as, when interacting 13 | with the GitHub API 14 | 15 | 1. Decide your default GitHub Billing email: 16 | 17 | - Whilst this is overrideable per-org, choose your GitHub orgs' most 18 | frequently-used billing email to be the default 19 | 20 | 1. Manually create a Terraform Cloud organisation (or select an existing one) 21 | 22 | 1. Find the following placeholders across all files in the repo, and replace 23 | them with values appropriate to your situation. Note that most of them will 24 | be found as bareword references which deliberately break CUE 25 | (`FIXME_BILLING_EMAIL`), whereas you'll need to replace them with strings 26 | (`"billing-email@example.com"`) 27 | 28 | - `FIXME_NOTIFICATIONS_EMAIL`: a string containing the email that will 29 | receive notifications when GHA jobs fail, or when drift is detected 30 | between GitHub and your last-applied configuration 31 | 32 | - `FIXME_DISCORD_WEBHOOK_ID`: the webhook ID which will receive 33 | notifications when GHA jobs fail, or when drift is detected between GitHub 34 | and your last-applied configuration 35 | 36 | - `FIXME_BILLING_EMAIL`: a string containing the email that will be set as 37 | each GitHub org's "billing email", unless overridden by the org-level 38 | settings 39 | 40 | - `FIXME_TERRAFORM_CLOUD_ORG`: a string containing your TFC org identifier 41 | 42 | - `FIXME_MACHINE_USER_ACCOUNT_USERNAME`: the username of the GitHub machine 43 | user account described above 44 | 45 | - `FIXME_ONE_OR_MORE_INFRA_ADMIN_USERNAMES`: the `CODEOWNERS` entries for 46 | the folks who will have admin oversight of your GitHub orgs 47 | 48 | Note that until you replace these values, `make test-config` will fail and 49 | no GHA jobs will be able to run. 50 | 51 | 1. Provide the following as GHA "Repository Secrets", via the GitHub UI: 52 | 53 | - `TFC_API_TOKEN`: a Terraform Cloud ("TFC") API token, scoped to a team 54 | with access to all TFC Workspaces you'll be creating for the system to use 55 | 56 | - `GH_API_TOKEN`: a GitHub Personal Access Token ("PAT") belonging to the 57 | machine user account described above. It should have access to the 58 | following scopes: 59 | 60 | - `repo`: required to create and modify public and private repositories 61 | 62 | - `admin:org`: required to modify org-level settings, and manage org membership 63 | 64 | - `delete_repo`: **only required if you want to allow Terraform to delete repositories**. 65 | The system *will work* without access to this scope. 66 | 67 | - `GOOGLE_SMTP_USERNAME`: The static Google SMTP username used to send 68 | failure and drift notification emails 69 | 70 | - `GOOGLE_SMTP_PASSWORD`: The static Google SMTP password associated with 71 | `GOOGLE_SMTP_USERNAME` 72 | 73 | - `DISCORD_WEBHOOK_TOKEN`: The static Discord webhook token which grants 74 | posting access to the webhook *ID* that you inserted in place of 75 | `FIXME_DISCORD_WEBHOOK_ID`, above 76 | 77 | 1. Enable GitHub Actions on the repository's Actions tab on the GitHub UI 78 | 79 | 1. Commit all the above file changes and `git push --force-with-lease` to your 80 | fork 81 | -------------------------------------------------------------------------------- /generate_tool.cue: -------------------------------------------------------------------------------- 1 | package infrastructure 2 | 3 | import ( 4 | "path" 5 | "tool/file" 6 | "tool/exec" 7 | "encoding/json" 8 | "encoding/yaml" 9 | "strings" 10 | 11 | "github.com/cue-examples/cue-terraform-github-config-experiment/config" 12 | ) 13 | 14 | _goos: string @tag(os,var=os) 15 | 16 | command: { 17 | gen_terraform: { 18 | let json_indent = " " & strings.MinRunes(4) & strings.MaxRunes(4) 19 | let dir_operations = path.FromSlash("_operations/github", path.Unix) 20 | let file_tf_json = "config.tf.json" 21 | let file_lockfile = ".terraform.lock.hcl" 22 | 23 | remove: { 24 | glob: file.Glob & { 25 | glob: path.Join([dir_operations, "*", "*.tf.json"], _goos) 26 | files: [...string] 27 | } 28 | for _, _filename in glob.files { 29 | "delete \(_filename)": file.RemoveAll & { 30 | path: _filename 31 | } 32 | } 33 | } 34 | 35 | orgs: { 36 | for orgName, orgTerraform in config.target.terraform.github.org 37 | let dir_org = path.Join([dir_operations, orgName], _goos) 38 | let dir_org_tmp = path.Join([dir_org, "tmp"], _goos) 39 | let file_org_config = path.Join([dir_org, file_tf_json], _goos) 40 | let file_org_tmp_gitkeep = path.Join([dir_org_tmp, ".gitkeep"], _goos) 41 | let file_org_lockfile = path.Join([dir_org, file_lockfile], _goos) 42 | let task_mkdir_org_tmp_id = "mkdir \(dir_org_tmp)" { 43 | (task_mkdir_org_tmp_id): file.Mkdir & { 44 | $after: [ for v in remove {v}] 45 | path: dir_org_tmp 46 | createParents: true 47 | } 48 | "generate config \(file_org_config)": file.Create & { 49 | $after: [ orgs[task_mkdir_org_tmp_id]] 50 | filename: file_org_config 51 | contents: json.Indent(json.Marshal(orgTerraform.config), "", json_indent) + "\n" 52 | } 53 | "generate \(file_org_tmp_gitkeep)": file.Create & { 54 | $after: [ orgs[task_mkdir_org_tmp_id]] 55 | filename: file_org_tmp_gitkeep 56 | contents: "" 57 | } 58 | "symlink \(file_org_lockfile)": exec.Run & { 59 | $after: [ orgs[task_mkdir_org_tmp_id]] 60 | let target = path.Join(["..", ".terraform_lockfile", file_lockfile], _goos) 61 | dir: dir_org 62 | // the single param form of `ln -s` creates a file in CWD, named after the target file 63 | cmd: [ "ln", "-nfs", target] 64 | success: true 65 | } 66 | } 67 | } 68 | 69 | lockfile: file.Create & { 70 | $after: [ for v in remove {v}] 71 | filename: path.Join([dir_operations, ".terraform_lockfile", file_tf_json]) 72 | let tf = { 73 | terraform: config.github.terraform 74 | } 75 | contents: json.Indent(json.Marshal(tf), "", json_indent) + "\n" 76 | } 77 | } 78 | 79 | gen_ci: { 80 | github: { 81 | let dir_gha = path.FromSlash(".github/workflows", path.Unix) 82 | mkdir: file.Mkdir & { 83 | path: dir_gha 84 | createParents: true 85 | } 86 | remove: { 87 | glob: file.Glob & { 88 | $after: mkdir 89 | glob: path.Join([dir_gha, "*.yml"], _goos) 90 | files: [...string] 91 | } 92 | for _, _filename in glob.files { 93 | "delete \(_filename)": file.RemoveAll & { 94 | path: _filename 95 | } 96 | } 97 | } 98 | workflows: { 99 | let warning = "# Code generated by generate_tool.cue - DO NOT EDIT." 100 | for workflow_file_prefix, workflow_content in config.github.actions.workflow 101 | let _filename = workflow_file_prefix + ".yml" { 102 | "generate \(_filename)": file.Create & { 103 | $after: [ for v in remove {v}] 104 | filename: path.Join([dir_gha, _filename], _goos) 105 | contents: "\(warning)\n\(yaml.Marshal(workflow_content))" 106 | } 107 | } 108 | } 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /config/templates.cue: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // Resources and settings that are templated into all orgs 4 | 5 | // All github_repository resources have identical CUE identifiers and 6 | // user-visible names on GitHub 7 | github: org: [_]: config: resource: { 8 | github_repository?: [Name=string]: { 9 | name: Name 10 | } 11 | } 12 | // Every CUE-addressable resource gets a pair of fields added: 13 | // - #tfid: the translated identifier (e.g. `_foo_repo_xyz`) 14 | // - #tfref: the path by which it can be addressed via Terraform's 15 | // runtime expression references (e.g. `github_repository._foo_repo_xyz`) 16 | github: org: [_]: config: resource: { 17 | [resource_type=_]: [cue_resource_name=string]: { 18 | #tfid: "\({target.terraform.#Identifier.adapt & {#in: cue_resource_name}}.#out)" 19 | #tfref: "\(resource_type).\(#tfid)" 20 | } 21 | } 22 | 23 | // Orgs that set config._all_company_employees_are_org_members template 24 | // resources across several different resource types 25 | github: org: [_]: config: resource: { 26 | if config._all_company_employees_are_org_members { 27 | // Add all employees to the org's membership 28 | github_membership: { 29 | for name, employee in company.employees 30 | let id = employee.login.github { 31 | (id): { 32 | username: id 33 | role: *"member" | "admin" 34 | } 35 | } 36 | } 37 | // Create the team in which all employees will be *members|maintainers 38 | github_team: company_employees: { 39 | name: "Company Employees Team" 40 | description: "All company employees [terraform-managed]" 41 | privacy: "secret" 42 | create_default_maintainer: false 43 | } 44 | // Add all employees to the employee team 45 | github_team_membership: { 46 | for name, employee in company.employees 47 | let id = employee.login.github { 48 | "members_company_employees_team_\(id)": { 49 | team_id: "${\(github_team.company_employees.#tfref).id}" 50 | username: id 51 | role: *"member" | "maintainer" 52 | } 53 | } 54 | } 55 | 56 | // all repos grant access to the company employees team 57 | if resource.github_repository != _|_ { 58 | for repo_name, _ in resource.github_repository { 59 | github_repository_collaborators: (repo_name): { 60 | repository: repo_name 61 | team: [{ 62 | team_id: "${ \( resource.github_team.company_employees.#tfref ).slug }" 63 | permission: *"push" | string 64 | }] 65 | depends_on: [ resource.github_repository[repo_name].#tfref] 66 | } 67 | } 68 | } 69 | } 70 | } 71 | 72 | // For each type of _non_org_member_access that exists, grant the appropriate 73 | // access to the username specified 74 | github: org: [_]: config: resource: { 75 | 76 | _non_org_member_access: _ 77 | // `_non_org_member_access` gives us a user-centric view of outside 78 | // collaborators' access permissions, which is better for audit and ops-y 79 | // purposes. 80 | // `github_repository_collaborators` needs a repo-centric view to assemble 81 | // its `user` list of permissions, and that's `inverted_access_struct`. 82 | // We'll probably be able to instantiate our 83 | // `github_repository_collaborators` resources inside *this* loop (and delete 84 | // the loop below this one) as & when & if CUE lists ever become open. 85 | // But, right now, we need to create this struct to be iterated over below. 86 | let inverted_access_struct = { 87 | for access_type, access_list in _non_org_member_access 88 | for user_name, user_access in access_list 89 | for repo_name, user_permission in user_access { 90 | (repo_name): (user_name): user_permission 91 | } 92 | } 93 | 94 | for repo_name, access_list in inverted_access_struct { 95 | github_repository_collaborators: (repo_name): { 96 | repository: repo_name 97 | user: [ for user_name, user_permission in access_list { 98 | { 99 | username: user_name 100 | permission: user_permission 101 | } 102 | }] 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /.github/workflows/detect-drift.main-branch.scheduled.yml: -------------------------------------------------------------------------------- 1 | # Code generated by generate_tool.cue - DO NOT EDIT. 2 | name: Detect drift 3 | "on": 4 | schedule: 5 | - cron: 30 7 * * 1-5 6 | workflow_dispatch: {} 7 | jobs: 8 | Alert: 9 | name: Report drift 10 | needs: 11 | - Drift-fictional-meme 12 | - Drift-supreme-octo-enigma 13 | if: ${{ failure() }} 14 | runs-on: ubuntu-20.04 15 | defaults: 16 | run: 17 | working-directory: . 18 | steps: 19 | - name: Notify Discord 20 | if: ${{ always() }} 21 | run: |- 22 | curl \ 23 | --no-progress-meter \ 24 | -d content=":x: Infrastructure drift detected: [Workflow]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID)" \ 25 | -d username="Github Actions" \ 26 | "https://discord.com/api/webhooks/1086214516129398814/${{ secrets.DISCORD_WEBHOOK_TOKEN }}" 27 | - name: Notify Email 28 | if: ${{ always() }} 29 | run: |- 30 | sudo apt-get install -qq swaks 31 | swaks \ 32 | -tls \ 33 | --server smtp.gmail.com:587 \ 34 | --auth LOGIN \ 35 | --auth-user "${{ secrets.GOOGLE_SMTP_USERNAME }}" \ 36 | --auth-password "${{ secrets.GOOGLE_SMTP_PASSWORD }}" \ 37 | --h-Subject "Infrastructure drift detected" \ 38 | --body "Infrastructure drift detected: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \ 39 | --to "cue-terraform-github-config-experiment-controller+notifications@cue.works" 40 | env: 41 | CUE_DEBUG_SCRIPTS: ${{ vars.CUE_DEBUG_SCRIPTS }} 42 | Drift-fictional-meme: 43 | name: 'Detect drift: fictional-meme' 44 | runs-on: ubuntu-20.04 45 | concurrency: terraform-state-lock-org_fictional-meme 46 | permissions: 47 | contents: read 48 | defaults: 49 | run: 50 | working-directory: . 51 | steps: 52 | - name: Check out code 53 | uses: actions/checkout@v3 54 | if: ${{ success() }} 55 | - name: Setup Terraform 56 | uses: hashicorp/setup-terraform@v2 57 | with: 58 | terraform_version: 1.4.6 59 | cli_config_credentials_token: ${{ secrets.TFC_API_TOKEN }} 60 | terraform_wrapper: false 61 | if: ${{ success() }} 62 | - name: Initialize Terraform state/plugins/backend 63 | run: make ci_tf_init ORG=fictional-meme 64 | if: ${{ success() }} 65 | - name: Detect drift 66 | env: 67 | TF_VAR_provider_github_token: ${{ secrets.GH_API_TOKEN }} 68 | run: make ci_tf_plan ORG=fictional-meme DRIFT_DETECTION=1 69 | if: ${{ success() }} 70 | env: 71 | CUE_DEBUG_SCRIPTS: ${{ vars.CUE_DEBUG_SCRIPTS }} 72 | Drift-supreme-octo-enigma: 73 | name: 'Detect drift: supreme-octo-enigma' 74 | runs-on: ubuntu-20.04 75 | concurrency: terraform-state-lock-org_supreme-octo-enigma 76 | permissions: 77 | contents: read 78 | defaults: 79 | run: 80 | working-directory: . 81 | steps: 82 | - name: Check out code 83 | uses: actions/checkout@v3 84 | if: ${{ success() }} 85 | - name: Setup Terraform 86 | uses: hashicorp/setup-terraform@v2 87 | with: 88 | terraform_version: 1.4.6 89 | cli_config_credentials_token: ${{ secrets.TFC_API_TOKEN }} 90 | terraform_wrapper: false 91 | if: ${{ success() }} 92 | - name: Initialize Terraform state/plugins/backend 93 | run: make ci_tf_init ORG=supreme-octo-enigma 94 | if: ${{ success() }} 95 | - name: Detect drift 96 | env: 97 | TF_VAR_provider_github_token: ${{ secrets.GH_API_TOKEN }} 98 | run: make ci_tf_plan ORG=supreme-octo-enigma DRIFT_DETECTION=1 99 | if: ${{ success() }} 100 | env: 101 | CUE_DEBUG_SCRIPTS: ${{ vars.CUE_DEBUG_SCRIPTS }} 102 | env: 103 | TF_IN_AUTOMATION: yes it is 104 | TF_INPUT: 0 105 | concurrency: 106 | group: terraform-state-lock 107 | cancel-in-progress: false 108 | -------------------------------------------------------------------------------- /ci/make/internal-targets.mk: -------------------------------------------------------------------------------- 1 | # FILE_TF_PLAN_RELATIVE is the plan's file path+name that terraform uses, *after* its `-chdir` param has been obeyed. 2 | FILE_TF_PLAN_RELATIVE:=tmp/tfplan.zip 3 | # FILE_TF_PLAN is the plan's file path+name that everything *other* than terraform uses. 4 | FILE_TF_PLAN=$(DIR_TF)/$(FILE_TF_PLAN_RELATIVE) 5 | 6 | FILE_TF_INIT=$(DIR_TF)/.terraform/terraform.tfstate 7 | 8 | # The 4 following FILE_TF_PLAN_* variables are used in the process that generates PR comments from terraform ouput. 9 | FILE_TF_PLAN_ERR=$(FILE_TF_PLAN).err 10 | FILE_TF_PLAN_TXT=$(FILE_TF_PLAN).txt 11 | FILE_TF_PLAN_DIFF=$(FILE_TF_PLAN).diff 12 | FILE_TF_PLAN_DIFF_MD=$(FILE_TF_PLAN).diff.md 13 | 14 | # A make-ism to SPOT the check for makevars (`make foo VAR=1`) not being set, and a noisy error. 15 | check_var_defined=$(if $(strip $($1)),,$(error "$1" is not defined)) 16 | 17 | ################################################################ 18 | ## Internal targets ############################################ 19 | ################################################################ 20 | 21 | lockfile_INTERNAL_init_upgrade: ORG=.terraform_lockfile 22 | lockfile_INTERNAL_init_upgrade: # This target is present for internal sequencing only. Don't run it manually 23 | $(CMD_TF) init -upgrade 24 | 25 | CMD_TF_PLAN=$(CMD_TF) plan -input=false -no-color -out="$(FILE_TF_PLAN_RELATIVE)" 2> >(tee "$(FILE_TF_PLAN_ERR)" >&2) 26 | $(FILE_TF_PLAN): $(FILE_TF_INIT) 27 | $(FILE_TF_PLAN): 28 | $(call check_var_defined,ORG) 29 | ifdef DRIFT_DETECTION # in CI, indicating that a scheduled drift-detection job is running 30 | $(CMD_TF_PLAN) -detailed-exitcode 31 | else # all other cases: fall back to checking the most recent commit's message body for the magic no-op string 32 | @set -x; if git log --format=%b -1 | grep -q ^TERRAFORM-PLAN-NO-OP-REQUIRED; \ 33 | then $(CMD_TF_PLAN) -detailed-exitcode ;\ 34 | else $(CMD_TF_PLAN) ;\ 35 | fi 36 | endif 37 | 38 | $(FILE_TF_INIT): 39 | $(call check_var_defined,ORG) 40 | $(CMD_TF) init -no-color 41 | 42 | # Make needs to ".IGNORE" errors whilst creating the following targets, because 43 | # any such errors need to be surfaced in the resulting text files that the 44 | # recipes generate and not the Make invocation's error messages, which will be 45 | # hidden away in CI job logs. 46 | # NB don't expand this list without thinking it through *very* carefully. 47 | .IGNORE: $(FILE_TF_PLAN_TXT) $(FILE_TF_PLAN_DIFF) $(FILE_TF_PLAN_DIFF_MD) 48 | $(FILE_TF_PLAN_TXT): $(FILE_TF_PLAN) 49 | $(FILE_TF_PLAN_TXT): # Create a plaintext version of the current plan 50 | $(call check_var_defined,ORG) 51 | $(CMD_TF) show -no-color $(FILE_TF_PLAN_RELATIVE) >"$@" 2> >(tee $@.err >&2) 52 | $(FILE_TF_PLAN_DIFF): $(FILE_TF_PLAN_TXT) 53 | $(FILE_TF_PLAN_DIFF): # Turn the plaintext plan into a diff-alike version, suitable for GitHub PR comments 54 | # Create the aggregate of: 55 | # - the plan (FILE_TF_PLAN_TXT) 56 | # - problems encountered generating the plan (FILE_TF_PLAN_ERR) 57 | # - problems /parsing/ the plan (FILE_TF_PLAN_TXT.err) 58 | # ... and make sure there's a newline between each file's contents. 59 | # Then format the aggregate as per https://github.com/github/markup/issues/1440#issuecomment-803889380. (sed) 60 | # Remove any trailing blank lines. (`tac|awk|tac` hack) 61 | for file in $(FILE_TF_PLAN_TXT) $(FILE_TF_PLAN_ERR) $(FILE_TF_PLAN_TXT).err ; do cat $$file 2>&1; echo; done \ 62 | | sed -E --file=ci/misc/tf-plan-to-diff.sed \ 63 | | tac | awk 'NF{x=1};NF+x' | tac \ 64 | >"$@" 65 | $(FILE_TF_PLAN_DIFF_MD): $(FILE_TF_PLAN_DIFF) 66 | $(FILE_TF_PLAN_DIFF_MD): # Make a markdown-ish file from the plan diff contents 67 | cat ci/misc/tf-plan-to-diff.envsubst | envsubst >"$@" 68 | { cat "$^"; echo '```'; } >>"$@" 69 | 70 | ################################################################ 71 | ## CI convenience shims ######################################## 72 | ################################################################ 73 | 74 | ci_tf_init: $(FILE_TF_INIT) 75 | 76 | ci_tf_validate: $(FILE_TF_INIT) 77 | ci_tf_validate: 78 | $(call check_var_defined,ORG) 79 | $(CMD_TF) validate -no-color 80 | 81 | ci_tf_plan: $(FILE_TF_PLAN) 82 | 83 | DANGER_ci_tf_apply: $(FILE_TF_INIT) 84 | $(call check_var_defined,ORG) 85 | ifndef REALLY_DO_RUN_TERRAFORM_APPLY 86 | $(error This command is dangerous, and should only be run inside CI) 87 | endif 88 | $(CMD_TF) apply -no-color -auto-approve > >(tee $(FILE_TF_APPLY_STDOUT)) 2> >(tee $(FILE_TF_APPLY_STDERR >&2)) 89 | 90 | MAKEFLAGS += --warn-undefined-variables \ 91 | --no-builtin-rules \ 92 | --no-builtin-variables \ 93 | --no-print-directory 94 | -------------------------------------------------------------------------------- /config/schema.cue: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | provider_github "github.com/cue-examples/cue-terraform-github-config-experiment/internal/schemata/providers/github" 5 | ) 6 | 7 | // Schemata imposed internally (by this system) and externally (by Terraform 8 | // and its components) 9 | 10 | //////////////////////////////////////////////////////////////// 11 | // Constraints implicitly imposed by this project's CUE use //// 12 | //////////////////////////////////////////////////////////////// 13 | 14 | company?: #company 15 | #company: employees?: [string]: login: github!: string 16 | 17 | versions?: #versions 18 | #versions: { 19 | terraform?: { 20 | core?: #version 21 | providers?: [string]: #version 22 | } 23 | github?: actions?: [string]: #version 24 | cue?: #version 25 | } 26 | #version: string 27 | 28 | github?: actions?: workflow?: [FilenameWithoutDotYmlSuffix=string]: _ 29 | 30 | // github.org.[_] contains information for a single GitHub Organization 31 | github?: org?: [OrgName=string]: config?: #terraform_input & { 32 | _all_company_employees_are_org_members!: bool 33 | resource?: { 34 | github_repository?: [Name=_]: name!: Name 35 | github_repository_collaborators?: [Name=string]: repository!: Name 36 | github_organization_settings?: self?: _ 37 | // Collaborator and bot access (currently) have distinct Terraform identifiers, 38 | // which requires them to be distinguished. 39 | _non_org_member_access?: { 40 | collaborator?: #access_list 41 | bot?: #access_list 42 | #access_list: { 43 | [Username=string]: [Repo=string]: "pull" | "push" | "triage" 44 | #ID: string 45 | } 46 | } 47 | } 48 | } 49 | 50 | // target.terraform.github.org.[_] is the Terraform-acceptable input for a 51 | // single GitHub Organization 52 | target?: terraform?: github?: org?: [string]: config?: #terraform_input & { 53 | resource?: [string]: #terraform_resource 54 | // https://developer.hashicorp.com/terraform/language/syntax/configuration#identifiers 55 | #terraform_resource: [=~"^[-a-zA-Z_][-a-zA-Z_0-9]*$"]: _ 56 | } 57 | 58 | //////////////////////////////////////////////////////////////// 59 | // Terraform-defined input schema, collated from several docs // 60 | //////////////////////////////////////////////////////////////// 61 | #terraform_input: {// https://developer.hashicorp.com/terraform/language/syntax/json 62 | resource?: {// https://developer.hashicorp.com/terraform/language/resources/syntax 63 | [ResourceType=string]: [ResourceIdentifier=string]: { 64 | #resource_meta_arguments 65 | ... 66 | } 67 | github_membership?: [_]: provider_github.#resources.github_membership 68 | github_organization_settings?: [_]: provider_github.#resources.github_organization_settings 69 | github_repository_collaborators?: [_]: provider_github.#resources.github_repository_collaborators 70 | github_team?: [_]: provider_github.#resources.github_team 71 | github_team_membership?: [_]: provider_github.#resources.github_team_membership 72 | github_team_repository?: [_]: provider_github.#resources.github_team_repository 73 | github_repository?: [_]: provider_github.#resources.github_repository 74 | } 75 | 76 | terraform?: {// https://developer.hashicorp.com/terraform/language/settings 77 | cloud?: {// https://developer.hashicorp.com/terraform/cli/cloud/settings#the-cloud-block 78 | organization!: string 79 | workspaces?: { 80 | tags!: [string, ...string] 81 | } | { 82 | name!: string 83 | } 84 | hostname?: string 85 | token?: string 86 | } 87 | required_providers?: [ProviderName=string]: {// https://developer.hashicorp.com/terraform/language/providers/requirements#requiring-providers 88 | source?: string 89 | version?: string 90 | } 91 | required_version?: string 92 | } 93 | 94 | provider?: [ProviderName=string]: {// https://developer.hashicorp.com/terraform/language/providers/configuration 95 | alias?: string 96 | ... 97 | } 98 | 99 | variable?: [Name=string]: {// https://developer.hashicorp.com/terraform/language/values/variables 100 | default?: _ 101 | type?: string 102 | description?: string 103 | validation?: _ 104 | sensitive?: bool 105 | nullable?: bool 106 | } 107 | 108 | moved?: [ ...{from: string, to: string}] 109 | 110 | #resource_meta_arguments: { 111 | for_each?: _ // https://developer.hashicorp.com/terraform/language/meta-arguments/for_each 112 | count?: int // https://developer.hashicorp.com/terraform/language/meta-arguments/count 113 | provider?: string // https://developer.hashicorp.com/terraform/language/meta-arguments/resource-provider 114 | lifecycle?: {// https://developer.hashicorp.com/terraform/language/meta-arguments/lifecycle 115 | create_before_destroy?: bool 116 | prevent_destroy?: bool 117 | ignore_changes?: [...string] 118 | replace_triggered_by?: [...string] 119 | #condition?: {// https://developer.hashicorp.com/terraform/language/expressions/custom-conditions 120 | condition!: string 121 | error_message!: string 122 | } 123 | precondition?: #condition 124 | postcondition?: #condition 125 | } 126 | depends_on?: [...string] // https://developer.hashicorp.com/terraform/language/meta-arguments/depends_on 127 | provisioner?: [string]: {...} // https://developer.hashicorp.com/terraform/language/resources/provisioners/syntax 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /.github/workflows/test.PR-branch.yml: -------------------------------------------------------------------------------- 1 | # Code generated by generate_tool.cue - DO NOT EDIT. 2 | name: Test proposed changes 3 | "on": 4 | pull_request: 5 | branches: 6 | - main 7 | types: 8 | - opened 9 | - synchronize 10 | - reopened 11 | jobs: 12 | Test-Shared-Components: 13 | name: Test shared components 14 | runs-on: ubuntu-20.04 15 | needs: 16 | - Test-fictional-meme 17 | - Test-supreme-octo-enigma 18 | defaults: 19 | run: 20 | working-directory: . 21 | steps: 22 | - name: Check out code 23 | uses: actions/checkout@v3 24 | if: ${{ success() }} 25 | - name: Setup CUE 26 | uses: cue-lang/setup-cue@0be332bb74c8a2f07821389447ba3163e2da3bfb 27 | with: 28 | version: v0.6.0-beta.1 29 | if: ${{ success() }} 30 | - name: Test that all generated files match their CUE sources 31 | run: make generate check_clean_working_tree 32 | - name: Test the unified config 33 | run: make test-config 34 | env: 35 | CUE_DEBUG_SCRIPTS: ${{ vars.CUE_DEBUG_SCRIPTS }} 36 | Test-fictional-meme: 37 | name: 'Test: fictional-meme' 38 | runs-on: ubuntu-20.04 39 | concurrency: terraform-state-lock-org_fictional-meme 40 | permissions: 41 | contents: read 42 | pull-requests: write 43 | defaults: 44 | run: 45 | working-directory: . 46 | steps: 47 | - name: Check out code 48 | uses: actions/checkout@v3 49 | with: 50 | ref: ${{ github.event.pull_request.head.sha }} 51 | if: ${{ success() }} 52 | - name: Setup CUE 53 | uses: cue-lang/setup-cue@0be332bb74c8a2f07821389447ba3163e2da3bfb 54 | with: 55 | version: v0.6.0-beta.1 56 | if: ${{ success() }} 57 | - name: Setup Terraform 58 | uses: hashicorp/setup-terraform@v2 59 | with: 60 | terraform_version: 1.4.6 61 | cli_config_credentials_token: ${{ secrets.TFC_API_TOKEN }} 62 | terraform_wrapper: false 63 | if: ${{ success() }} 64 | - name: Initialize Terraform state/plugins/backend 65 | run: make ci_tf_init ORG=fictional-meme 66 | if: ${{ success() }} 67 | - name: Validate terraform input 68 | run: make ci_tf_validate ORG=fictional-meme 69 | if: ${{ success() }} 70 | - name: Serialise Terraform's plan 71 | run: make ci_tf_plan ORG=fictional-meme 72 | env: 73 | TF_VAR_provider_github_token: ${{ secrets.GH_API_TOKEN }} 74 | if: ${{ success() }} 75 | - if: (success() || failure()) 76 | name: Reformat plan output as diff 77 | run: make _operations/github/fictional-meme/tmp/tfplan.zip.diff.md ORG=fictional-meme COMMIT_ID=${{ github.sha }} 78 | - if: (success() || failure()) 79 | name: Post plan diff to GitHub PR 80 | uses: mshick/add-pr-comment@v2.6.1 81 | with: 82 | status: ${{ job.status }} 83 | allow-repeats: true 84 | message-path: _operations/github/fictional-meme/tmp/tfplan.zip.diff.md 85 | message-failure: |- 86 | # test plan FAILURE: `fictional-meme` 87 | 88 | Check GitHub Actions job output for more details. 89 | env: 90 | CUE_DEBUG_SCRIPTS: ${{ vars.CUE_DEBUG_SCRIPTS }} 91 | Test-supreme-octo-enigma: 92 | name: 'Test: supreme-octo-enigma' 93 | runs-on: ubuntu-20.04 94 | concurrency: terraform-state-lock-org_supreme-octo-enigma 95 | permissions: 96 | contents: read 97 | pull-requests: write 98 | defaults: 99 | run: 100 | working-directory: . 101 | steps: 102 | - name: Check out code 103 | uses: actions/checkout@v3 104 | with: 105 | ref: ${{ github.event.pull_request.head.sha }} 106 | if: ${{ success() }} 107 | - name: Setup CUE 108 | uses: cue-lang/setup-cue@0be332bb74c8a2f07821389447ba3163e2da3bfb 109 | with: 110 | version: v0.6.0-beta.1 111 | if: ${{ success() }} 112 | - name: Setup Terraform 113 | uses: hashicorp/setup-terraform@v2 114 | with: 115 | terraform_version: 1.4.6 116 | cli_config_credentials_token: ${{ secrets.TFC_API_TOKEN }} 117 | terraform_wrapper: false 118 | if: ${{ success() }} 119 | - name: Initialize Terraform state/plugins/backend 120 | run: make ci_tf_init ORG=supreme-octo-enigma 121 | if: ${{ success() }} 122 | - name: Validate terraform input 123 | run: make ci_tf_validate ORG=supreme-octo-enigma 124 | if: ${{ success() }} 125 | - name: Serialise Terraform's plan 126 | run: make ci_tf_plan ORG=supreme-octo-enigma 127 | env: 128 | TF_VAR_provider_github_token: ${{ secrets.GH_API_TOKEN }} 129 | if: ${{ success() }} 130 | - if: (success() || failure()) 131 | name: Reformat plan output as diff 132 | run: make _operations/github/supreme-octo-enigma/tmp/tfplan.zip.diff.md ORG=supreme-octo-enigma COMMIT_ID=${{ github.sha }} 133 | - if: (success() || failure()) 134 | name: Post plan diff to GitHub PR 135 | uses: mshick/add-pr-comment@v2.6.1 136 | with: 137 | status: ${{ job.status }} 138 | allow-repeats: true 139 | message-path: _operations/github/supreme-octo-enigma/tmp/tfplan.zip.diff.md 140 | message-failure: |- 141 | # test plan FAILURE: `supreme-octo-enigma` 142 | 143 | Check GitHub Actions job output for more details. 144 | env: 145 | CUE_DEBUG_SCRIPTS: ${{ vars.CUE_DEBUG_SCRIPTS }} 146 | env: 147 | TF_IN_AUTOMATION: yes it is 148 | TF_INPUT: 0 149 | concurrency: 150 | group: terraform-state-lock 151 | cancel-in-progress: false 152 | -------------------------------------------------------------------------------- /docs/managing-existing-resources.md: -------------------------------------------------------------------------------- 1 | # Managing Existing GitHub Resources 2 | 3 | ## Introduction 4 | 5 | Terraform aligns closely with the CRUD cycle of infrastructure management, with 6 | a particular emphasis on "Create" - it *really* wants to manage resources that 7 | it originally created, and making it manage resources that it *didn't* create 8 | is fiddly at best. 9 | 10 | In order for Terraform to control GitHub resources that already exist, they 11 | must be "imported" and placed under Terraform's complete control. There is no 12 | concept of a "partially" managed resource. 13 | 14 | (This process *might* be simplified significantly if Terraform 1.5's "import 15 | blocks" end up being useable in our system. See the 16 | [1.5.0-rc2 release notes](https://github.com/hashicorp/terraform/releases/tag/v1.5.0-rc2) 17 | for an indication of their intended capabilities, but keep in mind they haven't 18 | been tested in our system in any way.) 19 | 20 | ## Maybe Don't Do This? 21 | 22 | To manage an existing resource with Terraform, **first consider *not* importing 23 | an existing resource**. Importing a resource is a fiddly and risky operation, 24 | which should be avoided if at all possible. Whilst this document looks long and 25 | perhaps slightly intimidating, the actual process is quick enough once 26 | understood. Having said that, the *risks* outlined below don't diminish, even 27 | with practice. 28 | 29 | If it might be acceptable to move forward by deleting the resource and 30 | configuring Terraform to create it from scratch, then choose that option. This 31 | strategy will be more suitable for stateless resources such as team memberships 32 | and branch protection rules and less suitable for repos, but it should be your 33 | preferred option when available. 34 | 35 | ## Setup 36 | 37 | To safely import an existing resource, you will need exclusive write access to 38 | this repo for the duration of this process. Arrange this with your colleagues, 39 | with no PRs being opened, updated, **or merged(!!!)** by anyone except 40 | yourself. Avoid clashing with the drift detection job's 41 | [scheduled execution time](/.github/workflows/detect-drift.main-branch.scheduled.yml#L5). 42 | 43 | The first time you perform an import, **strongly** prefer pairing with someone 44 | who has done this before. The worst-case scenario for getting something wrong 45 | in this processs is that **the resource you're importing will be deleted**. As 46 | we'll see, this is a function of Terraform's state file behaviour, not of this 47 | system specifically. 48 | 49 | Read and follow the 50 | [Running Terraform Locally](/README/md#running-terraform-locally) 51 | section of the main README. You will be invoking Terraform locally several 52 | times during this process. All the security warnings found in that section 53 | apply here. 54 | 55 | Read the next section, "Configuration", through to the end. Don't start without 56 | having read it at least once, so you know where the dangerous areas are, why 57 | they exist, and how you'll avoid the problems they describe. 58 | 59 | ## Configuration 60 | 61 | Start off with a pristine checkout of this repo, on the `main` branch. **Make 62 | sure your local `main` branch is up to date with the remote**. Ensure your 63 | colleagues know that they *must not* open, update, **or merge** any PRs from 64 | this moment onwards, until you reach the end of this process and let them know. 65 | 66 | Create a feature branch off `main`. This process will complete when you have 67 | merged this branch via PR, with `main` branch CI jobs passing and asserting 68 | that your commit was a no-op ... but *the sequence of operations getting to 69 | that point is **critical**.* 70 | 71 | Add a reasonable guess at the current state of the resource to be imported into 72 | the right place in the CUE `config` package - almost certainly inside a 73 | `config/org.*.cue` file. For a repo, this might only include its resource 74 | path/name and visibility level - you don't *need* to get anything correct, yet, 75 | **except the resource's path which is *critical***. Changing the path 76 | mid-import (i.e. after having run `terraform import`) is outside the scope of 77 | this guide, and if you discover you need to do so, reach out for support 78 | *immediately*. 79 | 80 | Run 81 | 82 | ```shell 83 | make generate 84 | ``` 85 | 86 | to reflect your CUE change in the appropriate 87 | `_operations/github/*/config.tf.json` static config file. 88 | 89 | Use 90 | 91 | ```shell 92 | git status 93 | ``` 94 | 95 | to confirm that the only change inside `_operations/` is in the 96 | `config.tf.json` file of the GitHub org that already owns the resource to be 97 | imported. (Importing is *not* a cross-org activity, and cannot be used to 98 | change resource ownwership.) 99 | 100 | With the 101 | [required credentials available](/README.md#running-terraform-locally), run 102 | 103 | ```shell 104 | make test-plan ORG= 105 | ``` 106 | 107 | to set up Terraform for your import operation. Don't worry that the plan output 108 | says it would *create* the resource that you're importing: we're only using 109 | this Makefile target for its ability to correctly initialise Terraform for that 110 | org - that plan output won't be enacted. 111 | 112 | Change into the importing org's directory inside `_operations/github/`. Check 113 | that you see your changes reflected in `config.tf.json`. 114 | 115 | Open the 116 | [Terraform documentation for the `github` provider](https://registry.terraform.io/providers/integrations/github/5.25.1/docs). 117 | Make sure you're reading the docs for the version of the provider we're using, 118 | as set in `config/manifest.cue` at path `versions.terraform.providers.github`. 119 | 120 | Find the docs for the resource type that you're importing. e.g. 121 | [`github_repository`](https://registry.terraform.io/providers/integrations/github/5.25.1/docs/resources/repository). 122 | Find the "Import" section (it's usually at the bottom of the page), and find 123 | out how the import needs to be specified **for this specific resource type**. 124 | 125 | The documentation should show you 4 components of the requisite `terraform 126 | import` command, and should explain how the 4th component must be constructed. 127 | 128 | However, the docs are often poorly constructed, with confusing names chosen for 129 | the example import operations. The docs for the `github_repository` resource 130 | type contain especially poor examples so, next, we'll over-explain the command 131 | components so it's clear what you're doing. 132 | 133 | These are the components of the import command you'll see on the resource type 134 | documentation page: 135 | 136 | 1. `terraform`: the path to the Terraform binary you have available locally. 137 | 138 | Its version must adhere to the constraints set in `config/manifest.cue` at 139 | path `versions.terraform.core` 140 | 141 | 1. `import`: the import sub-command 142 | 143 | 1. `github_.`: this string is the 144 | *Terraform* path in your config by which the resource can be reached **inside 145 | the `resource` struct**. It is *critical* 146 | 147 | Consult the `config.tf.json` file to ensure you use the string matching that 148 | which CUE has exported *precisely*. `` might have 149 | been translated during export into something meeting Terraform's naming 150 | constraints. 151 | 152 | **Don't assume it's the same name that you used in your *CUE* config** 153 | 154 | 1. ``: this string is the key by which the 155 | provider will uniquely identify the to-be-imported resource remotely, inside 156 | GitHub 157 | 158 | Sometimes it's simple (e.g. at time of writing, a `github_repository` is 159 | imported via its name alone), and sometimes it's multi-faceted (e.g. an 160 | individual's `github_membership` currently uses org name and member name, 161 | with a separator) 162 | 163 | Read the resource type docs to identify how this component has to be 164 | constructed 165 | 166 | **There is no Terraform CLI validation mechanism for components #3 and #4**. 167 | Don't get them wrong. 168 | 169 | Construct your import command with its 4 components as detailed above. 170 | 171 | ## Critical Region 172 | 173 | From this next step, until your PR is merged and its `main` branch jobs pass, 174 | if `terraform apply` gets invoked anywhere, in CI or by another user, on any 175 | branch not containing your config changes, **then that other Terraform 176 | invocation will destroy the resource you have imported**, if it has API 177 | permissions to do so. 178 | 179 | This is what we call a "critical region" for the system, where the normal 180 | functioning of the system **must** be paused whilst you make your changes. 181 | 182 | Run your import command, carefully constructed above: 183 | 184 | ```shell 185 | # DO NOT copy and paste this - it is only indicative 186 | # terraform import github_. 187 | ``` 188 | 189 | Terraform will tell you if the import is successful. If it isn't, reach out for 190 | support. 191 | 192 | **You have *not* yet finished - continue reading**. 193 | 194 | You have just added the resource into Terraform's *live* state file, stored 195 | centrally in Terraform Cloud. The state file is Terraform's concept of remote 196 | resources that exist and are Terraform's responsibility. The state file for 197 | each org is a singleton, and has no affinity with your branch, or your machine: 198 | it's shared across all invocations of Terraform that manage this org. 199 | 200 | So, right now, if `terraform apply` is invoked, anywhere, against a 201 | `config.tf.json` that *doesn't* contain config describing your newly imported 202 | resource, then that Terraform invocation will happily attempt to align reality 203 | (i.e. make changes at GitHub) with the config that *it* sees - and that means 204 | destroying resources that it's responsible for, but which don't need to exist 205 | any more (because *they're not in the config file that it sees*). 206 | 207 | The rest of this process involves aligning your config with the remote 208 | resource's actual configuration, and merging the resulting config change into 209 | the `main` branch. 210 | 211 | Whilst still inside the `_operations/github/...` directory containing the org's 212 | `config.tf.json`, run 213 | 214 | ```shell 215 | terraform plan 216 | ``` 217 | 218 | to see the differences between your current config and GitHub's reality. 219 | 220 | Terraform will propose making some changes to your newly-imported resource. **You 221 | *must* adapt your config** until Terraform proposes making *no* changes. 222 | 223 | Do this by taking the per-field output from `terraform plan` and codifying the 224 | *inverse* of that result in your resource's CUE config. Here's an example .. 225 | 226 | Changes being proposed look like this: 227 | 228 | ```text 229 | Terraform will perform the following actions: 230 | 231 | # github_repository.playground will be updated in-place 232 | ~ resource "github_repository" "playground" { 233 | ~ has_downloads = true -> false 234 | ~ has_projects = true -> false 235 | ~ has_wiki = true -> false 236 | id = "playground" 237 | name = "playground" 238 | # (31 unchanged attributes hidden) 239 | 240 | # (1 unchanged block hidden) 241 | } 242 | ``` 243 | 244 | Here, the 3 fields `has_downloads`, `has_projects`, and `has_wiki` are being 245 | proposed to move from `true` to `false`. This tells us that the actual repo on 246 | GitHub has those fields already set to `true` - and so we need to reflect that 247 | in our config by adding this to our existing CUE resource struct: 248 | 249 | ```CUE 250 | our_resource: { 251 | has_downloads: true 252 | has_projects: true 253 | has_wiki: true 254 | } 255 | ``` 256 | 257 | After making the changes to your CUE config (**not directly in 258 | `config.tf.json`**) , regenerate your Terraform config and re-run Terraform 259 | from inside the same org-level directory: 260 | 261 | ```shell 262 | make -C "$(git rev-parse --show-toplevel)" generate 263 | terraform plan 264 | ``` 265 | 266 | If you have now correctly mirrored the state of the existing remote resource, 267 | Terraform will show you something like this message: 268 | 269 | ``` 270 | No changes. Your infrastructure matches the configuration. 271 | 272 | Terraform has compared your real infrastructure against your configuration 273 | and found no differences, so no changes are needed. 274 | ``` 275 | 276 | Seeing something close to the above message allows you to move on. 277 | 278 | If you don't see this, and changes are still proposed, codify their inverse and 279 | re-attempt the regeneration/plan step. Do this until you see no changes being 280 | proposed. 281 | 282 | Once you have eliminated all proposed changes, commit both your changes to the 283 | CUE config and the generated file on your feature branch. Use a commit message 284 | like the following, **and be sure to include the Terraform no-op flag in the 285 | commit message body, at the start of a line**: 286 | 287 | ```text 288 | org/: import github_ 289 | 290 | TERRAFORM-PLAN-NO-OP-REQUIRED 291 | ``` 292 | 293 | Open a PR to merge your feature branch into `main`. The 294 | `TERRAFORM-PLAN-NO-OP-REQUIRED` marker will make the CI tests fail if your 295 | config differs from the resource's actual GitHub state. Make any required 296 | changes to the resource in a *subsequent* PR - don't take a potentially 297 | dangerous shortcut by attempting to make changes to the resource in this PR! 298 | 299 | Have the PR reviewed, and then "Rebase and merge" your branch into `main`. 300 | 301 | After the `main` branch CI jobs have finished succesfully, the repo's critical 302 | region is over - announce this to your colleagues. 303 | 304 | If the jobs fail, reach out for support *immediately*. 305 | -------------------------------------------------------------------------------- /config/github_actions.cue: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // All GitHub Actions workflows, jobs and steps. 4 | // This file is somewhat (too) long. 5 | 6 | import ( 7 | "strings" 8 | "path" 9 | ) 10 | 11 | github: actions: { 12 | _orgs: { 13 | for k, _ in github.org { 14 | (k): {name: k} 15 | } 16 | } 17 | 18 | workflow: { 19 | // This workflow tests changes to the `main` branch, invokes `terraform 20 | // apply` if the tests pass, and posts the resulting Terraform output to 21 | // the PR that introduced the change 22 | "test-and-apply.main-branch": { 23 | name: "Test & apply changes" 24 | on: push: branches: ["main"] 25 | jobs: { 26 | tests 27 | let tests = { 28 | "Test-Shared-Components": job.#TestShared 29 | for org, config in _orgs { 30 | "Test-\(config.name)": job.#TestAndPublish & {#Org: config} 31 | } 32 | } 33 | apply 34 | let apply = { 35 | for org, config in _orgs { 36 | "Apply-\(config.name)": job.#Apply & { 37 | #Org: config 38 | #WaitForJobs: [ for jobId, _ in tests {jobId}] 39 | } 40 | } 41 | } 42 | "Notify-On-Failure": job.#NotifyApplyFailure & { 43 | #JobsToCheck: [ for jobId, _ in {tests & apply} {jobId}] 44 | } 45 | } 46 | } 47 | 48 | // This workflow tests changes across all orgs on PR feature branches and 49 | // posts the output of `terraform plan` to the associated PR 50 | "test.PR-branch": { 51 | name: "Test proposed changes" 52 | on: pull_request: { 53 | branches: ["main"] // the *target* of the PR 54 | types: ["opened", "synchronize", "reopened"] 55 | } 56 | jobs: { 57 | let tests = {for org, config in _orgs { 58 | "Test-\(config.name)": job.#TestAndPublish & {#Org: config} 59 | }} 60 | tests 61 | "Test-Shared-Components": job.#TestShared & { 62 | needs: [ for k, _ in tests {k}] 63 | } 64 | } 65 | } 66 | 67 | // This workflow runs week-daily and reports any drift between Terraform 68 | // configuration and GitHub resources' states 69 | "detect-drift.main-branch.scheduled": { 70 | name: "Detect drift" 71 | //on: push: branches: [ "jcm/drift-detection/**"] // Only used for testing, pre-merge 72 | on: schedule: [{cron: "30 7 * * 1-5"}] // 0730 UTC, Monday-Friday 73 | on: workflow_dispatch: {} 74 | jobs: { 75 | detect 76 | let detect = { 77 | for org, config in _orgs { 78 | "Drift-\(config.name)": job.#DetectDrift & {#Org: config} 79 | } 80 | } 81 | "Alert": job.#NotifyDrift & { 82 | #JobsToCheck: [ for k, v in detect {k}] 83 | } 84 | } 85 | } 86 | 87 | _job_id_constraint: [ 88 | // "[Job] IDs may only contain alphanumeric characters, '_', and '-'" 89 | =~"^[-a-zA-Z0-9_]+$", 90 | // "IDs must start with a letter or '_'" 91 | =~"^[a-zA-Z_]", 92 | // "and must be less than 100 characters" 93 | strings.MaxRunes(99), 94 | ] 95 | _#JobID: [and(_job_id_constraint)]: _ 96 | [_]: { 97 | jobs: _#JobID 98 | env: { 99 | TF_IN_AUTOMATION: "yes it is" 100 | TF_INPUT: 0 101 | } 102 | concurrency: { 103 | // All workflows share a static concurrency group. 104 | // This is the simplest, safest approach, and shouldn't be changed 105 | // without signficant thought, and understanding of the specific 106 | // terraform operations being performed across workflows, jobs, and 107 | // branches. 108 | group: "terraform-state-lock" 109 | "cancel-in-progress": false 110 | } 111 | } 112 | } 113 | 114 | job: { 115 | 116 | #DetectDrift: { 117 | _#common_job_params 118 | #Org: { 119 | name: string 120 | } 121 | 122 | name: "Detect drift: \(#Org.name)" 123 | "runs-on": versions.github.actions.runner 124 | concurrency: "terraform-state-lock-org_\(#Org.name)" 125 | permissions: { 126 | contents: "read" 127 | } 128 | steps: [ 129 | step.#Checkout, 130 | step.#SetupTerraform, 131 | step.#TerraformInit & {#OrgName: #Org.name}, 132 | { 133 | step.#Step 134 | name: "Detect drift" 135 | env: TF_VAR_provider_github_token: "${{ secrets.GH_API_TOKEN }}" 136 | run: "make ci_tf_plan ORG=\(#Org.name) DRIFT_DETECTION=1" 137 | }, 138 | ] 139 | } 140 | 141 | #NotifyDrift: { 142 | _#common_job_params 143 | #JobsToCheck: [...string] 144 | name: "Report drift" 145 | needs: #JobsToCheck 146 | if: "${{ failure() }}" 147 | let workflow_link = "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" 148 | "runs-on": versions.github.actions.runner 149 | steps: [ 150 | step.#NotifyDiscord & { 151 | if: "${{ always() }}" 152 | #Message: ":x: Infrastructure drift detected: " + 153 | "[Workflow](\(workflow_link))" 154 | }, 155 | step.#NotifyEmail & { 156 | if: "${{ always() }}" 157 | #To: ["cue-terraform-github-config-experiment-controller+notifications@cue.works"] 158 | #Subject: "Infrastructure drift detected" 159 | #Message: "\(#Subject): \(workflow_link)" 160 | }, 161 | ] 162 | } 163 | 164 | #NotifyApplyFailure: { 165 | _#common_job_params 166 | #JobsToCheck: [string, ...string] 167 | name: "Notify on apply failure" 168 | needs: #JobsToCheck 169 | if: "${{ failure() }}" 170 | "runs-on": "ubuntu-20.04" 171 | let workflow_link = "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" 172 | steps: [ 173 | step.#NotifyDiscord & { 174 | if: "${{ always() }}" 175 | #Message: ":x: main branch CI failure: " + 176 | "[Workflow](\(workflow_link))" 177 | }, 178 | step.#NotifyEmail & { 179 | if: "${{ always() }}" 180 | #To: ["cue-terraform-github-config-experiment-controller+notifications@cue.works"] 181 | #Subject: "CI failure on infra repo `main` branch" 182 | #Message: "Failed CI workflow: \(workflow_link)" 183 | }, 184 | ] 185 | } 186 | 187 | #TestShared: { 188 | _#common_job_params 189 | name: "Test shared components" 190 | "runs-on": versions.github.actions.runner 191 | needs?: [...string] 192 | steps: [ 193 | step.#Checkout, 194 | step.#SetupCUE, 195 | { 196 | name: "Test that all generated files match their CUE sources" 197 | run: "make generate check_clean_working_tree" 198 | }, 199 | { 200 | name: "Test the unified config" 201 | run: "make test-config" 202 | }, 203 | ] 204 | } 205 | 206 | #TestAndPublish: { 207 | _#common_job_params 208 | #Org: { 209 | name: string 210 | } 211 | _file_tf_plan_diff_md: path.Join([ "_operations", "github", #Org.name, "tmp", "tfplan.zip.diff.md"], path.Unix) 212 | 213 | name: "Test: \(#Org.name)" 214 | "runs-on": versions.github.actions.runner 215 | concurrency: "terraform-state-lock-org_\(#Org.name)" 216 | permissions: { 217 | contents: "read" 218 | "pull-requests": "write" 219 | } 220 | steps: [ 221 | step.#Checkout & { 222 | #gitref: "${{ github.event.pull_request.head.sha }}" 223 | }, 224 | step.#SetupCUE, 225 | step.#SetupTerraform, 226 | step.#TerraformInit & {#OrgName: #Org.name}, 227 | 228 | // end of setup, start of tests 229 | step.#TerraformValidate & {#OrgName: #Org.name}, 230 | { 231 | step.#Step 232 | name: "Serialise Terraform's plan" 233 | run: "make ci_tf_plan ORG=\(#Org.name)" 234 | env: TF_VAR_provider_github_token: "${{ secrets.GH_API_TOKEN }}" 235 | }, 236 | { 237 | step.#Step 238 | if: step.#If.IgnorePreviousStepsFailures 239 | name: "Reformat plan output as diff" 240 | run: "make \(_file_tf_plan_diff_md) ORG=\(#Org.name) COMMIT_ID=${{ github.sha }}" 241 | }, 242 | { 243 | step.#Step 244 | if: step.#If.IgnorePreviousStepsFailures 245 | name: "Post plan diff to GitHub PR" 246 | uses: "mshick/add-pr-comment@" + versions.github.actions."mshick/add-pr-comment" 247 | with: { 248 | status: "${{ job.status }}" 249 | "allow-repeats": true 250 | "message-path": _file_tf_plan_diff_md 251 | "message-failure": """ 252 | # test plan FAILURE: `\(#Org.name)` 253 | 254 | Check GitHub Actions job output for more details. 255 | """ 256 | } 257 | }, 258 | ] 259 | } 260 | 261 | #Apply: { 262 | _#common_job_params 263 | 264 | // a non-empty list of other jobs to wait for 265 | #WaitForJobs: [string, ...string] 266 | #Org: { 267 | name: string 268 | } 269 | 270 | let _file_tf_tmp = path.Join(["_operations", "github", #Org.name, "tmp"], path.Unix) 271 | _file_tf_apply_log_md: path.Join([_file_tf_tmp, "tf-apply.log.md"], path.Unix) 272 | _file_tf_apply_stderr: path.Join([_file_tf_tmp, "tf-apply.stderr.log"], path.Unix) 273 | _file_tf_apply_stdout: path.Join([_file_tf_tmp, "tf-apply.stdout.log"], path.Unix) 274 | 275 | needs: #WaitForJobs 276 | name: "Apply: \(#Org.name)" 277 | "runs-on": versions.github.actions.runner 278 | concurrency: "terraform-state-lock-org_\(#Org.name)" 279 | permissions: { 280 | contents: "read" 281 | "pull-requests": "write" 282 | } 283 | steps: [ 284 | step.#Checkout, 285 | step.#SetupCUE, 286 | step.#SetupTerraform, 287 | step.#TerraformInit & {#OrgName: #Org.name}, 288 | { 289 | step.#Step 290 | name: "terraform apply" 291 | id: "terraform_apply" 292 | env: { 293 | TF_VAR_provider_github_token: "${{ secrets.GH_API_TOKEN }}" 294 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 295 | } 296 | run: """ 297 | set -euo pipefail 298 | 299 | if [ "${CUE_DEBUG_SCRIPTS:-}" = "true" ] 300 | then 301 | set -x 302 | fi 303 | 304 | # Assert that the working tree contains only files which are as 305 | # they were committed, or are gitignored. This ensures that the 306 | # config we're about to `terraform apply` is exactly what the 307 | # developer comitted, with no additional files that might confuse 308 | # terraform. 309 | make check_clean_working_tree || { 310 | echo "ERROR: reason: git working tree is not clean" | tee -a \(_file_tf_apply_stderr) 311 | echo "ERROR: result: exiting and failing before attemping a 'terraform apply'" | tee -a \(_file_tf_apply_stderr) 312 | exit 1 313 | } 314 | 315 | make DANGER_ci_tf_apply \\ 316 | ORG=\(#Org.name) \\ 317 | FILE_TF_APPLY_STDERR=\(_file_tf_apply_stderr) \\ 318 | FILE_TF_APPLY_STDOUT=\(_file_tf_apply_stdout) \\ 319 | REALLY_DO_RUN_TERRAFORM_APPLY=true 320 | 321 | """ 322 | }, 323 | { 324 | step.#Step 325 | if: step.#If.IgnorePreviousStepsFailures 326 | name: "Reformat terraform-apply output as diff" 327 | env: { 328 | COMMIT_ID: "${{ github.sha }}" 329 | ORG: #Org.name 330 | APPLY_STATUS: "${{ steps.terraform_apply.conclusion }}" 331 | } 332 | run: """ 333 | file=\(_file_tf_apply_log_md) 334 | cat ci/misc/tf-apply-to-diff.envsubst \\ 335 | | envsubst \\ 336 | >$file 337 | 338 | # format as diff; strip trailing blank lines 339 | sed -E --file=ci/misc/tf-plan-to-diff.sed \\ 340 | \(_file_tf_apply_stdout) \\ 341 | \(_file_tf_apply_stderr) \\ 342 | | tac | awk 'NF{x=1};NF+x' | tac \\ 343 | >>$file 344 | 345 | echo '```' >>$file 346 | 347 | """ 348 | }, 349 | { 350 | step.#Step 351 | if: step.#If.IgnorePreviousStepsFailures 352 | name: "Post apply diff to GitHub PR" 353 | uses: "mshick/add-pr-comment@" + versions.github.actions."mshick/add-pr-comment" 354 | with: { 355 | status: "${{ job.status }}" 356 | "allow-repeats": true 357 | "message-path": _file_tf_apply_log_md 358 | "message-failure": """ 359 | # terraform apply FAILURE: `\(#Org.name)` 360 | 361 | Check GitHub Actions job output for more details. 362 | """ 363 | } 364 | }, 365 | ] 366 | } 367 | _#common_job_params: { 368 | defaults: run: "working-directory": "." 369 | env: CUE_DEBUG_SCRIPTS: "${{ vars.CUE_DEBUG_SCRIPTS }}" 370 | } 371 | } 372 | 373 | step: { 374 | 375 | #Step: { 376 | name: string 377 | if: string | *"${{ success() }}" 378 | } 379 | 380 | #If: { 381 | IgnorePreviousStepsFailures: "(success() || failure())" 382 | } 383 | 384 | #Checkout: { 385 | #Step 386 | name: "Check out code" 387 | uses: "actions/checkout@" + versions.github.actions."actions/checkout" 388 | #gitref?: string 389 | if #gitref != _|_ { 390 | with: ref: #gitref 391 | } 392 | } 393 | 394 | #SetupCUE: { 395 | #Step 396 | name: "Setup CUE" 397 | uses: "cue-lang/setup-cue@" + versions.github.actions."cue-lang/setup-cue" 398 | with: version: versions.cue 399 | } 400 | 401 | #SetupTerraform: { 402 | #Step 403 | name: "Setup Terraform" 404 | uses: "hashicorp/setup-terraform@" + versions.github.actions."hashicorp/setup-terraform" 405 | with: { 406 | terraform_version: versions.terraform.core 407 | cli_config_credentials_token: "${{ secrets.TFC_API_TOKEN }}" 408 | terraform_wrapper: bool | *false 409 | } 410 | } 411 | 412 | #TerraformInit: { 413 | #Step 414 | #OrgName: string 415 | name: "Initialize Terraform state/plugins/backend" 416 | run: "make ci_tf_init ORG=\(#OrgName)" 417 | } 418 | 419 | #TerraformValidate: { 420 | #Step 421 | #OrgName: string 422 | name: "Validate terraform input" 423 | run: "make ci_tf_validate ORG=\(#OrgName)" 424 | } 425 | 426 | #NotifyDiscord: { 427 | #Step 428 | #Message: string 429 | name: "Notify Discord" 430 | if: string | *"${{ always() }}" // this default isn't intended to layer on top of #Step's default - it forces the consumer to make an explicit choice, whilst providing a hint beyond #Step's opinion. 431 | run: """ 432 | curl \\ 433 | --no-progress-meter \\ 434 | -d content="\(#Message)" \\ 435 | -d username="Github Actions" \\ 436 | "https://discord.com/api/webhooks/1086214516129398814/${{ secrets.DISCORD_WEBHOOK_TOKEN }}" 437 | """ 438 | } 439 | 440 | #NotifyEmail: { 441 | #Step 442 | #To: [string, ...string] 443 | #Subject: string 444 | #Message: string 445 | _to: strings.Join( 446 | [ for k in #To {"\"\(k)\""}], 447 | ",") 448 | name: "Notify Email" 449 | if: string | *"${{ always() }}" // this default isn't intended to layer on top of #Step's default - it forces the consumer to make an explicit choice, whilst providing a hint beyond #Step's opinion. 450 | run: """ 451 | sudo apt-get install -qq swaks 452 | swaks \\ 453 | -tls \\ 454 | --server smtp.gmail.com:587 \\ 455 | --auth LOGIN \\ 456 | --auth-user "${{ secrets.GOOGLE_SMTP_USERNAME }}" \\ 457 | --auth-password "${{ secrets.GOOGLE_SMTP_PASSWORD }}" \\ 458 | --h-Subject "\(#Subject)" \\ 459 | --body "\(#Message)" \\ 460 | --to \(_to) 461 | """ 462 | } 463 | } 464 | } 465 | -------------------------------------------------------------------------------- /.github/workflows/test-and-apply.main-branch.yml: -------------------------------------------------------------------------------- 1 | # Code generated by generate_tool.cue - DO NOT EDIT. 2 | name: Test & apply changes 3 | "on": 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | Notify-On-Failure: 9 | name: Notify on apply failure 10 | needs: 11 | - Test-Shared-Components 12 | - Test-fictional-meme 13 | - Test-supreme-octo-enigma 14 | - Apply-fictional-meme 15 | - Apply-supreme-octo-enigma 16 | if: ${{ failure() }} 17 | runs-on: ubuntu-20.04 18 | defaults: 19 | run: 20 | working-directory: . 21 | steps: 22 | - name: Notify Discord 23 | if: ${{ always() }} 24 | run: |- 25 | curl \ 26 | --no-progress-meter \ 27 | -d content=":x: main branch CI failure: [Workflow]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID)" \ 28 | -d username="Github Actions" \ 29 | "https://discord.com/api/webhooks/1086214516129398814/${{ secrets.DISCORD_WEBHOOK_TOKEN }}" 30 | - name: Notify Email 31 | if: ${{ always() }} 32 | run: |- 33 | sudo apt-get install -qq swaks 34 | swaks \ 35 | -tls \ 36 | --server smtp.gmail.com:587 \ 37 | --auth LOGIN \ 38 | --auth-user "${{ secrets.GOOGLE_SMTP_USERNAME }}" \ 39 | --auth-password "${{ secrets.GOOGLE_SMTP_PASSWORD }}" \ 40 | --h-Subject "CI failure on infra repo `main` branch" \ 41 | --body "Failed CI workflow: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \ 42 | --to "cue-terraform-github-config-experiment-controller+notifications@cue.works" 43 | env: 44 | CUE_DEBUG_SCRIPTS: ${{ vars.CUE_DEBUG_SCRIPTS }} 45 | Test-Shared-Components: 46 | name: Test shared components 47 | runs-on: ubuntu-20.04 48 | defaults: 49 | run: 50 | working-directory: . 51 | steps: 52 | - name: Check out code 53 | uses: actions/checkout@v3 54 | if: ${{ success() }} 55 | - name: Setup CUE 56 | uses: cue-lang/setup-cue@0be332bb74c8a2f07821389447ba3163e2da3bfb 57 | with: 58 | version: v0.6.0-beta.1 59 | if: ${{ success() }} 60 | - name: Test that all generated files match their CUE sources 61 | run: make generate check_clean_working_tree 62 | - name: Test the unified config 63 | run: make test-config 64 | env: 65 | CUE_DEBUG_SCRIPTS: ${{ vars.CUE_DEBUG_SCRIPTS }} 66 | Test-fictional-meme: 67 | name: 'Test: fictional-meme' 68 | runs-on: ubuntu-20.04 69 | concurrency: terraform-state-lock-org_fictional-meme 70 | permissions: 71 | contents: read 72 | pull-requests: write 73 | defaults: 74 | run: 75 | working-directory: . 76 | steps: 77 | - name: Check out code 78 | uses: actions/checkout@v3 79 | with: 80 | ref: ${{ github.event.pull_request.head.sha }} 81 | if: ${{ success() }} 82 | - name: Setup CUE 83 | uses: cue-lang/setup-cue@0be332bb74c8a2f07821389447ba3163e2da3bfb 84 | with: 85 | version: v0.6.0-beta.1 86 | if: ${{ success() }} 87 | - name: Setup Terraform 88 | uses: hashicorp/setup-terraform@v2 89 | with: 90 | terraform_version: 1.4.6 91 | cli_config_credentials_token: ${{ secrets.TFC_API_TOKEN }} 92 | terraform_wrapper: false 93 | if: ${{ success() }} 94 | - name: Initialize Terraform state/plugins/backend 95 | run: make ci_tf_init ORG=fictional-meme 96 | if: ${{ success() }} 97 | - name: Validate terraform input 98 | run: make ci_tf_validate ORG=fictional-meme 99 | if: ${{ success() }} 100 | - name: Serialise Terraform's plan 101 | run: make ci_tf_plan ORG=fictional-meme 102 | env: 103 | TF_VAR_provider_github_token: ${{ secrets.GH_API_TOKEN }} 104 | if: ${{ success() }} 105 | - if: (success() || failure()) 106 | name: Reformat plan output as diff 107 | run: make _operations/github/fictional-meme/tmp/tfplan.zip.diff.md ORG=fictional-meme COMMIT_ID=${{ github.sha }} 108 | - if: (success() || failure()) 109 | name: Post plan diff to GitHub PR 110 | uses: mshick/add-pr-comment@v2.6.1 111 | with: 112 | status: ${{ job.status }} 113 | allow-repeats: true 114 | message-path: _operations/github/fictional-meme/tmp/tfplan.zip.diff.md 115 | message-failure: |- 116 | # test plan FAILURE: `fictional-meme` 117 | 118 | Check GitHub Actions job output for more details. 119 | env: 120 | CUE_DEBUG_SCRIPTS: ${{ vars.CUE_DEBUG_SCRIPTS }} 121 | Test-supreme-octo-enigma: 122 | name: 'Test: supreme-octo-enigma' 123 | runs-on: ubuntu-20.04 124 | concurrency: terraform-state-lock-org_supreme-octo-enigma 125 | permissions: 126 | contents: read 127 | pull-requests: write 128 | defaults: 129 | run: 130 | working-directory: . 131 | steps: 132 | - name: Check out code 133 | uses: actions/checkout@v3 134 | with: 135 | ref: ${{ github.event.pull_request.head.sha }} 136 | if: ${{ success() }} 137 | - name: Setup CUE 138 | uses: cue-lang/setup-cue@0be332bb74c8a2f07821389447ba3163e2da3bfb 139 | with: 140 | version: v0.6.0-beta.1 141 | if: ${{ success() }} 142 | - name: Setup Terraform 143 | uses: hashicorp/setup-terraform@v2 144 | with: 145 | terraform_version: 1.4.6 146 | cli_config_credentials_token: ${{ secrets.TFC_API_TOKEN }} 147 | terraform_wrapper: false 148 | if: ${{ success() }} 149 | - name: Initialize Terraform state/plugins/backend 150 | run: make ci_tf_init ORG=supreme-octo-enigma 151 | if: ${{ success() }} 152 | - name: Validate terraform input 153 | run: make ci_tf_validate ORG=supreme-octo-enigma 154 | if: ${{ success() }} 155 | - name: Serialise Terraform's plan 156 | run: make ci_tf_plan ORG=supreme-octo-enigma 157 | env: 158 | TF_VAR_provider_github_token: ${{ secrets.GH_API_TOKEN }} 159 | if: ${{ success() }} 160 | - if: (success() || failure()) 161 | name: Reformat plan output as diff 162 | run: make _operations/github/supreme-octo-enigma/tmp/tfplan.zip.diff.md ORG=supreme-octo-enigma COMMIT_ID=${{ github.sha }} 163 | - if: (success() || failure()) 164 | name: Post plan diff to GitHub PR 165 | uses: mshick/add-pr-comment@v2.6.1 166 | with: 167 | status: ${{ job.status }} 168 | allow-repeats: true 169 | message-path: _operations/github/supreme-octo-enigma/tmp/tfplan.zip.diff.md 170 | message-failure: |- 171 | # test plan FAILURE: `supreme-octo-enigma` 172 | 173 | Check GitHub Actions job output for more details. 174 | env: 175 | CUE_DEBUG_SCRIPTS: ${{ vars.CUE_DEBUG_SCRIPTS }} 176 | Apply-fictional-meme: 177 | defaults: 178 | run: 179 | working-directory: . 180 | needs: 181 | - Test-Shared-Components 182 | - Test-fictional-meme 183 | - Test-supreme-octo-enigma 184 | name: 'Apply: fictional-meme' 185 | runs-on: ubuntu-20.04 186 | concurrency: terraform-state-lock-org_fictional-meme 187 | permissions: 188 | contents: read 189 | pull-requests: write 190 | steps: 191 | - name: Check out code 192 | uses: actions/checkout@v3 193 | if: ${{ success() }} 194 | - name: Setup CUE 195 | uses: cue-lang/setup-cue@0be332bb74c8a2f07821389447ba3163e2da3bfb 196 | with: 197 | version: v0.6.0-beta.1 198 | if: ${{ success() }} 199 | - name: Setup Terraform 200 | uses: hashicorp/setup-terraform@v2 201 | with: 202 | terraform_version: 1.4.6 203 | cli_config_credentials_token: ${{ secrets.TFC_API_TOKEN }} 204 | terraform_wrapper: false 205 | if: ${{ success() }} 206 | - name: Initialize Terraform state/plugins/backend 207 | run: make ci_tf_init ORG=fictional-meme 208 | if: ${{ success() }} 209 | - name: terraform apply 210 | id: terraform_apply 211 | env: 212 | TF_VAR_provider_github_token: ${{ secrets.GH_API_TOKEN }} 213 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 214 | run: | 215 | set -euo pipefail 216 | 217 | if [ "${CUE_DEBUG_SCRIPTS:-}" = "true" ] 218 | then 219 | set -x 220 | fi 221 | 222 | # Assert that the working tree contains only files which are as 223 | # they were committed, or are gitignored. This ensures that the 224 | # config we're about to `terraform apply` is exactly what the 225 | # developer comitted, with no additional files that might confuse 226 | # terraform. 227 | make check_clean_working_tree || { 228 | echo "ERROR: reason: git working tree is not clean" | tee -a _operations/github/fictional-meme/tmp/tf-apply.stderr.log 229 | echo "ERROR: result: exiting and failing before attemping a 'terraform apply'" | tee -a _operations/github/fictional-meme/tmp/tf-apply.stderr.log 230 | exit 1 231 | } 232 | 233 | make DANGER_ci_tf_apply \ 234 | ORG=fictional-meme \ 235 | FILE_TF_APPLY_STDERR=_operations/github/fictional-meme/tmp/tf-apply.stderr.log \ 236 | FILE_TF_APPLY_STDOUT=_operations/github/fictional-meme/tmp/tf-apply.stdout.log \ 237 | REALLY_DO_RUN_TERRAFORM_APPLY=true 238 | if: ${{ success() }} 239 | - if: (success() || failure()) 240 | name: Reformat terraform-apply output as diff 241 | env: 242 | COMMIT_ID: ${{ github.sha }} 243 | ORG: fictional-meme 244 | APPLY_STATUS: ${{ steps.terraform_apply.conclusion }} 245 | run: | 246 | file=_operations/github/fictional-meme/tmp/tf-apply.log.md 247 | cat ci/misc/tf-apply-to-diff.envsubst \ 248 | | envsubst \ 249 | >$file 250 | 251 | # format as diff; strip trailing blank lines 252 | sed -E --file=ci/misc/tf-plan-to-diff.sed \ 253 | _operations/github/fictional-meme/tmp/tf-apply.stdout.log \ 254 | _operations/github/fictional-meme/tmp/tf-apply.stderr.log \ 255 | | tac | awk 'NF{x=1};NF+x' | tac \ 256 | >>$file 257 | 258 | echo '```' >>$file 259 | - if: (success() || failure()) 260 | name: Post apply diff to GitHub PR 261 | uses: mshick/add-pr-comment@v2.6.1 262 | with: 263 | status: ${{ job.status }} 264 | allow-repeats: true 265 | message-path: _operations/github/fictional-meme/tmp/tf-apply.log.md 266 | message-failure: |- 267 | # terraform apply FAILURE: `fictional-meme` 268 | 269 | Check GitHub Actions job output for more details. 270 | env: 271 | CUE_DEBUG_SCRIPTS: ${{ vars.CUE_DEBUG_SCRIPTS }} 272 | Apply-supreme-octo-enigma: 273 | defaults: 274 | run: 275 | working-directory: . 276 | needs: 277 | - Test-Shared-Components 278 | - Test-fictional-meme 279 | - Test-supreme-octo-enigma 280 | name: 'Apply: supreme-octo-enigma' 281 | runs-on: ubuntu-20.04 282 | concurrency: terraform-state-lock-org_supreme-octo-enigma 283 | permissions: 284 | contents: read 285 | pull-requests: write 286 | steps: 287 | - name: Check out code 288 | uses: actions/checkout@v3 289 | if: ${{ success() }} 290 | - name: Setup CUE 291 | uses: cue-lang/setup-cue@0be332bb74c8a2f07821389447ba3163e2da3bfb 292 | with: 293 | version: v0.6.0-beta.1 294 | if: ${{ success() }} 295 | - name: Setup Terraform 296 | uses: hashicorp/setup-terraform@v2 297 | with: 298 | terraform_version: 1.4.6 299 | cli_config_credentials_token: ${{ secrets.TFC_API_TOKEN }} 300 | terraform_wrapper: false 301 | if: ${{ success() }} 302 | - name: Initialize Terraform state/plugins/backend 303 | run: make ci_tf_init ORG=supreme-octo-enigma 304 | if: ${{ success() }} 305 | - name: terraform apply 306 | id: terraform_apply 307 | env: 308 | TF_VAR_provider_github_token: ${{ secrets.GH_API_TOKEN }} 309 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 310 | run: | 311 | set -euo pipefail 312 | 313 | if [ "${CUE_DEBUG_SCRIPTS:-}" = "true" ] 314 | then 315 | set -x 316 | fi 317 | 318 | # Assert that the working tree contains only files which are as 319 | # they were committed, or are gitignored. This ensures that the 320 | # config we're about to `terraform apply` is exactly what the 321 | # developer comitted, with no additional files that might confuse 322 | # terraform. 323 | make check_clean_working_tree || { 324 | echo "ERROR: reason: git working tree is not clean" | tee -a _operations/github/supreme-octo-enigma/tmp/tf-apply.stderr.log 325 | echo "ERROR: result: exiting and failing before attemping a 'terraform apply'" | tee -a _operations/github/supreme-octo-enigma/tmp/tf-apply.stderr.log 326 | exit 1 327 | } 328 | 329 | make DANGER_ci_tf_apply \ 330 | ORG=supreme-octo-enigma \ 331 | FILE_TF_APPLY_STDERR=_operations/github/supreme-octo-enigma/tmp/tf-apply.stderr.log \ 332 | FILE_TF_APPLY_STDOUT=_operations/github/supreme-octo-enigma/tmp/tf-apply.stdout.log \ 333 | REALLY_DO_RUN_TERRAFORM_APPLY=true 334 | if: ${{ success() }} 335 | - if: (success() || failure()) 336 | name: Reformat terraform-apply output as diff 337 | env: 338 | COMMIT_ID: ${{ github.sha }} 339 | ORG: supreme-octo-enigma 340 | APPLY_STATUS: ${{ steps.terraform_apply.conclusion }} 341 | run: | 342 | file=_operations/github/supreme-octo-enigma/tmp/tf-apply.log.md 343 | cat ci/misc/tf-apply-to-diff.envsubst \ 344 | | envsubst \ 345 | >$file 346 | 347 | # format as diff; strip trailing blank lines 348 | sed -E --file=ci/misc/tf-plan-to-diff.sed \ 349 | _operations/github/supreme-octo-enigma/tmp/tf-apply.stdout.log \ 350 | _operations/github/supreme-octo-enigma/tmp/tf-apply.stderr.log \ 351 | | tac | awk 'NF{x=1};NF+x' | tac \ 352 | >>$file 353 | 354 | echo '```' >>$file 355 | - if: (success() || failure()) 356 | name: Post apply diff to GitHub PR 357 | uses: mshick/add-pr-comment@v2.6.1 358 | with: 359 | status: ${{ job.status }} 360 | allow-repeats: true 361 | message-path: _operations/github/supreme-octo-enigma/tmp/tf-apply.log.md 362 | message-failure: |- 363 | # terraform apply FAILURE: `supreme-octo-enigma` 364 | 365 | Check GitHub Actions job output for more details. 366 | env: 367 | CUE_DEBUG_SCRIPTS: ${{ vars.CUE_DEBUG_SCRIPTS }} 368 | env: 369 | TF_IN_AUTOMATION: yes it is 370 | TF_INPUT: 0 371 | concurrency: 372 | group: terraform-state-lock 373 | cancel-in-progress: false 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CUE.works infrastructure 2 | 3 | This repo contains and orchestrates a system which manages most CUE-owned 4 | GitHub Organisations. 5 | 6 | The system uses GitHub Actions ("GHA") to coordinate Terraform invocations that 7 | manage the state of the GitHub orgs' entities such as org members, repo 8 | settings, outside collaborators 9 | [and more](internal/schemata/providers/github/). 10 | It can be easily extended to manage any GitHub entity type supported by 11 | [the `github` Terraform provider](https://registry.terraform.io/providers/integrations/github/5.25.1/docs) 12 | and potentially any entity type supported by 13 | [any Terraform provider](https://registry.terraform.io/browse/providers). 14 | 15 | CUE is the system's source of truth, which is primarily stored in the `config` 16 | package. 17 | 18 | There are [Quickstart guides](#quickstart-guides) available for some common 19 | tasks: 20 | 21 | - [Creating a new repo](#creating-a-new-repo) 22 | - [Deleting a repo](#deleting-a-repo) 23 | - [Onboarding a new employee](#onboarding-a-new-employee) 24 | - [Granting an outside collaborator access to specific repos](#granting-an-outside-collaborator-access-to-specific-repos) 25 | - [Adding an org to the system](#adding-an-org-to-the-system) 26 | 27 | There is a basic guide to 28 | [customising this repo and setting up your GitHub Actions environment](docs/customising-this-repo.md). 29 | 30 | There are additional Quickstart guides for the following tasks, but **these 31 | processes are untested** and should only be performed by *experienced* users of 32 | this system, and with an eye on validating the process and promoting its entry 33 | to the list above: 34 | 35 | - [Moving a repo across orgs](#moving-a-repo-across-orgs) 36 | - [Renaming a repo](#renaming-a-repo) 37 | 38 | The GitHub org that *this* repo belongs to is deliberately left unmanaged by 39 | the system, so that we can never accidentally cause the system to lock 40 | ourselves out of this repo, unable to undo the change. 41 | 42 | The system manages other orgs via a GitHub machine user account. That account 43 | is an owner of any org it manages. We have chosen not to manage *that* machine 44 | user's membership in any org via this system - again, so that we can never 45 | inadvertently remove the machine user from any org and lock ourselves out of 46 | *that* org. 47 | 48 | --- 49 | 50 | ## Operations 51 | 52 | ### Overview 53 | 54 | This repo is a CUE module whose `config` package defines the configuration of 55 | all GitHub orgs managed by the system. All CUE files in the `config/` directory 56 | contribute to the package. The [files' purposes](#this-repo) and [resulting CUE 57 | structure](#the-main-cue-package) are documented in this README. 58 | 59 | CUE is the source of truth both for the GitHub orgs being managed by Terraform 60 | and the GHA jobs that automate the management. However neither Terraform nor 61 | GHA accept CUE as input, so we serialise their respective config files before 62 | they execute. We store Terraform's config files in the repo (instead of 63 | exporting them at GHA runtime) so we can have confidence in their content and 64 | presence, and to ensure they are reviewable as part of any change. 65 | 66 | We shield each GitHub org from all the others (and shield the operator from 67 | making high-impact errors) by managing each org via its own Terraform config 68 | file and Terraform invocation, and by storing each org's state file in a 69 | dedicated Terraform Cloud ("TFC") Workspace. TFC is used solely to store 70 | Terraform state files and to provide a locking mechanism preventing parallel 71 | Terraform invocations from interfering with each other. It is not used for its 72 | ability to invoke Terraform. 73 | 74 | Changes are made to the repo's contents via GitHub Pull Requests ("PR"s) from 75 | feature branches onto the `main` branch. Only commits to the `main` branch 76 | trigger a `terraform apply` invocation, and that branch is protected from 77 | direct pushes. Commits must be added via PRs. 78 | 79 | Terraform invocations run inside GHA, with each org having a dedicated 80 | `terraform plan` job during PR testing, and a dedicated `terraform apply` job 81 | after each PR merge. 82 | 83 | Terraform's generated config files live inside `_operations/github/` in a 84 | directory named after the org being managed. This per-org directory is 85 | Terraform's working directory, and is where Terraform initialises its provider 86 | plugins before invocations. 87 | 88 | Changes to files inside `_operations/` are made by Makefile recipes, CUE 89 | `_tool` scripts, and Terraform's lockfile management - all of which are 90 | triggered by the operator and never by CI itself. 91 | 92 | The contents of files inside `_operations/` are controlled via the `config` 93 | package. Changes inside `_operations/`, `config/` and `.github/` must be 94 | committed by the operator. No *manual* edits of files inside `_operations/` 95 | are needed, and such edits are guarded against by an early CI assertion that 96 | the committed contents of `config/` produces exactly the committed contents of 97 | `_operations/`. 98 | 99 | ### Making A Content Change In One Or More Orgs 100 | 101 | After making a change in `config/` that updates one or more org's resources or 102 | introduces new resources, you must: 103 | 104 | - test the changes with `make test-config` 105 | - generate static Terraform configuration with `make generate` 106 | - submit the changes as a PR 107 | - validate the per-org `terraform plan` output posted to your PR as comments 108 | - have the PR reviewed and approved 109 | - merge the PR 110 | 111 | #### Test The Changes 112 | 113 | With a version of CUE installed that understands required fields, run 114 | 115 | ```shell 116 | make test-config 117 | ``` 118 | 119 | #### Generate Static Terraform Configuration 120 | 121 | Run 122 | 123 | ``` 124 | make generate 125 | ``` 126 | 127 | #### Submit The Changes 128 | 129 | On a non-`main` branch, commit all changed files, including but not limited to 130 | all changes under the `config/`, `_operations/`, and `.github/` directories. 131 | 132 | Add all changes in a single commit. Use a commit prefix such as: 133 | 134 | - changes to a single org: `org/cue-foo: add the X field to Y` 135 | - changes across multiple orgs: `org/cue-{foo,bar}: make Y do X` 136 | - changes across all orgs: `org/*: change all X to Y` 137 | - e.g. global template changes 138 | 139 | If your commit doesn't update, create, or destroy any Terraform resources (e.g. 140 | it's a CUE refactor, a documentation update, or a whitespace change), inform 141 | the CI tests that they should assert that no-op-ness by including this string 142 | on a separate line in your commit message *body*: 143 | 144 | ```text 145 | TERRAFORM-PLAN-NO-OP-REQUIRED 146 | ``` 147 | 148 | Open a draft PR that would add your changes to the `main` branch in this repo. 149 | 150 | #### Validate The Per-Org `terraform plan` 151 | 152 | The CI test jobs will finish by running `terraform plan` once for each managed 153 | org. Each `terraform plan`'s output is posted to your PR as a separate comment. 154 | 155 | Check that Terraform is proposing to create, update or destroy the resources 156 | that you expect. 157 | 158 | If your PR passes the GitHub Actions CI tests for every org, mark the PR as 159 | ready and request a review from someone on the team. 160 | 161 | #### Have The PR Reviewed And Approved 162 | 163 | This repo has a [`CODEOWNERS`](/CODEOWNERS) file which enforces that certain 164 | files' changes must be reviewed by specific people. In general, PRs may be 165 | reviewed by anyone with access to the repo. @myitcv has the most information 166 | about the system, and in an emergency you can reach out to @jpluscplusm for 167 | support. 168 | 169 | The `main` branch's protection rules require an approval on a PR's latest 170 | commit before that PR's commit(s) can be added to the branch. 171 | 172 | #### Merge The PR 173 | 174 | After the PR has been approved **ensure that any other PR merges have fully 175 | run to completion in the repo's GitHub Actions**. 176 | 177 | Merge the PR yourself, using the "Rebase and merge" strategy. 178 | 179 | ### Make Targets 180 | 181 | There is a single Makefile at the top of the repo. This Makefile is used by 182 | developers, operators, and CI to perform specific tasks. The Makefile is 183 | written in the "phony" task runner model, not the traditional `make 184 | a.specific.file.exist` model - all tasks run unconditionally, without checking 185 | any file modification times. The Makefile includes other Makefiles (in 186 | `/ci/make/`) which contain the messy implementation details of how CI jobs turn 187 | `terraform plan` output into nice PR comments. 188 | 189 | Running `make [help]` lists the runnable targets alongside a brief description 190 | of what each target does. These targets are listed here with slightly more 191 | detail than at the CLI. 192 | 193 | Parameters should be provided as Makevars (`make PARAM=value`). 194 | Providing them as envvars *may* work, but has not been tested. 195 | 196 | | Target | Parameters | Purpose | 197 | | :--- | :---: | :--- 198 | | `test-config` | | `cue vet` the `config` package 199 | | `clean` | | Remove all .gitignored files 200 | | `generate` | | Recreate all generated files in the repo 201 | | `trim` | | Run cue-trim on the non-GitHub-Actions portions of the unified config (because something about our workflows confuses `trim`, which has been filed as an upstream bug) 202 | | `lockfile_upgrade` | | Upgrade Terraform providers to the latest versions permitted by `config/manifest.cue` 203 | | `lockfile_hash` | | Place a full set of platform-specific hashes in terraform's lock file, without re-locking versions 204 | | `check_clean_working_tree` | | Assert that all git's tracked files are unchanged from their latest committed state, and no untracked files exist 205 | | `help` | | Show abbreviated help text 206 | 207 | ### Upgrading Terraform Providers 208 | 209 | --- 210 | 211 |
212 | 213 | Click to open this paragraph if you have prior 214 | experience of upgrading Terraform providers in a different 215 | system 216 | 217 |
218 | 219 | In vanilla Terraform setups you would run `terraform init` with its `-upgrade` 220 | flag in a directory that contains Terraform's config. 221 | 222 | **Do NOT do that here!** 223 | 224 | *Don't* run `terraform init -upgrade` in any specific org's `_operations/*` 225 | directory. 226 | 227 | Doing so would desync that org from all the other orgs (in terms of provider 228 | versioning) and would require unpicking symlinks (cf. 229 | https://github.com/hashicorp/terraform/issues/32707) 230 | 231 | Instead, read on for details of how to use Makefile targets to upgrade 232 | Terraform providers in this system. 233 | 234 |
235 | 236 | --- 237 | 238 | The versions of providers we use are constrained by 239 | [Terraform's constraint syntax](https://developer.hashicorp.com/terraform/language/expressions/version-constraints#version-constraint-syntax) 240 | in the version manifest at `config/manifest.cue`, under the CUE path 241 | 242 | ```CUE 243 | versions: terraform: providers: [_]: 244 | ``` 245 | 246 | `config/manifest.cue` is under your control, but the provider versions that 247 | will be used are *not* decided by this file's contents **on every Terraform 248 | invocation.** 249 | 250 | Instead, the versions are locked at the *specific* versions 251 | visible inside `_operations/github/.terraform_lockfile/.terraform.lock.hcl`. 252 | 253 | This file is static until an operator chooses to upgrade and lock provider 254 | versions. 255 | 256 | `_operations/github/.terraform_lockfile/.terraform.lock.hcl` is managed by 257 | Terraform, and is modified by the following Make targets (which invoke 258 | Terraform, which must be installed): 259 | 260 | - `make lockfile_upgrade`: upgrades to the latest versions permitted by the 261 | constraints in `config/manifest.cue` 262 | - `make lockfile_hash`: records a hash of each currently selected provider's 263 | installation files, *without* upgrading versions 264 | 265 | Runnng `make lockfile_upgrade` also invokes `lockfile_hash`. **Neither** target 266 | requires TFC or GitHub API tokens to be available on the local machine, and can 267 | be run by anyone with an appropriate version of Terraform installed. 268 | 269 | Both of these commands might modify 270 | `_operations/github/.terraform_lockfile/.terraform.lock.hcl`. 271 | 272 | **Changes to this file must be committed and PR'd.** 273 | 274 | **It is STRONGLY recommended to PR & merge provider version upgrades ahead of 275 | configuration changes which require the upgraded versions** so that the version 276 | upgrade can be verified as being a no-op change in isolation. 277 | 278 | ### Running Terraform Locally 279 | 280 | This system is intended to run Terraform solely in CI, and not on an operator's 281 | machine. The initial setup required for CI is documented 282 | [separately](docs/customising-this-repo.md). 283 | 284 | If you *do* need to run Terraform locally then the system requires 2 285 | environment variables to be set, containing credentials for its 2 different 286 | backend systems: Terraform Cloud ("TFC") and GitHub. 287 | 288 | However, before doing this, *consider if you really **need** to run Terraform 289 | locally*, or if you could instead achieve what you need via CI. The system 290 | currently does not have a concept of "read-only" access to either TFC or 291 | GitHub, so possessing the credentials required for Terraform invocation means 292 | your local machine is a *signficantly* elevated risk for the organisation. 293 | 294 | If you make the choice to run Terraform locally, protect the credentials: 295 | 296 | - **minimise the duration that the credentials are present** on your local 297 | machine 298 | - **remove them from your environment** after each session in which you use 299 | them 300 | - **don't make shadow copies of them in your local secret-management software** 301 | - **don't store them on your filesystem** for any period of time 302 | - ***don't commit them to this repo!*** 303 | 304 | #### Terraform Cloud 305 | 306 | [Terraform Cloud](https://app.terraform.io) ("TFC") is a managed service hosted 307 | by Hashicorp. We use it to store our Terraform state files, and to provide 308 | locking primitives for access to those state files. 309 | 310 | Expose a TFC API token via the environment variable 311 | `TF_TOKEN_app_terraform_io`. 312 | 313 | In mid-2023, these tokens look like 314 | `.atlasv1.`. 315 | 316 | #### GitHub API 317 | 318 | The GitHub resources resources managed by the system are accessed via the 319 | Github API. 320 | 321 | Expose an appropriately-permissioned GitHub API token via the environment 322 | variable `TF_VAR_provider_github_token`. 323 | 324 | In mid-2023, these tokens 325 | [look like](https://github.blog/changelog/2021-03-31-authentication-token-format-updates-are-generally-available/) 326 | `ghp_`. 327 | 328 | ### Managing Existing GitHub Resources 329 | 330 | Importing existing GitHub resources so they can be managed by Terraform is a 331 | fiddly and potentially risky operation. 332 | 333 | It is 334 | [documented separately](/docs/managing-existing-resources.md). 335 | 336 | --- 337 | 338 | ## Layout 339 | 340 | ### This Repo 341 | 342 | - [`ci/`](/ci): CI related files 343 | 344 | - [`github/`](/ci/github): a convenience symlink to `.github/workflows/` 345 | 346 | - [`make/`](/ci/make): a place for Makefiles that contain targets used by CI 347 | 348 | - [`misc/`](/ci/misc): `sed` and `envsubst` "scripts" which are used by CI to format 349 | `terraform [plan|apply]` output for comments on PRs 350 | 351 | - [`CODEOWNERS`](/CODEOWNERS): A file configuring a 352 | [GitHub feature](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners) 353 | that ensures that changes to certain files are reviewed by specific people 354 | 355 | - [`config/`](/config): user-modifiable configuration files 356 | 357 | - `org.*.cue`: per-org resources and org-level deviations from global defaults 358 | 359 | - [`generate_tool.cue`](/generate_tool.cue): a CUE script which writes the systems' generated files 360 | 361 | - [`.github/workflows/`](/.github/workflows): Generated GHA workflow files 362 | 363 | - [`internal/`](/internal): CUE files which don't have a place as part of the 364 | `config` package, but haven't yet been externalised from this repo 365 | 366 | - [`schemata/providers/github/`](/internal/schemata/providers/github): 367 | manually-generated schemata which validate our configuration of resources 368 | managed by the `github` Terraform provider 369 | 370 | - [`schemata/terraform/`](/internal/schemata/terraform): 371 | manually-generated, minimally-viable schemata for various provider-agnostic 372 | components of Terraform's input, including top-level config and 373 | [resource meta arguments](https://developer.hashicorp.com/terraform/language/resources) 374 | 375 | - [`Makefile`](/Makefile): the dev- and CI-facing task runner. See 376 | [Make Targets](#make-targets) for a list of its targets and the tasks it 377 | performs 378 | 379 | - [`_operations/`](/_operations): the root directory for all generated files 380 | (except GitHub Actions workflow files, which must live in `.github/workflows` 381 | and cannot be symlinks) 382 | 383 | - [`github/cue-*/`](/_operations/github): a per-GitHub-org working directory 384 | for Terraform invocations, each containing the generated `config.tf.json` 385 | file for the GitHub org the directory is named after 386 | 387 | - [`github/.terraform_lockfile/`](/_operations/github/.terraform_lockfile): 388 | the working directory used by [`make lockfile_upgrade`](#make-targets) 389 | operations, containing only those parts of our Terraform configuration 390 | which affect provider dependency version selection 391 | 392 | - [`.terraform.lock.hcl`](/_operations/github/.terraform_lockfile/.terraform.lock.hcl): 393 | The Terraform-controlled lockfile which contains the exact versions of 394 | providers the system uses. Controlled by a Make target, and documented 395 | [elsewhere in this README](#upgrading-terraform-providers) 396 | 397 | ### The Main CUE Package 398 | 399 | The high-level shape of the `config` package is described in 400 | [`schema.cue`](/config/schema.cue). 401 | 402 | Each `github.org.[OrgName=string]: {}` struct represents a managed GitHub org 403 | named `OrgName`. 404 | 405 | Inside this struct the org's config lives in the `config` field, whose shape is 406 | also described in [`schema.cue`](/config/schema.cue), in the `#terraform_input` 407 | definition. 408 | 409 | Each `github.actions.workflow.[Filename=string]: {}` struct that exists results 410 | in a separate GHA workflow file being written into 411 | `.github/workflows/.yml` 412 | 413 | Each `target.terraform.github.org.[OrgName=string]: {}` struct is a dynamic 414 | copy of the same path *without* the `target.terraform` prefix. A copy exists 415 | for each managed org. 416 | 417 | This struct contains the content that ultimately gets serialised into 418 | `_operations/github//config.tf.json`, but with one difference: 419 | resource names are translated in order to meet a specific set of Terraform 420 | requirements, which are different from CUE's struct name constraints. The 421 | translation rules live inside `target.terraform.#Identifier.adapt`, defined in 422 | `config/target.terraform.cue` along with their documentation. 423 | 424 | --- 425 | 426 | ## Quickstart Guides 427 | 428 | After following any quickstart section, also follow the 429 | [content change](#making-a-content-change-in-one-or-more-orgs) 430 | section to submit and apply your changes. 431 | 432 | ### Creating A New Repo 433 | 434 | #### Prerequisites 435 | 436 | - You want to create a repo with name `` in org `` 437 | - You know if it will be a public or private repo 438 | 439 | #### Configuration 440 | 441 | Configure the new repo in `config/org..cue`, with the following path 442 | and minimum concrete configuration: 443 | 444 | ```CUE 445 | github: org: : config: { 446 | resource: { 447 | github_repository: { 448 | : { 449 | visibility: "public" | "private" 450 | } 451 | } 452 | } 453 | } 454 | ``` 455 | 456 | Other options are available, as per the schema in 457 | `internal/schemata/providers/github/repository.cue`. Global defaults, 458 | if any, are visible in `config/defaults.cue`. 459 | 460 | Now read the 461 | [content change](#making-a-content-change-in-one-or-more-orgs) 462 | section. 463 | 464 | ### Deleting A Repo 465 | 466 | #### Prerequisites 467 | 468 | - A public or private repo exists that you wish to delete entirely 469 | - The repo is managed by this system 470 | - The repo is named ``, and it exists in the `` org 471 | 472 | #### Configuration 473 | 474 | In the `config` package, in the file dedicated to ``'s resources, 475 | find the struct 476 | 477 | ```cue 478 | github: org: : config: resource 479 | ``` 480 | 481 | Inside that struct, find and delete the `github_repository: ` 482 | struct. 483 | 484 | Alow find and delete any references to the repo in other structs such as: 485 | 486 | - `_non_org_member_access: collaborator` 487 | - `_non_org_member_access: bot` 488 | - `github_repository_collaborators` 489 | 490 | You now need to PR and merge these change, **but the CI job after the merge 491 | onto the main branch will fail**. This is expected, and is a safety precaution. 492 | 493 | *After* reading and following the 494 | [content change](#making-a-content-change-in-one-or-more-orgs) 495 | section, return to this guide and continue from this point. 496 | 497 | --- 498 | 499 | Your PR passed all its tests, you merged it, and the `terraform apply` job on 500 | the `main` branch failed. This is because this system has not been granted 501 | permission to delete *any* repos, so it can't accidentally destroy content 502 | irreversibly. 503 | 504 | Find someone who has control of an org-owner account in the `` org. 505 | 506 | Ask them to navigate to the repo's settings page and delete the repo manually. 507 | 508 | After they have done this, **re-trigger the CI job on the `main` branch** and 509 | observe your commit's status going green. 510 | 511 | ### Onboarding A New Employee 512 | 513 | #### Prerequisites 514 | 515 | - You want to grant a new company employee read and write access to all public 516 | and private repos across all orgs 517 | - You know their GitHub login 518 | 519 | #### Configuration 520 | 521 | Add the new employee to `config/employees.cue` as an `#Employee` struct inside 522 | `company.employees`. 523 | 524 | Now read the 525 | [content change](#making-a-content-change-in-one-or-more-orgs) 526 | section. 527 | 528 | ### Granting An Outside Collaborator Access To Specific Repos 529 | 530 | #### Prerequisites 531 | 532 | - You want to grant a 3rd party read, write, or triage access to a specific 533 | public or private repo 534 | - You know their GitHub login is `` 535 | - You know the repo name is `` and it already exists inside org 536 | `` 537 | 538 | #### Configuration 539 | 540 | Configure the access in `config/org..cue` with the following path and 541 | minimum concrete configuration: 542 | 543 | ```CUE 544 | github: org: : config: { 545 | resource: { 546 | _non_org_member_access: { 547 | collaborator: { 548 | : { 549 | : "pull" | "push" | "triage" 550 | } 551 | } 552 | } 553 | } 554 | } 555 | ``` 556 | 557 | Now read the 558 | [content change](#making-a-content-change-in-one-or-more-orgs) 559 | section. 560 | 561 | ### Adding An Org To The System 562 | 563 | #### Prerequisites 564 | 565 | - You want to give the system the ability to create GitHub entities inside a 566 | GitHub org called `` 567 | - The GitHub org already exists 568 | - or you can create it via 569 | [their web UI](https://github.com/account/organizations/new) 570 | - You want the org to adopt our 571 | [org-level default settings](config/defaults.cue) 572 | - *or* you know which of the org's settings deviate from our defaults, and 573 | what their values are 574 | - The machine user account `cue-terraform-github-config-experiment-controller` 575 | has been manually invited into the org as an owner 576 | - ... and the machine user has logged in and accepted the invitation via the 577 | GitHub web UI 578 | - You have created a dedicated "CLI-driven workflow" workspace in Terraform 579 | Cloud for the org 580 | [via their web UI](https://app.terraform.io) 581 | - The workflow is the "CLI-driven" flavour 582 | - The workspace is named `org_` 583 | - You have tagged it, after creation, via the workspace's main page, with 584 | these tags: 585 | - `service:github` 586 | - `org:` 587 | - You have switched the workspace's "Execution Mode" to "Local" via the 588 | workspace's Settings page 589 | 590 | #### Configuration 591 | 592 | Create the file `config/org..cue` with the following minimum content: 593 | 594 | ```CUE 595 | package config 596 | 597 | github: org: : {} 598 | ``` 599 | 600 | If there are any 601 | [org-level settings](https://registry.terraform.io/providers/integrations/github/5.25.1/docs/resources/organization_settings#argument-reference) 602 | that deviate from our defaults in `config/defaults.cue`, place them at this 603 | path: 604 | 605 | ```CUE 606 | github: org: : config: { 607 | resource: { 608 | github_organization_settings: self: { 609 | ... 610 | } 611 | } 612 | } 613 | ``` 614 | 615 | The controlling schema is at 616 | `internal/schemata/providers/github/organization_settings.cue` 617 | 618 | Adopting this config *will* change the existing org-level settings to our 619 | defaults (or your overrides) **without showing you the existing values** in a 620 | PR's `terraform plan` output. 621 | 622 | Now read the 623 | [content change](#making-a-content-change-in-one-or-more-orgs) 624 | section. 625 | 626 | ## UNTESTED Quickstart Guides 627 | 628 | These guides have never been tested, and are purely indicative of the process 629 | that might be followed. If you complete any of the following, please validate 630 | its process carefully and consider promoting it to the main 631 | [Quickstart](#quickstart-guides) section if it's robust enough. 632 | 633 | ### Renaming A Repo 634 | 635 | NB **THIS PROCESS HAS NOT BEEN TESTED**. So long as it sits in this section of 636 | the documentation ("UNTESTED Quickstart Guides") please exercise **extreme** 637 | caution when following this process, and add any updates/changes/better-words 638 | that you feel would help the next reader. 639 | 640 | #### Prerequisites 641 | 642 | - a repo named `` exists in the org `` 643 | - a repo named `` **does *not* exist** in that same org 644 | - you want to rename `` to ``, inside the same org 645 | - you accept that the outside collaborators with access to `` will get 646 | re-invited to collaborate on the repo after the renaming 647 | 648 | #### Configuration 649 | 650 | In the `config` package, in the file dedicated to ``'s resources, 651 | find the struct 652 | 653 | ```cue 654 | github: org: : config: resource 655 | ``` 656 | 657 | Inside that struct, find the `github_repository: ` struct. 658 | 659 | Change the path element `` to ``. 660 | 661 | Also find and change any references to the repo's name in other structs such 662 | as: 663 | 664 | - `_non_org_member_access: collaborator` 665 | - `_non_org_member_access: bot` 666 | 667 | The `github_repository_collaborators` struct cannot be updated in place, so a 668 | destroy/create cycle will be performed on that resource. This will re-invite 669 | any collaborator or bot accounts to collaborate on ``, and will require 670 | them to accept the invitation before access is re-granted. 671 | 672 | Add a new struct: 673 | 674 | ```cue 675 | github: org: : config: moved 676 | ``` 677 | 678 | `moved` is an ordered list of `{ from: string, to: string }` tuples that 679 | enables Terraform to track identifier renames over time. You may well be 680 | establishing the first such element of the struct, and the struct 681 | itself, as we haven't used this Terraform feature previously. 682 | 683 | `from` and `to` must contain the **Terraform-visible** identifiers of the old 684 | and renamed repo, respectively. This means that they must reflect the name changes 685 | that are performed by `target.terraform.#Identifier.adapt{}` - which, among 686 | other things, changes periods into underscores. 687 | 688 | Both `from` and `to` must be strings, not CUE-resolved references. Neither 689 | should refer to a resource's CUE `#tfref` convenience field (that normally 690 | contains the Terraform-visible identifier for a resource, as a string) because 691 | at least one of the before- and after-the-renaming CUE structs will be missing 692 | from your config. 693 | 694 | Now read the 695 | [content change](#making-a-content-change-in-one-or-more-orgs) section, paying 696 | very close attention to the mid-PR Terraform plan output, telling you what will 697 | be created, updated in place, update with a destroy/recreate, and destroyed 698 | entirely. **Be very, very sure that you understand what Terraform is proposing 699 | to do**. 700 | 701 | ### Moving A Repo Across Orgs 702 | 703 | NB **THIS PROCESS HAS NOT BEEN TESTED** and is only sketched out as an 704 | indicator for an experienced user of this system to use as a starting point. So 705 | long as it sits in this section of the documentation ("UNTESTED Quickstart 706 | Guides") please exercise **extreme** caution when adapting this process, and 707 | add any updates/changes/better-words that you feel would help the next reader. 708 | 709 | This process is a lightweight concept for how transferring a repo across orgs 710 | might work. It will require admin-level involvement halfway through, as it 711 | leans on the *UI*-based "transfer a repo" feature that's only available to repo 712 | admins. This is because there doesn't appear to be support for cross-org 713 | transfers in 714 | [the `github` Terraform provider](https://github.com/integrations/terraform-provider-github/issues?q=is%3Aissue+transfer). 715 | 716 | 1. Move the config defining the repo between the 2 orgs' `config/org.*.cue` 717 | files and `resource` structs 718 | 1. Move (or delete) the config of any resources that are dependent on the repo 719 | (such as access granted via `_non_org_member_access` between the 2 orgs' 720 | `resource` structs 721 | 1. Raise a PR containing these changes in line with the 722 | [content change](#making-a-content-change-in-one-or-more-orgs) section, but 723 | **do not merge the PR** 724 | 1. Observe that the `terraform plan` output posted to the PR as comments shows 725 | that the repo will be deleted from one org and created in the other. Again, 726 | **do not merge the PR** 727 | 1. Ensure that all your colleagues are aware that **from this point onwards, 728 | until further notice**, you must have an exclusive lock on the repo, and 729 | **they must *not* open, sync, or merge any PRs, or otherwise cause a 730 | `terraform` invocation to ocurr** 731 | 1. Use a GitHub account (probably an org-owner, or one that has, at minimum, 732 | repo-admin permissions on the repo in question and the ability to create 733 | repos in the receiving org) to perform the transfer via the repo's settings 734 | UI 735 | 1. Re-run the PR's checks. Observe that the donating org's plan no longer tries 736 | to delete the repo (though it *will* be proposing deletion of various linked 737 | resources). Observe that the receiving org's plan still shows it is going to 738 | try and create the repo. As above, **do not merge the PR** 739 | 1. Follow the relevant parts of the separate doc on [managing existing github 740 | resources](/docs/managing-existing-resources.md), making adjustments where 741 | neccessary when your situation differs from that assumed by the document 742 | - in particular, you must perform all local `terraform` invocations whilst 743 | your PR'd branch is checked out, and your litmus test for success is *not* 744 | "the PR shows a no-op change". You won't raise a second PR, and you 745 | **won't** mark your PR as a no-op with `TERRAFORM-PLAN-NO-OP-REQUIRED` 746 | (because the donating org will have some resources deleted) 747 | - all the warnings in that doc about the risks involved still hold 748 | - all the warnings in that doc about the "critical region" still hold 749 | 1. When you have performed the import, re-run your PR's checks. Observe that: 750 | - the *donating* org will still have some deletions. Understand and agree 751 | with each 752 | - the receving org will have some creations 753 | - but critically *not* the `github_repository` resource 754 | - understand and agree with each 755 | 1. If the above point is not 100% the case, reach out for support *immediately*. 756 | 1. Merge the PR, and verify that the changes performed after the merge match 757 | those proposed in the PR comments 758 | 1. Advise your colleagues that the critical region has finished. 759 | 760 | --------------------------------------------------------------------------------