diff --git a/.github/workflows/tflint-unused.yml b/.github/workflows/tflint-unused.yml index 61e169ce3..9762a41da 100644 --- a/.github/workflows/tflint-unused.yml +++ b/.github/workflows/tflint-unused.yml @@ -1,4 +1,4 @@ -name: tflint-unused-declarations +name: terraform-lint on: pull_request: @@ -7,7 +7,7 @@ on: jobs: check: - name: Check for unused declarations + name: Lint changed Terraform runs-on: ubuntu-24.04 steps: - name: Checkout repository @@ -52,3 +52,15 @@ jobs: echo "::error::tflint found unused declarations in one or more changed modules (see groups above)." exit 1 fi + + # CI backstop for the local pre-commit hook. + - name: Check version pinning + if: steps.changed.outputs.dirs != '' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + files="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- '*.tf' || true)" + [ -z "$files" ] && exit 0 + # shellcheck disable=SC2086 + ./scripts/check-version-pinning.sh $files diff --git a/.github/workflows/tofu-test.yml b/.github/workflows/tofu-test.yml index 9ad421932..2a390226c 100644 --- a/.github/workflows/tofu-test.yml +++ b/.github/workflows/tofu-test.yml @@ -14,7 +14,7 @@ jobs: test-commons-modules: uses: nullplatform/actions-nullplatform/.github/workflows/tofu-test.yml@main with: - modules: '["infrastructure/commons/cert_manager", "infrastructure/commons/external_dns", "infrastructure/commons/istio"]' + modules: '["infrastructure/commons/cert_manager", "infrastructure/commons/external_dns", "infrastructure/commons/istio", "infrastructure/commons/prometheus"]' test-aws-modules: uses: nullplatform/actions-nullplatform/.github/workflows/tofu-test.yml@main @@ -25,12 +25,12 @@ jobs: test-gcp-modules: uses: nullplatform/actions-nullplatform/.github/workflows/tofu-test.yml@main with: - modules: '["infrastructure/gcp/artifact-registry", "infrastructure/gcp/cloud-dns", "infrastructure/gcp/cloud-nat", "infrastructure/gcp/iam", "infrastructure/gcp/vpc"]' + modules: '["infrastructure/gcp/artifact-registry", "infrastructure/gcp/cloud-dns", "infrastructure/gcp/cloud-nat", "infrastructure/gcp/gke", "infrastructure/gcp/iam", "infrastructure/gcp/security", "infrastructure/gcp/vpc"]' test-nullplatform-modules: uses: nullplatform/actions-nullplatform/.github/workflows/tofu-test.yml@main with: - modules: '["nullplatform/account", "nullplatform/api_key", "nullplatform/dimension", "nullplatform/dimension_value", "nullplatform/users", "nullplatform/metrics", "nullplatform/asset/docker_server", "nullplatform/cloud/azure/cloud", "nullplatform/cloud/gcp/cloud", "nullplatform/cloud/aws/cloud", "nullplatform/code_repository"]' + modules: '["nullplatform/account", "nullplatform/agent", "nullplatform/api_key", "nullplatform/dimension", "nullplatform/dimension_value", "nullplatform/users", "nullplatform/metrics", "nullplatform/asset/docker_server", "nullplatform/cloud/azure/cloud", "nullplatform/cloud/gcp/cloud", "nullplatform/cloud/aws/cloud", "nullplatform/code_repository", "nullplatform/base", "nullplatform/agent"]' test-container-orchestration-modules: uses: nullplatform/actions-nullplatform/.github/workflows/tofu-test.yml@main diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3a7551db7..246c08c4d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,6 +23,13 @@ repos: require_serial: true exclude: '\.terraform/|\.terragrunt-cache/' + - id: check-version-pinning + name: check version pinning + entry: scripts/check-version-pinning.sh + language: script + files: '\.tf$' + exclude: '\.terraform/|\.terragrunt-cache/' + - id: block-superpowers-files name: block superpowers files entry: >- diff --git a/CHANGELOG.md b/CHANGELOG.md index 48a2073f1..95c735de2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # Changelog +## [6.22.1](https://github.com/nullplatform/tofu-modules/compare/v6.22.0...v6.22.1) (2026-09-01) + + +### Bug Fixes + +* **base:** expose gateway_api_crd_ref, default to v1.3.0 for Istio 1.27 ([#544](https://github.com/nullplatform/tofu-modules/issues/544)) ([4bf8323](https://github.com/nullplatform/tofu-modules/commit/4bf8323b32716944411b745e2f15aae8da1a64d2)) + +## [6.22.0](https://github.com/nullplatform/tofu-modules/compare/v6.21.0...v6.22.0) (2026-09-01) + + +### Features + +* **istio:** remove legacy istio-ingressgateway helm release ([#543](https://github.com/nullplatform/tofu-modules/issues/543)) ([957dbc4](https://github.com/nullplatform/tofu-modules/commit/957dbc4b7f9ed1346511f49042fd939143d3831f)) + +## [6.21.0](https://github.com/nullplatform/tofu-modules/compare/v6.20.0...v6.21.0) (2026-08-28) + + +### Features + +* **api_key:** add base type ([#538](https://github.com/nullplatform/tofu-modules/issues/538)) ([272cbd0](https://github.com/nullplatform/tofu-modules/commit/272cbd08410dd78183363e1642c8a79478266cbd)) + +## [6.20.0](https://github.com/nullplatform/tofu-modules/compare/v6.19.1...v6.20.0) (2026-08-28) + + +### Features + +* require an explicit version for everything the modules deploy ([#540](https://github.com/nullplatform/tofu-modules/issues/540)) ([868ad98](https://github.com/nullplatform/tofu-modules/commit/868ad989663aa15e4dbdea98fa940e668e4994c2)) + +## [6.19.1](https://github.com/nullplatform/tofu-modules/compare/v6.19.0...v6.19.1) (2026-08-21) + + +### Bug Fixes + +* **base:** bump default nullplatform_base_helm_version to 2.44.0 ([#534](https://github.com/nullplatform/tofu-modules/issues/534)) ([a7e91b7](https://github.com/nullplatform/tofu-modules/commit/a7e91b7ad613da19de3a653c497152480e33f15d)) + +## [6.19.0](https://github.com/nullplatform/tofu-modules/compare/v6.18.0...v6.19.0) (2026-08-21) + + +### Features + +* **external_dns:** add google provider support ([#532](https://github.com/nullplatform/tofu-modules/issues/532)) ([222ff52](https://github.com/nullplatform/tofu-modules/commit/222ff522601d3b4f5c85cc016e79981fa58cd5c4)) + ## [7.1.0](https://github.com/nullplatform/tofu-modules/compare/v7.0.3...v7.1.0) (2026-08-19) @@ -13,6 +55,69 @@ * **service_definition:** pin default (use_default_actions) actions into the package BOM ([c1493b5](https://github.com/nullplatform/tofu-modules/commit/c1493b5a1024094606a4a67cd6c05d5812daab90)) +## [6.18.0](https://github.com/nullplatform/tofu-modules/compare/v6.17.0...v6.18.0) (2026-08-19) + + +### Features + +* **istio:** expose istio_ingressgateway_replicas to guarantee HA for node drains ([#379](https://github.com/nullplatform/tofu-modules/issues/379)) ([058986c](https://github.com/nullplatform/tofu-modules/commit/058986c7c5aaf8a998bd25bcf021fc40c2fd0dce)) + +## [6.17.0](https://github.com/nullplatform/tofu-modules/compare/v6.16.1...v6.17.0) (2026-08-19) + + +### Features + +* **base:** make logs controller and control plane agent image tags configurable ([#527](https://github.com/nullplatform/tofu-modules/issues/527)) ([1c892a5](https://github.com/nullplatform/tofu-modules/commit/1c892a566950b81a9201dc0d89e1c091dbbd6eb6)) + +## [6.16.1](https://github.com/nullplatform/tofu-modules/compare/v6.16.0...v6.16.1) (2026-08-14) + + +### Bug Fixes + +* **gcp/security:** resolve the subnetwork in its own project and region ([#520](https://github.com/nullplatform/tofu-modules/issues/520)) ([0b8f322](https://github.com/nullplatform/tofu-modules/commit/0b8f322f600de9e80630bd09764a421cc5249c20)) + +## [6.16.0](https://github.com/nullplatform/tofu-modules/compare/v6.15.0...v6.16.0) (2026-08-14) + + +### Features + +* **gcp/backend:** add GCS terraform state bucket module ([#511](https://github.com/nullplatform/tofu-modules/issues/511)) ([9355c6e](https://github.com/nullplatform/tofu-modules/commit/9355c6e4fcb6cb38be04f01fea264a54f7ac3c9c)) + +## [6.15.0](https://github.com/nullplatform/tofu-modules/compare/v6.14.0...v6.15.0) (2026-08-14) + + +### Features + +* **gcp/artifact-registry:** optional static service account key ([#514](https://github.com/nullplatform/tofu-modules/issues/514)) ([aaa9674](https://github.com/nullplatform/tofu-modules/commit/aaa96740639412dccc74b4dce56ca55f03511de5)) + +## [6.14.0](https://github.com/nullplatform/tofu-modules/compare/v6.13.1...v6.14.0) (2026-08-14) + + +### Features + +* **nullplatform/agent:** require ingress templates for non-aws clouds ([#515](https://github.com/nullplatform/tofu-modules/issues/515)) ([fbb4198](https://github.com/nullplatform/tofu-modules/commit/fbb4198d33b1da084e033e7b371d35d9509c6ed9)) + +## [6.13.1](https://github.com/nullplatform/tofu-modules/compare/v6.13.0...v6.13.1) (2026-08-14) + + +### Bug Fixes + +* **gcp/security:** handle full resource path in cluster subnetwork lookup ([#512](https://github.com/nullplatform/tofu-modules/issues/512)) ([e790af4](https://github.com/nullplatform/tofu-modules/commit/e790af437e4358a1e1254f00c3dba226fbc51b31)) + +## [6.13.0](https://github.com/nullplatform/tofu-modules/compare/v6.12.0...v6.13.0) (2026-08-13) + + +### Features + +* **aks:** support disabling local accounts with an Entra ID authorization path ([#461](https://github.com/nullplatform/tofu-modules/issues/461)) ([3e3c412](https://github.com/nullplatform/tofu-modules/commit/3e3c412b5a996241da6a904f9e86b3faf51c6487)) + +## [6.12.0](https://github.com/nullplatform/tofu-modules/compare/v6.11.3...v6.12.0) (2026-08-12) + + +### Features + +* **eks:** add traffic_manager_port variable ([#509](https://github.com/nullplatform/tofu-modules/issues/509)) ([b2165b2](https://github.com/nullplatform/tofu-modules/commit/b2165b2e0cdd922922b4eb029c9f69512c8d0e62)) + ## [7.0.3](https://github.com/nullplatform/tofu-modules/compare/v7.0.2...v7.0.3) (2026-08-11) @@ -34,6 +139,15 @@ * **azure/aks_route_table:** stable trigger instead of timestamp() ([#474](https://github.com/nullplatform/tofu-modules/issues/474)) ([#493](https://github.com/nullplatform/tofu-modules/issues/493)) ([dad52fa](https://github.com/nullplatform/tofu-modules/commit/dad52fad010915756586d05c1915b4d5bc1d1adf)) +## [6.11.3](https://github.com/nullplatform/tofu-modules/compare/v6.11.2...v6.11.3) (2026-08-11) + + +### Bug Fixes + +* **azure/aks_route_table:** stable trigger instead of timestamp() ([#474](https://github.com/nullplatform/tofu-modules/issues/474)) ([#493](https://github.com/nullplatform/tofu-modules/issues/493)) ([8d07ed2](https://github.com/nullplatform/tofu-modules/commit/8d07ed22635586a4e30baec79fcbbe994b78ddf3)) +* **azure:** make the internal gateway LB subnet configurable and grantable ([#494](https://github.com/nullplatform/tofu-modules/issues/494)) ([36f4540](https://github.com/nullplatform/tofu-modules/commit/36f45406a7bde29126939739a19f7a9139f2d31a)) +* **ci:** correct release-please target-branch resolution for the dual release line scheme ([#504](https://github.com/nullplatform/tofu-modules/issues/504)) ([895385e](https://github.com/nullplatform/tofu-modules/commit/895385ee0c76802b1773d07cf80ba5420d766ddf)) + ## [7.0.0](https://github.com/nullplatform/tofu-modules/compare/v6.11.2...v7.0.0) (2026-08-10) diff --git a/README.md b/README.md index eea697ad0..6ed387a9b 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Reference any module via its Git source, pinned to a release tag: ```hcl module "vpc" { - source = "git::https://github.com/nullplatform/tofu-modules.git//infrastructure/aws/vpc?ref=v6.11.0" + source = "git::https://github.com/nullplatform/tofu-modules.git//infrastructure/aws/vpc?ref=v6.19.1" # module inputs ... } @@ -46,6 +46,11 @@ tofu apply See the [latest releases](https://github.com/nullplatform/tofu-modules/releases) for available versions. +Several modules require you to pin a chart version, an image tag or a git ref, with no +default. **[VERSIONS.md](VERSIONS.md) lists every one of them with its current value and a +ready-to-paste block** — read it before your first apply rather than hunting the numbers down +one variable at a time. + ## Versioning Releases follow [Semantic Versioning](https://semver.org/) and are automated via [release-please](https://github.com/googleapis/release-please). See [CHANGELOG.md](CHANGELOG.md) for the full release history. diff --git a/VERSIONS.md b/VERSIONS.md new file mode 100644 index 000000000..b2352a36a --- /dev/null +++ b/VERSIONS.md @@ -0,0 +1,134 @@ +# Pinned versions + +Every version these modules deploy — Helm charts, container images, and the git refs the agent +clones — is listed here with the value to pin. + +For the version of *these modules*, see the +[releases](https://github.com/nullplatform/tofu-modules/releases). + +## Why it matters + +`latest` and branch names resolve at deploy time, not at apply time. A pod restart can pull a +different build with no change on your side and no diff to review. Every default below names +a specific release, so an upgrade is something someone decides. + +## What to pin + +Verified 2026-08-27. + +| Component | Current | Variable | Module | +| --- | --- | --- | --- | +| `nullplatform-base` chart | `2.44.0` | `nullplatform_base_helm_version` | `nullplatform/base` | +| `nullplatform-agent` chart | `2.37.0` | `nullplatform_agent_helm_version` | `nullplatform/agent` | +| `cert-manager` chart | `v1.21.1` | `cert_manager_version` | `infrastructure/commons/cert_manager` | +| `prometheus` chart | `29.27.0` | `prometheus_version` | `infrastructure/commons/prometheus` | +| `k8s-logs-controller` | `1.6.0` | `logging_controller_image_tag` | `nullplatform/base` | +| `controlplane-agent` | `0.9.2` | `control_plane_agent_image_tag` | `nullplatform/base` | +| `k8s-traffic-manager` | `1.8.0` | `agent_traffic_manager_tag` | `nullplatform/agent` | +| traffic manager (provider config) | `1.8.0` | `traffic_manager_version` | `container_orchestration/eks` | +| `scopes` repository | `v1.15.1` | `agent_repos_scope` | `nullplatform/agent` | + +**Read your cluster before copying these.** The rule is to pin what you are already running, +so the change stays functionally inert. Four of these were previously unpinnable and resolved +at deploy time, so what you run may not match the table: `cert_manager_version`, +`prometheus_version`, `logging_controller_image_tag`, and `traffic_manager_version` on eks. + +## Ready to paste + +```hcl +module "base" { + nullplatform_base_helm_version = "2.44.0" + logging_controller_image_tag = "1.6.0" + control_plane_agent_image_tag = "0.9.2" +} + +module "agent" { + nullplatform_agent_helm_version = "2.37.0" + image_tag = "0.9.2" + agent_repos_scope_tag = "v1.15.1" + agent_traffic_manager_tag = "1.8.0" + + agent_repos_extra = [ + "https://github.com/nullplatform/scopes-lambda.git#v0.3.1", + "https://github.com/nullplatform/scopes-static-files.git#v0.4.0", + ] +} + +# eks, aks and gke all take this +module "container_orchestration" { + traffic_manager_version = "1.8.0" +} + +module "cert_manager" { + cert_manager_version = "v1.21.1" +} + +module "prometheus" { + prometheus_version = "29.27.0" +} + +module "service_definition" { + # No value listed: repository_org and repository_name are configurable, so which spec + # repository you read is your choice and so is its ref. + repository_branch = "..." +} +``` + +To find what an install is actually running before changing anything: + +```bash +helm -n nullplatform-tools get values nullplatform-base +helm -n list -o json | jq -r '.[] | "\(.name)\t\(.chart)"' +kubectl -n nullplatform-tools get deploy \ + -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.template.spec.containers[*].image}{"\n"}{end}' +``` + +The traffic manager image is assembled from `agent_traffic_manager_tag` and published to the +agent as `TRAFFIC_CONTAINER_IMAGE`. `extra_envs` still takes precedence over it, so a digest +or a mirrored registry path can be passed the way it was before the tag was exposed. + +## Caveats + +**The scopes ref steps back.** `agent_repos_scope` used to point at `scopes.git#main`, and +`main` has moved past `v1.15.1`. Pinning the tag is deliberate — it is the ref named in the +migration request — but it is not the same tree the branch tip pointed at. + +**cert-manager and prometheus were not pinnable at all.** `cert_manager_version` existed but +was never wired to its `helm_release`, and `prometheus` had no version argument, so both +tracked whatever their chart repository served. + +**Not everything is covered yet.** The scopes and service-spec repositories are read through +eleven other paths, in `scope_definition`, `scope_definition_agent_association`, +`parameter_storage_definition` and `service_definition`, and those still default to a moving +branch. Pinning `agent_repos_scope` does not cover them: the agent clones the ref while the +definition modules read the branch. They are listed in +`scripts/version-pinning-baseline.txt` with the reason. + +**A name cannot prove immutability.** The checks below reject `latest`, `main`, `master` and +`HEAD`. A tag called `beta` or a branch called `develop` passes. Nothing distinguishes a +mutable ref from a fixed one by name alone. + +## Keeping this current + +There is no automation that bumps these numbers, on purpose. Bumping a documented version to +whatever is newest would put the drift back in documentation form, and it contradicts the rule +above about pinning what you already run. When a new version ships, someone decides and edits +this table. + +What is automated is the opposite direction: `scripts/check-version-pinning.sh` rejects a *new* +moving default, a repository URL pinned to a branch, or a `helm_release` with no `version`. It +runs in pre-commit and again as a step in the `terraform-lint` workflow, so skipping the local +hook does not skip the check. Deliberately deferred violations live in +`scripts/version-pinning-baseline.txt` with the reason; that file should only ever shrink. + +One trap worth knowing before bumping an image by hand: **`k8s-traffic-manager` publishes a +`v2.0.2` built 2026-02-09 while `1.8.0` was built 2026-07-29**, and `k8s-logs-controller` a +`v2.0.1` from that same February against a `1.6.0` from August. The higher version number is +the older build, from a line that was not continued. Compare build dates, not version numbers: + +```bash +tag=1.8.0; repo=nullplatform/k8s-traffic-manager +t=$(curl -s "https://public.ecr.aws/token/?scope=repository:$repo:pull" | jq -r .token) +curl -s -H "Authorization: Bearer $t" "https://public.ecr.aws/v2/$repo/tags/list" | jq -r '.tags[]' +``` + diff --git a/infrastructure/aws/vpc/main.tf b/infrastructure/aws/vpc/main.tf index b0144843d..18dbec401 100644 --- a/infrastructure/aws/vpc/main.tf +++ b/infrastructure/aws/vpc/main.tf @@ -24,8 +24,8 @@ module "vpc" { # } public_subnet_tags = { - "kubernetes.io/role/elb" = 1 - "nullplatform/subnet-type" = "public" + "kubernetes.io/role/elb" = 1 + "nullplatform/subnet-type" = "public" } private_subnet_tags = { diff --git a/infrastructure/azure/aks/main.tf b/infrastructure/azure/aks/main.tf index cff490c16..7d24c162f 100644 --- a/infrastructure/azure/aks/main.tf +++ b/infrastructure/azure/aks/main.tf @@ -30,8 +30,10 @@ module "aks" { # RBAC / AAD / OIDC / Workload Identity ############################################ role_based_access_control_enabled = true - rbac_aad_azure_rbac_enabled = false + rbac_aad_azure_rbac_enabled = var.azure_rbac_enabled + rbac_aad_admin_group_object_ids = var.admin_group_object_ids rbac_aad_tenant_id = data.azurerm_client_config.current.tenant_id + local_account_disabled = var.local_account_disabled workload_identity_enabled = true oidc_issuer_enabled = true diff --git a/infrastructure/azure/aks/validations.tf b/infrastructure/azure/aks/validations.tf index 974228a50..bc1985b35 100644 --- a/infrastructure/azure/aks/validations.tf +++ b/infrastructure/azure/aks/validations.tf @@ -9,5 +9,14 @@ resource "terraform_data" "validations" { condition = var.attach_acr != true || var.acr_id != null error_message = "acr_id is required when attach_acr is true. Leave attach_acr null (legacy) or set it false for clusters without an ACR." } + + # Disabling local accounts removes the certificate-based admin path, leaving Entra ID as the only + # way in. Without Azure RBAC or an admin group, no identity is authorized against the API server: + # the cluster stays reachable only through whatever admin kubeconfig was issued beforehand, and + # becomes unrecoverable from configuration once that credential stops working. + precondition { + condition = var.local_account_disabled != true || var.azure_rbac_enabled || try(length(var.admin_group_object_ids), 0) > 0 + error_message = "local_account_disabled = true removes the only certificate-based path into the cluster. Set azure_rbac_enabled = true, or provide admin_group_object_ids, so at least one Entra ID identity stays authorized." + } } } diff --git a/infrastructure/azure/aks/variables.tf b/infrastructure/azure/aks/variables.tf index 6655a868d..74eb1cc47 100644 --- a/infrastructure/azure/aks/variables.tf +++ b/infrastructure/azure/aks/variables.tf @@ -200,3 +200,26 @@ variable "system_pool_node_count" { type = number default = 2 } + +############################################################################### +# OPTIONAL VARIABLES - CLUSTER ACCESS AND AUTHORIZATION +############################################################################### + +variable "local_account_disabled" { + type = bool + description = "Whether to disable the AKS local (certificate-based) admin accounts. Null (default) leaves the Azure default, which keeps them enabled. When true, Entra ID becomes the only way into the API server, so an authorization path must be configured as well — see azure_rbac_enabled and admin_group_object_ids." + default = null +} + +variable "azure_rbac_enabled" { + type = bool + description = "Whether Kubernetes authorization is delegated to Azure RBAC, so cluster access is granted with Azure role assignments such as 'Azure Kubernetes Service RBAC Cluster Admin'. Defaults to false, which keeps authorization inside Kubernetes RBAC." + default = false + nullable = false +} + +variable "admin_group_object_ids" { + type = list(string) + description = "Entra ID group object IDs whose members get cluster-admin through Kubernetes RBAC. The alternative to azure_rbac_enabled when authorization should stay in-cluster." + default = null +} diff --git a/infrastructure/commons/cert_manager/main.tf b/infrastructure/commons/cert_manager/main.tf index 284dbc7e9..5ec6eda99 100644 --- a/infrastructure/commons/cert_manager/main.tf +++ b/infrastructure/commons/cert_manager/main.tf @@ -3,6 +3,7 @@ resource "helm_release" "cert_manager" { repository = "https://charts.jetstack.io" chart = "cert-manager" namespace = var.cert_manager_namespace + version = var.cert_manager_version create_namespace = true disable_webhooks = false diff --git a/infrastructure/commons/cert_manager/tests/cert_manager_aws.tftest.hcl b/infrastructure/commons/cert_manager/tests/cert_manager_aws.tftest.hcl index d7b52522f..e085bb663 100644 --- a/infrastructure/commons/cert_manager/tests/cert_manager_aws.tftest.hcl +++ b/infrastructure/commons/cert_manager/tests/cert_manager_aws.tftest.hcl @@ -1,12 +1,13 @@ mock_provider "helm" {} variables { - cloud_provider = "aws" - hosted_zone_name = "myorg.example.com" - account_slug = "myorg" - private_domain_name = "myorg.example.com" - aws_sa_arn = "arn:aws:iam::123456789012:role/cert-manager" - aws_region = "us-east-1" + cloud_provider = "aws" + hosted_zone_name = "myorg.example.com" + account_slug = "myorg" + private_domain_name = "myorg.example.com" + aws_sa_arn = "arn:aws:iam::123456789012:role/cert-manager" + aws_region = "us-east-1" + cert_manager_version = "v1.21.1" } # Validates AWS provider config plans successfully @@ -100,3 +101,19 @@ run "rejects_invalid_aws_identity_mode" { expect_failures = [var.aws_identity_mode] } + +################################################################################ +# Version pinning +################################################################################ + +# cert_manager_version was declared with a default and never referenced: grep found one +# occurrence, its own declaration. The helm_release had no version argument, so installs +# tracked whatever charts.jetstack.io served while the README showed a number. +run "cert_manager_version_reaches_the_release" { + command = plan + + assert { + condition = helm_release.cert_manager.version == "v1.21.1" + error_message = "cert_manager_version must be wired to the helm_release, not merely declared" + } +} diff --git a/infrastructure/commons/cert_manager/tests/cert_manager_azure.tftest.hcl b/infrastructure/commons/cert_manager/tests/cert_manager_azure.tftest.hcl index 479a3c76f..f9a069769 100644 --- a/infrastructure/commons/cert_manager/tests/cert_manager_azure.tftest.hcl +++ b/infrastructure/commons/cert_manager/tests/cert_manager_azure.tftest.hcl @@ -11,6 +11,7 @@ variables { azure_resource_group_name = "rg-test" azure_tenant_id = "11111111-2222-3333-4444-555555555555" azure_hosted_zone_name = "myorg.nullimplementation.com" + cert_manager_version = "v1.21.1" } # Validates Azure provider config plans successfully diff --git a/infrastructure/commons/cert_manager/tests/cert_manager_cloudflare.tftest.hcl b/infrastructure/commons/cert_manager/tests/cert_manager_cloudflare.tftest.hcl index 47e95bb29..8932865c9 100644 --- a/infrastructure/commons/cert_manager/tests/cert_manager_cloudflare.tftest.hcl +++ b/infrastructure/commons/cert_manager/tests/cert_manager_cloudflare.tftest.hcl @@ -1,11 +1,12 @@ mock_provider "helm" {} variables { - cloud_provider = "cloudflare" - hosted_zone_name = "myorg.nullimplementation.com" - account_slug = "myorg" - private_domain_name = "myorg.nullimplementation.com" - cloudflare_token = "fake-cloudflare-token-for-testing" + cloud_provider = "cloudflare" + hosted_zone_name = "myorg.nullimplementation.com" + account_slug = "myorg" + private_domain_name = "myorg.nullimplementation.com" + cloudflare_token = "fake-cloudflare-token-for-testing" + cert_manager_version = "v1.21.1" } # Validates Cloudflare provider config plans successfully diff --git a/infrastructure/commons/cert_manager/tests/cert_manager_cross_provider.tftest.hcl b/infrastructure/commons/cert_manager/tests/cert_manager_cross_provider.tftest.hcl index 89b0334df..8c3d9cf43 100644 --- a/infrastructure/commons/cert_manager/tests/cert_manager_cross_provider.tftest.hcl +++ b/infrastructure/commons/cert_manager/tests/cert_manager_cross_provider.tftest.hcl @@ -1,5 +1,9 @@ mock_provider "helm" {} +variables { + cert_manager_version = "v1.21.1" +} + # Validates invalid cloud_provider is rejected run "rejects_invalid_provider" { command = plan diff --git a/infrastructure/commons/cert_manager/tests/cert_manager_oci.tftest.hcl b/infrastructure/commons/cert_manager/tests/cert_manager_oci.tftest.hcl index 306360989..3a87c59b3 100644 --- a/infrastructure/commons/cert_manager/tests/cert_manager_oci.tftest.hcl +++ b/infrastructure/commons/cert_manager/tests/cert_manager_oci.tftest.hcl @@ -8,6 +8,7 @@ variables { oci_compartment_ocid = "ocid1.compartment.oc1..aaaaaaaatest" oci_sa_ocid = "ocid1.principal.oc1..aaaaaaaatest" oci_region = "us-ashburn-1" + cert_manager_version = "v1.21.1" } # Validates OCI provider config plans successfully diff --git a/infrastructure/commons/cert_manager/variables.tf b/infrastructure/commons/cert_manager/variables.tf index 0ed891cab..85a75d454 100644 --- a/infrastructure/commons/cert_manager/variables.tf +++ b/infrastructure/commons/cert_manager/variables.tf @@ -62,9 +62,14 @@ variable "private_domain_name" { ############################################################################### variable "cert_manager_version" { - description = "The version of cert-manager Helm chart to deploy" + # example: v1.21.1 + description = "No default: every install pins this deliberately — see VERSIONS.md. The version of cert-manager Helm chart to deploy. Was declared but never wired to the helm_release, so installs tracked whatever the chart repository served; the default is the version that resolved to as of 2026-08-27, which keeps behaviour unchanged while removing the drift." type = string - default = "1.18.2" + + validation { + condition = var.cert_manager_version != "" && !contains(["latest", "main", "master"], lower(var.cert_manager_version)) + error_message = "cert_manager_version must be a non-empty fixed version, not empty and not a moving reference." + } } variable "cert_manager_namespace" { diff --git a/infrastructure/commons/external_dns/locals.tf b/infrastructure/commons/external_dns/locals.tf index 0bd7d99cf..5c47dbaea 100644 --- a/infrastructure/commons/external_dns/locals.tf +++ b/infrastructure/commons/external_dns/locals.tf @@ -123,12 +123,28 @@ locals { ] } + google_config = { + provider = { name = "google" } + serviceAccount = { + create = true + name = var.gcp_service_account_name + annotations = { + "iam.gke.io/gcp-service-account" = var.gcp_service_account_email + } + } + extraArgs = [ + "--google-project=${var.gcp_project_id}", + "--google-zone-visibility=${lower(var.zone_type)}", + ] + } + provider_configs = { cloudflare = local.cloudflare_config aws = local.route53_config oci = local.oci_config azure = local.azure_config "azure-private-dns" = local.azure_config + google = local.google_config } external_dns_values = merge(local.base_config, local.provider_configs[var.dns_provider_name]) diff --git a/infrastructure/commons/external_dns/tests/external_dns_cross_provider.tftest.hcl b/infrastructure/commons/external_dns/tests/external_dns_cross_provider.tftest.hcl index 05d0896ee..0fb4841e7 100644 --- a/infrastructure/commons/external_dns/tests/external_dns_cross_provider.tftest.hcl +++ b/infrastructure/commons/external_dns/tests/external_dns_cross_provider.tftest.hcl @@ -111,4 +111,9 @@ run "all_providers_in_config_map" { condition = contains(keys(local.provider_configs), "oci") error_message = "provider_configs should contain oci" } + + assert { + condition = contains(keys(local.provider_configs), "google") + error_message = "provider_configs should contain google" + } } diff --git a/infrastructure/commons/external_dns/tests/external_dns_google.tftest.hcl b/infrastructure/commons/external_dns/tests/external_dns_google.tftest.hcl new file mode 100644 index 000000000..cc8e35773 --- /dev/null +++ b/infrastructure/commons/external_dns/tests/external_dns_google.tftest.hcl @@ -0,0 +1,176 @@ +mock_provider "helm" {} +mock_provider "kubernetes" {} + +variables { + dns_provider_name = "google" + domain_filters = "myorg.example.com" + external_dns_namespace = "external-dns" + gcp_project_id = "my-gcp-project" + gcp_service_account_email = "external-dns@my-gcp-project.iam.gserviceaccount.com" + zone_type = "public" +} + +run "google_full_config" { + command = plan + + assert { + condition = helm_release.external_dns.name == "external-dns-public" + error_message = "Helm release name should include type suffix" + } +} + +run "google_workload_identity_annotation" { + command = plan + + assert { + condition = local.google_config.serviceAccount.annotations["iam.gke.io/gcp-service-account"] == "external-dns@my-gcp-project.iam.gserviceaccount.com" + error_message = "GCP Workload Identity annotation should match gcp_service_account_email" + } +} + +run "google_project_in_values" { + command = plan + + assert { + condition = contains(local.google_config.extraArgs, "--google-project=my-gcp-project") + error_message = "extraArgs should include --google-project derived from gcp_project_id" + } +} + +run "google_values_reach_helm_release" { + command = plan + + assert { + condition = local.external_dns_values.provider.name == "google" + error_message = "external_dns_values should select the google provider config" + } + + assert { + condition = contains(local.external_dns_values.extraArgs, "--google-project=my-gcp-project") + error_message = "gcp_project_id must reach the chart via external_dns_values.extraArgs as --google-project" + } +} + +run "google_zone_visibility_lowercased" { + command = plan + + variables { + zone_type = "Public" + } + + assert { + condition = contains(local.google_config.extraArgs, "--google-zone-visibility=public") + error_message = "zone_type should be lowercased before being passed as --google-zone-visibility, even when the input has mixed case" + } +} + +run "google_zone_visibility_arg" { + command = plan + + assert { + condition = contains(local.google_config.extraArgs, "--google-zone-visibility=public") + error_message = "Extra args should include --google-zone-visibility" + } +} + +run "google_private_zone_visibility_arg" { + command = plan + + variables { + zone_type = "private" + } + + assert { + condition = contains(local.google_config.extraArgs, "--google-zone-visibility=private") + error_message = "Extra args should include --google-zone-visibility=private when zone_type is private" + } +} + +run "google_default_service_account_name" { + command = plan + + assert { + condition = local.google_config.serviceAccount.name == "external-dns" + error_message = "Default GCP service account name should be external-dns" + } +} + +run "google_custom_service_account_name" { + command = plan + + variables { + gcp_service_account_name = "external-dns-private" + } + + assert { + condition = local.google_config.serviceAccount.name == "external-dns-private" + error_message = "Custom gcp_service_account_name should be reflected in serviceAccount.name" + } +} + +run "no_cloudflare_secret_for_google" { + command = plan + + assert { + condition = length(kubernetes_secret_v1.external_dns_cloudflare) == 0 + error_message = "Cloudflare secret should not be created for google provider" + } +} + +run "no_azure_secret_for_google" { + command = plan + + assert { + condition = length(kubernetes_secret_v1.external_dns_azure_config) == 0 + error_message = "Azure secret should not be created for google provider" + } +} + +run "no_oci_secret_for_google" { + command = plan + + assert { + condition = length(kubernetes_secret_v1.external_dns_oci_config) == 0 + error_message = "OCI secret should not be created for google provider" + } +} + +run "google_requires_project_id" { + command = plan + + variables { + gcp_project_id = "" + } + + expect_failures = [terraform_data.provider_validation] +} + +run "google_requires_service_account_email" { + command = plan + + variables { + gcp_service_account_email = "" + } + + expect_failures = [terraform_data.provider_validation] +} + +run "google_requires_zone_type" { + command = plan + + variables { + zone_type = "" + } + + expect_failures = [terraform_data.provider_validation] +} + +run "google_rejects_invalid_zone_type" { + command = plan + + variables { + zone_type = "internal" + } + + expect_failures = [terraform_data.provider_validation] +} diff --git a/infrastructure/commons/external_dns/validation.tf b/infrastructure/commons/external_dns/validation.tf index 405af91db..4746aaf4c 100644 --- a/infrastructure/commons/external_dns/validation.tf +++ b/infrastructure/commons/external_dns/validation.tf @@ -28,6 +28,18 @@ resource "terraform_data" "provider_validation" { condition = var.dns_provider_name != "oci" || var.oci_region != "" error_message = "oci_region is required when dns_provider_name is 'oci'." } + precondition { + condition = var.dns_provider_name != "google" || length(var.gcp_project_id) > 0 + error_message = "gcp_project_id is required when dns_provider_name is 'google'." + } + precondition { + condition = var.dns_provider_name != "google" || length(var.gcp_service_account_email) > 0 + error_message = "gcp_service_account_email is required when dns_provider_name is 'google'." + } + precondition { + condition = var.dns_provider_name != "google" || (var.zone_type != "" && contains(["public", "private"], lower(var.zone_type))) + error_message = "When dns_provider_name is 'google', zone_type must be 'public' or 'private'." + } precondition { condition = !local.azure_family_active || length(var.azure_client_id) > 0 error_message = "azure_client_id is required when dns_provider_name is 'azure' or 'azure-private-dns'." diff --git a/infrastructure/commons/external_dns/variables.tf b/infrastructure/commons/external_dns/variables.tf index 159e40d7a..3a9519c6d 100644 --- a/infrastructure/commons/external_dns/variables.tf +++ b/infrastructure/commons/external_dns/variables.tf @@ -112,7 +112,7 @@ variable "zone_id_filter" { } variable "zone_type" { - description = "The Route53 hosted zone type for ExternalDNS to manage (public or private)" + description = "The DNS zone type/visibility for ExternalDNS to manage (public or private). Used by the 'aws' (--aws-zone-type) and 'google' (--google-zone-visibility) providers." type = string default = "" nullable = false @@ -164,8 +164,8 @@ variable "dns_provider_name" { type = string description = "The DNS provider to use with ExternalDNS. Use 'azure' for Azure Public DNS zones and 'azure-private-dns' for Azure Private DNS zones — both share the same auth, secret, and ServiceAccount wiring." validation { - condition = contains(["cloudflare", "aws", "oci", "azure", "azure-private-dns"], var.dns_provider_name) - error_message = "dns_provider_name must be one of: 'cloudflare', 'aws', 'oci', 'azure', 'azure-private-dns'." + condition = contains(["cloudflare", "aws", "oci", "azure", "azure-private-dns", "google"], var.dns_provider_name) + error_message = "dns_provider_name must be one of: 'cloudflare', 'aws', 'oci', 'azure', 'azure-private-dns', 'google'." } } @@ -216,3 +216,28 @@ variable "azure_tenant_id" { default = "" } +############################################################################### +# GCP CONFIGURATION +############################################################################### + +variable "gcp_project_id" { + description = "The GCP project ID where the Cloud DNS zones are located (required when dns_provider_name is 'google')" + type = string + default = "" + nullable = false +} + +variable "gcp_service_account_email" { + description = "Email of the GCP service account bound via Workload Identity for Cloud DNS access (required when dns_provider_name is 'google'). Create the service account and the Workload Identity binding outside this module (e.g. with infrastructure/gcp/iam) and pass its email here." + type = string + default = "" + nullable = false +} + +variable "gcp_service_account_name" { + description = "The Kubernetes service account name for GCP Workload Identity" + type = string + default = "external-dns" + nullable = false +} + diff --git a/infrastructure/commons/istio/locals.tf b/infrastructure/commons/istio/locals.tf deleted file mode 100644 index 96cc47ae7..000000000 --- a/infrastructure/commons/istio/locals.tf +++ /dev/null @@ -1,13 +0,0 @@ -locals { - helm_values = templatefile("${path.module}/templates/istio_ingressgateway.tmpl.yaml", { - service_type = var.service_type - status_port = var.status_port - https_port = var.https_port - https_target_port = var.https_target_port - enable_http2 = var.enable_http2 - http2_port = var.http2_port - http2_target_port = var.http2_target_port - cloud_provider = var.cloud_provider - oci_load_balancer_subnet_ids = var.oci_load_balancer_subnet_ids - }) -} diff --git a/infrastructure/commons/istio/main.tf b/infrastructure/commons/istio/main.tf index 3f8c27c59..a9c6f6ade 100644 --- a/infrastructure/commons/istio/main.tf +++ b/infrastructure/commons/istio/main.tf @@ -61,32 +61,3 @@ resource "helm_release" "istiod" { }, ] } - -# Setup Istio Gateway using Helm -resource "helm_release" "istio_ingressgateway" { - name = "istio-ingressgateway" - depends_on = [helm_release.istiod] - repository = var.repository - chart = "gateway" - namespace = var.namespace - version = var.istio_ingressgateway_version - - create_namespace = true - disable_webhooks = false - force_update = true - wait = true - wait_for_jobs = true - timeout = 600 - atomic = true - cleanup_on_fail = true - replace = true - recreate_pods = true - reset_values = true - reuse_values = false - dependency_update = true - max_history = 10 - - values = [local.helm_values] - - -} diff --git a/infrastructure/commons/istio/templates/istio_ingressgateway.tmpl.yaml b/infrastructure/commons/istio/templates/istio_ingressgateway.tmpl.yaml deleted file mode 100644 index f4dd4d38c..000000000 --- a/infrastructure/commons/istio/templates/istio_ingressgateway.tmpl.yaml +++ /dev/null @@ -1,20 +0,0 @@ -service: - type: ${service_type} -%{ if cloud_provider == "oci" && length(oci_load_balancer_subnet_ids) > 0 ~} - annotations: - service.beta.kubernetes.io/oci-load-balancer-subnet1: "${join(",", oci_load_balancer_subnet_ids)}" - service.beta.kubernetes.io/oci-load-balancer-internal: "true" -%{ endif ~} - ports: - - name: status-port - port: ${status_port} - targetPort: ${status_port} - %{ if enable_http2 } - - name: http2 - port: ${http2_port} - protocol: TCP - targetPort: ${http2_target_port} - %{ endif } - - name: https - port: ${https_port} - targetPort: ${https_target_port} diff --git a/infrastructure/commons/istio/tests/istio.tftest.hcl b/infrastructure/commons/istio/tests/istio.tftest.hcl index bcf7f95dd..6fa66e39b 100644 --- a/infrastructure/commons/istio/tests/istio.tftest.hcl +++ b/infrastructure/commons/istio/tests/istio.tftest.hcl @@ -13,14 +13,9 @@ run "default_config" { condition = helm_release.istiod.namespace == "istio-system" error_message = "Istiod should deploy to istio-system namespace" } - - assert { - condition = helm_release.istio_ingressgateway.namespace == "istio-system" - error_message = "Ingress gateway should deploy to istio-system namespace" - } } -# Validates all three components have correct chart names +# Validates both components have correct chart names run "correct_chart_names" { command = plan @@ -33,11 +28,6 @@ run "correct_chart_names" { condition = helm_release.istiod.chart == "istiod" error_message = "Istiod chart should be 'istiod'" } - - assert { - condition = helm_release.istio_ingressgateway.chart == "gateway" - error_message = "Ingress gateway chart should be 'gateway'" - } } # Validates consistent versions across all components @@ -45,9 +35,8 @@ run "consistent_versions" { command = plan variables { - istio_base_version = "1.27.1" - istiod_version = "1.27.1" - istio_ingressgateway_version = "1.27.1" + istio_base_version = "1.27.1" + istiod_version = "1.27.1" } assert { @@ -59,11 +48,6 @@ run "consistent_versions" { condition = helm_release.istiod.version == "1.27.1" error_message = "Istiod version should match" } - - assert { - condition = helm_release.istio_ingressgateway.version == "1.27.1" - error_message = "Ingress gateway version should match" - } } # Validates custom namespace is propagated to all components @@ -83,11 +67,6 @@ run "custom_namespace" { condition = helm_release.istiod.namespace == "custom-istio" error_message = "Istiod should use custom namespace" } - - assert { - condition = helm_release.istio_ingressgateway.namespace == "custom-istio" - error_message = "Ingress gateway should use custom namespace" - } } # Validates all releases use atomic deployments @@ -103,11 +82,6 @@ run "atomic_deployments" { condition = helm_release.istiod.atomic == true error_message = "Istiod should use atomic deployment" } - - assert { - condition = helm_release.istio_ingressgateway.atomic == true - error_message = "Ingress gateway should use atomic deployment" - } } # Validates custom repository URL is propagated diff --git a/infrastructure/commons/istio/tests/istio_cross_provider.tftest.hcl b/infrastructure/commons/istio/tests/istio_cross_provider.tftest.hcl deleted file mode 100644 index 4975a5839..000000000 --- a/infrastructure/commons/istio/tests/istio_cross_provider.tftest.hcl +++ /dev/null @@ -1,64 +0,0 @@ -mock_provider "helm" {} - -# Validates invalid cloud_provider is rejected -run "rejects_invalid_provider" { - command = plan - - variables { - cloud_provider = "digitalocean" - } - - expect_failures = [var.cloud_provider] -} - -# Validates default config (no cloud_provider) does not include OCI annotations -run "default_config_no_oci_annotations" { - command = plan - - assert { - condition = !can(regex("oci\\.oraclecloud\\.com/subnet-ids", local.helm_values)) - error_message = "OCI annotation should not be present when cloud_provider is not set" - } -} - -# Validates gcp provider plans successfully without OCI vars -run "gcp_provider_no_oci_vars_required" { - command = plan - - variables { - cloud_provider = "gcp" - } - - assert { - condition = !can(regex("oci\\.oraclecloud\\.com/subnet-ids", local.helm_values)) - error_message = "OCI annotation should not be present for gcp provider" - } -} - -# Validates aws provider plans successfully without OCI vars -run "aws_provider_no_oci_vars_required" { - command = plan - - variables { - cloud_provider = "aws" - } - - assert { - condition = !can(regex("oci\\.oraclecloud\\.com/subnet-ids", local.helm_values)) - error_message = "OCI annotation should not be present for aws provider" - } -} - -# Validates azure provider plans successfully without OCI vars -run "azure_provider_no_oci_vars_required" { - command = plan - - variables { - cloud_provider = "azure" - } - - assert { - condition = !can(regex("oci\\.oraclecloud\\.com/subnet-ids", local.helm_values)) - error_message = "OCI annotation should not be present for azure provider" - } -} diff --git a/infrastructure/commons/istio/tests/istio_oci.tftest.hcl b/infrastructure/commons/istio/tests/istio_oci.tftest.hcl deleted file mode 100644 index 05d603e54..000000000 --- a/infrastructure/commons/istio/tests/istio_oci.tftest.hcl +++ /dev/null @@ -1,61 +0,0 @@ -mock_provider "helm" {} - -variables { - cloud_provider = "oci" - oci_load_balancer_subnet_ids = ["ocid1.subnet.oc1..aaaaaaaatest"] -} - -# Validates OCI provider config plans successfully -run "oci_full_config" { - command = plan - - assert { - condition = helm_release.istio_ingressgateway.namespace == "istio-system" - error_message = "Ingress gateway should deploy to istio-system namespace" - } -} - -# Validates OCI subnet annotation is present in rendered helm values -run "oci_annotation_present" { - command = plan - - assert { - condition = can(regex("service\\.beta\\.kubernetes\\.io/oci-load-balancer-subnet1", local.helm_values)) - error_message = "OCI subnet annotation should be present in helm values" - } -} - -# Validates OCI annotation contains the provided subnet OCID -run "oci_annotation_contains_subnet_id" { - command = plan - - assert { - condition = can(regex("ocid1\\.subnet\\.oc1\\.\\.aaaaaaaatest", local.helm_values)) - error_message = "OCI annotation should include the provided subnet OCID" - } -} - -# Validates multiple subnet OCIDs are joined with comma -run "oci_multiple_subnets_joined" { - command = plan - - variables { - oci_load_balancer_subnet_ids = ["ocid1.subnet.oc1..aaaaaaaafirst", "ocid1.subnet.oc1..aaaaaaaasecond"] - } - - assert { - condition = can(regex("ocid1\\.subnet\\.oc1\\.\\.aaaaaaaafirst,ocid1\\.subnet\\.oc1\\.\\.aaaaaaaasecond", local.helm_values)) - error_message = "Multiple OCI subnets should be comma-joined in the annotation" - } -} - -# Validates OCI fails without subnet IDs -run "oci_requires_subnet_ids" { - command = plan - - variables { - oci_load_balancer_subnet_ids = [] - } - - expect_failures = [terraform_data.provider_validation] -} diff --git a/infrastructure/commons/istio/validation.tf b/infrastructure/commons/istio/validation.tf deleted file mode 100644 index 8f373a945..000000000 --- a/infrastructure/commons/istio/validation.tf +++ /dev/null @@ -1,8 +0,0 @@ -resource "terraform_data" "provider_validation" { - lifecycle { - precondition { - condition = var.cloud_provider != "oci" || length(var.oci_load_balancer_subnet_ids) > 0 - error_message = "oci_load_balancer_subnet_ids is required when cloud_provider is 'oci'." - } - } -} diff --git a/infrastructure/commons/istio/variables.tf b/infrastructure/commons/istio/variables.tf index cc29466b7..8c034cd2e 100644 --- a/infrastructure/commons/istio/variables.tf +++ b/infrastructure/commons/istio/variables.tf @@ -8,12 +8,6 @@ variable "istio_base_version" { default = "1.27.1" } -variable "istio_ingressgateway_version" { - description = "Helm chart version for the Istio ingress gateway" - type = string - default = "1.27.1" -} - variable "istiod_version" { description = "Helm chart version for istiod (Istio control plane)" type = string @@ -31,35 +25,6 @@ variable "istiod_replicas" { } } -############################################################################### -# SERVICE CONFIGURATION -############################################################################### - - -variable "service_type" { - type = string - description = "The Kubernetes service type for the Istio ingress gateway" - default = "LoadBalancer" -} - -variable "status_port" { - type = number - description = "The status port used (status-port)" - default = 15021 -} - -variable "https_port" { - type = number - description = "The external HTTPS service port" - default = 443 -} - -variable "https_target_port" { - type = number - description = "The container target port for HTTPS" - default = 8443 -} - ############################################################################### # REPOSITORY CONFIGURATION ############################################################################### @@ -76,53 +41,7 @@ variable "repository" { variable "namespace" { type = string - description = "The Kubernetes namespace where gateway will be installed." + description = "The Kubernetes namespace where Istio will be installed." default = "istio-system" } - -############################################################################### -# CLOUD PROVIDER CONFIGURATION -############################################################################### - -variable "cloud_provider" { - type = string - description = "The cloud provider where the cluster is running. Used to inject provider-specific LoadBalancer annotations (e.g. oci). Leave empty for generic/on-prem clusters." - default = "" - validation { - condition = contains(["", "aws", "oci", "azure", "gcp"], var.cloud_provider) - error_message = "Value must be one of: '', 'aws', 'oci', 'azure', 'gcp'" - } -} - -############################################################################### -# OCI CONFIGURATION -############################################################################### - -variable "oci_load_balancer_subnet_ids" { - type = list(string) - description = "List of OCI subnet OCIDs for the LoadBalancer Service (required when cloud_provider is 'oci')" - default = [] -} - -############################################################################### -# HTTP2 CONFIGURATION -############################################################################### - -variable "enable_http2" { - type = bool - description = "Whether to expose the HTTP2 (port 80) service" - default = false -} - -variable "http2_port" { - type = number - description = "The external service port for HTTP2 when enabled." - default = 80 -} - -variable "http2_target_port" { - type = number - description = "The container target port for HTTP2 when enabled" - default = 80 -} diff --git a/infrastructure/commons/prometheus/.terraform.lock.hcl b/infrastructure/commons/prometheus/.terraform.lock.hcl new file mode 100644 index 000000000..89971bbf5 --- /dev/null +++ b/infrastructure/commons/prometheus/.terraform.lock.hcl @@ -0,0 +1,25 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/helm" { + version = "3.2.0" + constraints = "~> 3.0" + hashes = [ + "h1:thNSoWm4pdgTEO1XBi1n5V2nwsdSA6EVEXP3qPZiFcA=", + "zh:1a214581dee54ec4e9afa4050e54f6c187aed4b51b2d2ac929c58706b65e1159", + "zh:2f8ba94af93011768ed1fffd4d25b980cd764f2d49c9f13475512ec48464da0b", + "zh:36373bca4f374e95f654def79e0b12df0c8f2e01c634db80ff2737ded29062e3", + "zh:3a26b5c3e47b2bbc01faa0fa9fe816ea4f74f1f8f4555dfad7f62ee38266aaff", + "zh:51bc637700f13cfc1f7c6a8c03a4b5b5755338419912d945dbfe5ccb0ddbf614", + "zh:53f32f91afcb682209de124d8a994e591687023ae7c0dfed6184c5511778b16b", + "zh:6f4434327ed466b2b5be0d5f4aa537bca71ebd7f7fe05468065aeda52dfc8896", + "zh:7394e8c6f5027fa21699e46c9a45bee1ce87fd8dd98f2e89e8d414ec70c8e4e1", + "zh:d90b855a0990e3aa6445afe66de730c2f4651f39699c6b6345ca02f9af2a1a08", + "zh:d9b7246e6af0f75155ed532855014f888e9e5613242c89e321b4e0f9b54f5726", + "zh:dabd399ca36172c15d176a24cf199ac886ef191f7131e806d76a8dc209e5beb8", + "zh:e57987397be46dc123365f16c3bec4dd0615453c8bfdcfc1b7c9f3202a09c516", + "zh:ec88430b833b943b38d02f70b33250c72bb6ffa3b3d22360990a41390161a2b2", + "zh:f8ea01b57982e9ed9ad3a750b1caba261a478f5b0ddcac78a4502d886fd2fc74", + "zh:fb63819037158205ebf42b649c3a8ec308234ffe987a5dbd4444e1e0683f1170", + ] +} diff --git a/infrastructure/commons/prometheus/main.tf b/infrastructure/commons/prometheus/main.tf index e5f5e247e..d6abb7440 100644 --- a/infrastructure/commons/prometheus/main.tf +++ b/infrastructure/commons/prometheus/main.tf @@ -3,6 +3,7 @@ resource "helm_release" "prometheus" { repository = "https://prometheus-community.github.io/helm-charts" chart = "prometheus" namespace = var.prometheus_namespace + version = var.prometheus_version create_namespace = true disable_webhooks = false diff --git a/infrastructure/commons/prometheus/tests/prometheus.tftest.hcl b/infrastructure/commons/prometheus/tests/prometheus.tftest.hcl new file mode 100644 index 000000000..d2594f358 --- /dev/null +++ b/infrastructure/commons/prometheus/tests/prometheus.tftest.hcl @@ -0,0 +1,34 @@ +mock_provider "helm" {} + +variables { + prometheus_version = "29.27.0" +} + +################################################################################ +# Version pinning +################################################################################ + +# The helm_release carried no version argument and the module exposed no variable for one, +# so every apply resolved to whatever prometheus-community served latest, with no diff to +# review and no way to pin without editing the module. +run "prometheus_version_reaches_the_release" { + command = plan + + assert { + condition = helm_release.prometheus.version == "29.27.0" + error_message = "prometheus_version must be wired to the helm_release so the deployed chart is a decision" + } +} + +run "prometheus_version_is_overridable" { + command = plan + + variables { + prometheus_version = "29.26.0" + } + + assert { + condition = helm_release.prometheus.version == "29.26.0" + error_message = "prometheus_version must be settable by the caller" + } +} diff --git a/infrastructure/commons/prometheus/variables.tf b/infrastructure/commons/prometheus/variables.tf index 502083c4c..4db279135 100644 --- a/infrastructure/commons/prometheus/variables.tf +++ b/infrastructure/commons/prometheus/variables.tf @@ -1,3 +1,14 @@ +variable "prometheus_version" { + # example: 29.27.0 + description = "No default: every install pins this deliberately — see VERSIONS.md. Helm chart version for the prometheus-community/prometheus chart. The helm_release carried no version at all, so every apply resolved to whatever the repository served latest; the default is the version that resolved to as of 2026-08-27, which keeps behaviour unchanged while removing the drift." + type = string + + validation { + condition = var.prometheus_version != "" && !contains(["latest", "main", "master"], lower(var.prometheus_version)) + error_message = "prometheus_version must be a non-empty fixed version, not empty and not a moving reference." + } +} + variable "nullplatform_port" { description = "Port number for nullplatform service communication" type = number diff --git a/infrastructure/gcp/artifact-registry/main.tf b/infrastructure/gcp/artifact-registry/main.tf index 4beb89b40..71ed4ee87 100644 --- a/infrastructure/gcp/artifact-registry/main.tf +++ b/infrastructure/gcp/artifact-registry/main.tf @@ -28,3 +28,15 @@ resource "google_service_account_iam_member" "workload_identity" { role = "roles/iam.workloadIdentityUser" member = "serviceAccount:${var.project_id}.svc.id.goog[${each.value.namespace}/${each.value.ksa_name}]" } + +resource "google_service_account_key" "artifact_sa_key" { + count = var.generate_key ? 1 : 0 + + service_account_id = google_service_account.artifact_sa.name + + # Only set when the caller opts in, so the default stays deterministic: a + # keepers value that changed on every apply would reissue the key every run. + keepers = var.key_rotation_token == null || var.key_rotation_token == "" ? null : { + rotation = var.key_rotation_token + } +} diff --git a/infrastructure/gcp/artifact-registry/outputs.tf b/infrastructure/gcp/artifact-registry/outputs.tf index 238f82e1b..504056839 100644 --- a/infrastructure/gcp/artifact-registry/outputs.tf +++ b/infrastructure/gcp/artifact-registry/outputs.tf @@ -12,3 +12,9 @@ output "service_account_email" { description = "GCP Service Account email. Annotate the Kubernetes ServiceAccount bound via workload_identity_bindings with iam.gke.io/gcp-service-account= to impersonate this account from pods." value = google_service_account.artifact_sa.email } + +output "service_account_key_base64" { + description = "Base64-encoded JSON key for the Artifact Registry service account, for Docker clients that authenticate with username '_json_key_base64' and this value as the password. Null unless generate_key is true." + value = var.generate_key ? google_service_account_key.artifact_sa_key[0].private_key : null + sensitive = true +} diff --git a/infrastructure/gcp/artifact-registry/tests/artifact_registry.tftest.hcl b/infrastructure/gcp/artifact-registry/tests/artifact_registry.tftest.hcl index 807fd3b32..091f73d65 100644 --- a/infrastructure/gcp/artifact-registry/tests/artifact_registry.tftest.hcl +++ b/infrastructure/gcp/artifact-registry/tests/artifact_registry.tftest.hcl @@ -54,3 +54,81 @@ run "iam_writer_role" { error_message = "SA should have artifactregistry.writer role" } } + +run "no_key_by_default" { + command = plan + + assert { + condition = length(google_service_account_key.artifact_sa_key) == 0 + error_message = "No service account key should be created when generate_key is false" + } + + assert { + condition = output.service_account_key_base64 == null + error_message = "service_account_key_base64 output should be null when generate_key is false" + } +} + +run "key_created_when_requested" { + # apply, not plan: private_key is computed, so the output can only be compared + # against the resource attribute once both have values. + command = apply + + variables { + generate_key = true + } + + assert { + condition = length(google_service_account_key.artifact_sa_key) == 1 + error_message = "A service account key should be created when generate_key is true" + } + + # Pins the output to private_key specifically. public_key is also base64 and + # also computed on this resource, so without this a swap between them ships a + # public key as the Docker password and only fails at runtime. + assert { + condition = nonsensitive(output.service_account_key_base64) == nonsensitive(google_service_account_key.artifact_sa_key[0].private_key) + error_message = "service_account_key_base64 must expose private_key: the _json_key_base64 Docker username expects the base64-encoded JSON key, not the public key" + } +} + +run "no_keepers_by_default_so_the_key_is_not_reissued_every_apply" { + command = plan + + variables { + generate_key = true + } + + assert { + condition = google_service_account_key.artifact_sa_key[0].keepers == null + error_message = "keepers must stay unset unless key_rotation_token is provided, otherwise the key churns on every apply" + } +} + +run "key_rotation_token_sets_keepers" { + command = plan + + variables { + generate_key = true + key_rotation_token = "2026-q3" + } + + assert { + condition = google_service_account_key.artifact_sa_key[0].keepers["rotation"] == "2026-q3" + error_message = "key_rotation_token should be wired to keepers so changing it forces a new key" + } +} + +run "empty_key_rotation_token_leaves_keepers_unset" { + command = plan + + variables { + generate_key = true + key_rotation_token = "" + } + + assert { + condition = google_service_account_key.artifact_sa_key[0].keepers == null + error_message = "An empty key_rotation_token must not set keepers" + } +} diff --git a/infrastructure/gcp/artifact-registry/variables.tf b/infrastructure/gcp/artifact-registry/variables.tf index bafded6d3..9c43a0684 100644 --- a/infrastructure/gcp/artifact-registry/variables.tf +++ b/infrastructure/gcp/artifact-registry/variables.tf @@ -33,3 +33,16 @@ variable "workload_identity_bindings" { })) default = [] } + +variable "generate_key" { + type = bool + description = "Generate a static JSON key for the Artifact Registry service account, exposed via the service_account_key_base64 output. Only needed for callers outside the cluster (e.g. an external system authenticating as a Docker registry client) that can't use Workload Identity. Leave false when every consumer runs in-cluster. Note that the key material is stored in plaintext in Terraform/OpenTofu state, and the service account holds roles/artifactregistry.writer at PROJECT scope." + default = false + nullable = false +} + +variable "key_rotation_token" { + type = string + description = "Arbitrary value wired to the service account key's keepers. Changing it forces a new key to be issued, which is the supported way to rotate: GCP user-managed keys do not expire on their own. Leave null to never rotate. Do not derive this from timestamp() or uuid() — the key would be reissued on every apply" + default = null +} diff --git a/infrastructure/gcp/backend/.terraform.lock.hcl b/infrastructure/gcp/backend/.terraform.lock.hcl new file mode 100644 index 000000000..800175d7f --- /dev/null +++ b/infrastructure/gcp/backend/.terraform.lock.hcl @@ -0,0 +1,43 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/google" { + version = "6.50.0" + constraints = ">= 5.0.0, < 7.0.0" + hashes = [ + "h1:MAAe4zFFdqS9M5rpmJK/vKgdb6ZMD/s/0Xd97yTDipA=", + "zh:1d4695f807d998f11fcdcfa174766287b82a8093513af857bcdad2d81c642480", + "zh:3173ac5df0294624d113812e49e2a55714aff7db617488168cecdf4168df9e29", + "zh:34d2b3d44c23bd6354fc4ab5917b302872ea1ab8de107034567f955b1717fa5b", + "zh:3a77f3cc2f3664cd5aaeeef4d044e6ec1695a079588fffec3ca03953664e5f04", + "zh:6b444e4b629ea8dc8cb112a39dde098dc5584d26d6de4177558f556a9a226696", + "zh:96545c8cd4d3a57069c5d1799eab5aedd887e16d98b5559a195f6d2c2d9bc674", + "zh:ba464caafde95ee16671d6b5ec90f053ed77a9d06c567456db6efd9160fa3165", + "zh:d876938e5b0d3f57a984d9be72467995f87fef6569968623415dc51d9f54d30b", + "zh:dfd908d873e314ab807d0abc9cfd42d2611cd06dc1b9ec719ebdbb738e8e68d6", + "zh:f9f16819a7738d564afd45fd169ba61004ec4e4e7089d2a4950cb8895be1fe1f", + ] +} + +provider "registry.opentofu.org/hashicorp/random" { + version = "3.9.0" + constraints = ">= 3.0.0" + hashes = [ + "h1:U8KXqGCoNI9/guYbTvzgdtVk3fRthoG0UXwm1JoEpIs=", + "zh:03f1114cc20b8913523735ab76e0f0a2b16ce13c92923a53304bf85f07fc0dbc", + "zh:105b678ee72322a3067f105d7e05e940f6143238f377f6e87ff4ec909246ac2a", + "zh:55f3bbf13ea18cbace61a706566a80f25f33fe2b1780b6f3d7b582af2a05b6d2", + "zh:63adf996db48f082f7a6351eb485e219cd88795fc71e6ec60a837263ab0d2cb1", + "zh:7e99550738a4e3cc68b8a467714b0d69371025fe95e3326d5323d026d55653e9", + "zh:8342b54af3a18a37e075eeae61be57f4de2ba71b35d95c5075d402dd2c1f289d", + "zh:83ee18e32ac9dd5fc91298554b7c4cfa4c3a1db50f4c797945637cc93c0844ae", + "zh:993ecc0adbf6bd535a59fbc9b735d8c33950e6f6eb5e621d750da9b71d65d80a", + "zh:ad722bc59d4edbf1415e827fc007c0efe6e0e9462d5568bae20b34be1058a261", + "zh:ae9448e1f87b2f9a6c5197a0e9862162ec6b137cb3a3835e11522995d8939e7c", + "zh:bc9cdd3aac784f759125c6627f6f6416e8726a1c184eb9cf3e55b9edbc94c627", + "zh:c8e35b89572ba1c40a9b20022e033a3395fb8d42e7604d50c900f193ba10382e", + "zh:e2deaa8a9975ef81d9f62baed12c41286918b0a10908e0e031f13f69a3b730a1", + "zh:ee39707557210a0ab1098aa357d2cdfe502e5a312d0dbdffb09d08facc4d3fc5", + "zh:f81afe4eb63e8aa9e0ea71be6c990f0dc69cb360e7191c0742a991f4a5081b64", + ] +} diff --git a/infrastructure/gcp/backend/README.md b/infrastructure/gcp/backend/README.md new file mode 100644 index 000000000..7711ec7db --- /dev/null +++ b/infrastructure/gcp/backend/README.md @@ -0,0 +1,175 @@ +# Module: backend + +## Description + +Creates a GCS bucket for storing Terraform/OpenTofu remote state with configurable storage class, versioning, encryption, access control, and audit logging + +## Architecture + +The module creates a random_id resource to generate a unique 8-byte hex suffix, which is appended to the lowercased bucket_prefix to form the globally unique name of a google_storage_bucket resource. Optional CMEK encryption is wired via a dynamic encryption block that activates only when kms_key_name is non-null and non-empty, and optional access logging is wired via a dynamic logging block that activates only when log_bucket is non-null and non-empty. Zero or more google_storage_bucket_iam_member resources are created via for_each over the allowed_members list, each granting roles/storage.objectAdmin on the bucket. Outputs expose the bucket name, gs:// URL, self-link, and location for use by remote state backend configurations. + +## Features + +- Creates a google_storage_bucket with a globally unique name by appending a random 16-character hex suffix to a caller-supplied prefix +- Enables object versioning on the bucket so previous Terraform state revisions can be recovered +- Configures uniform bucket-level access and public access prevention to enforce IAM-only access controls +- Attaches optional Cloud KMS customer-managed encryption key via a dynamic encryption block on the bucket +- Enables optional GCS access logging to a separate audit log bucket via a dynamic logging block +- Grants roles/storage.objectAdmin to an arbitrary list of IAM members via google_storage_bucket_iam_member resources +- Supports configurable storage class across standard, nearline, coldline, archive, and legacy GCS storage tiers + +## Basic Usage + +```hcl +module "backend" { + source = "git::https://github.com/nullplatform/tofu-modules.git//infrastructure/gcp/backend?ref=v6.22.1" + + project_id = "your-project-id" +} +``` + +## Using Outputs + +```hcl +# Reference outputs in other resources +resource "example_resource" "this" { + example_attribute = module.backend.bucket_name +} +``` + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.3 | +| [google](#requirement\_google) | >= 5.0, < 7.0 | +| [random](#requirement\_random) | >= 3.0 | + +## Providers + +| Name | Version | +|------|---------| +| [google](#provider\_google) | 6.50.0 | +| [random](#provider\_random) | 3.9.0 | + +## Resources + +| Name | Type | +|------|------| +| [google_storage_bucket.tf_state](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/storage_bucket) | resource | +| [google_storage_bucket_iam_member.allowed_members](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/storage_bucket_iam_member) | resource | +| [random_id.bucket_suffix](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [allowed\_members](#input\_allowed\_members) | IAM members (e.g. user:..., serviceAccount:..., group:...) additionally granted roles/storage.objectAdmin on the bucket. These bindings are additive: they grant access on top of whatever the project's IAM already allows, and do not restrict or revoke inherited access | `list(string)` | `[]` | no | +| [bucket\_prefix](#input\_bucket\_prefix) | Prefix for the GCS bucket name. A random suffix will be appended since bucket names must be globally unique across all of GCP. Lowercased automatically, since GCS bucket names cannot contain uppercase characters | `string` | `"tofu-state"` | no | +| [force\_destroy](#input\_force\_destroy) | Allow destruction of the bucket even if it contains objects. Leave false to protect Terraform/OpenTofu state from accidental deletion | `bool` | `false` | no | +| [kms\_key\_name](#input\_kms\_key\_name) | Full resource name of an existing Cloud KMS key used to encrypt the bucket's contents. Leave null or empty to use Google-managed encryption. When set, the project's GCS service agent must already hold roles/cloudkms.cryptoKeyEncrypterDecrypter on the key — this module does not grant it | `string` | `null` | no | +| [location](#input\_location) | GCS location for the bucket (e.g. a multi-region like US, or a region like us-central1) | `string` | `"US"` | no | +| [log\_bucket](#input\_log\_bucket) | Name of an existing GCS bucket to receive this bucket's access logs. Leave null or empty to disable access logging. Recommended for a state bucket, so reads of state objects leave an audit trail | `string` | `null` | no | +| [project\_id](#input\_project\_id) | The GCP project ID where the state bucket will be created | `string` | n/a | yes | +| [public\_access\_prevention](#input\_public\_access\_prevention) | Public access prevention setting for the bucket (enforced or inherited) | `string` | `"enforced"` | no | +| [storage\_class](#input\_storage\_class) | Storage class for the bucket | `string` | `"STANDARD"` | no | +| [tags](#input\_tags) | A mapping of labels to assign to the bucket | `map(string)` | `{}` | no | +| [uniform\_bucket\_level\_access](#input\_uniform\_bucket\_level\_access) | Enable uniform bucket-level access (IAM-only, no legacy ACLs) | `bool` | `true` | no | +| [versioning\_enabled](#input\_versioning\_enabled) | Enable object versioning on the bucket, so previous state revisions can be recovered. Note that prior revisions persist as non-current object versions until a lifecycle rule removes them, so any secret that ever passed through state remains readable in the bucket | `bool` | `true` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| [bucket\_name](#output\_bucket\_name) | Name of the GCS bucket for Terraform/OpenTofu state | +| [bucket\_self\_link](#output\_bucket\_self\_link) | Self-link of the GCS bucket | +| [bucket\_url](#output\_bucket\_url) | gs:// URL of the GCS bucket | +| [location](#output\_location) | Location of the GCS bucket | + + + diff --git a/infrastructure/gcp/backend/main.tf b/infrastructure/gcp/backend/main.tf new file mode 100644 index 000000000..97db6cb17 --- /dev/null +++ b/infrastructure/gcp/backend/main.tf @@ -0,0 +1,85 @@ +resource "random_id" "bucket_suffix" { + byte_length = 8 +} + +locals { + log_bucket_provided = var.log_bucket != null && var.log_bucket != "" + + # Falls back to a bucket this module creates and grants write access to, + # so state-bucket access logging is on out of the box (GCP-0077) without + # forcing every caller to bring their own centralized log bucket first. + effective_log_bucket = local.log_bucket_provided ? var.log_bucket : try(google_storage_bucket.logs[0].name, null) +} + +# The GCS service agent needs write access on whatever bucket receives access +# logs — https://cloud.google.com/storage/docs/access-logs#delivery. +data "google_storage_project_service_account" "gcs_account" { + count = local.log_bucket_provided ? 0 : 1 + project = var.project_id +} + +resource "google_storage_bucket" "logs" { + count = local.log_bucket_provided ? 0 : 1 + + name = "${lower(var.bucket_prefix)}-logs-${random_id.bucket_suffix.hex}" + project = var.project_id + location = var.location + storage_class = var.storage_class + force_destroy = var.force_destroy + + uniform_bucket_level_access = var.uniform_bucket_level_access + public_access_prevention = var.public_access_prevention + + versioning { + enabled = var.versioning_enabled + } + + labels = var.tags +} + +resource "google_storage_bucket_iam_member" "logs_writer" { + count = local.log_bucket_provided ? 0 : 1 + + bucket = google_storage_bucket.logs[0].name + role = "roles/storage.objectCreator" + member = "serviceAccount:${data.google_storage_project_service_account.gcs_account[0].email_address}" +} + +resource "google_storage_bucket" "tf_state" { + # GCS rejects uppercase bucket names, so the caller-supplied prefix is lowercased. + name = "${lower(var.bucket_prefix)}-${random_id.bucket_suffix.hex}" + project = var.project_id + location = var.location + storage_class = var.storage_class + force_destroy = var.force_destroy + + uniform_bucket_level_access = var.uniform_bucket_level_access + public_access_prevention = var.public_access_prevention + + versioning { + enabled = var.versioning_enabled + } + + # Empty string as well as null: an empty value reaches here whenever the key is + # wired from another module's output or a TF_VAR, and would emit an empty key. + dynamic "encryption" { + for_each = var.kms_key_name != null && var.kms_key_name != "" ? [var.kms_key_name] : [] + content { + default_kms_key_name = encryption.value + } + } + + logging { + log_bucket = local.effective_log_bucket + } + + labels = var.tags +} + +resource "google_storage_bucket_iam_member" "allowed_members" { + for_each = toset(var.allowed_members) + + bucket = google_storage_bucket.tf_state.name + role = "roles/storage.objectAdmin" + member = each.value +} diff --git a/infrastructure/gcp/backend/outputs.tf b/infrastructure/gcp/backend/outputs.tf new file mode 100644 index 000000000..23395feab --- /dev/null +++ b/infrastructure/gcp/backend/outputs.tf @@ -0,0 +1,24 @@ +output "bucket_name" { + description = "Name of the GCS bucket for Terraform/OpenTofu state" + value = google_storage_bucket.tf_state.name +} + +output "bucket_url" { + description = "gs:// URL of the GCS bucket" + value = google_storage_bucket.tf_state.url +} + +output "bucket_self_link" { + description = "Self-link of the GCS bucket" + value = google_storage_bucket.tf_state.self_link +} + +output "location" { + description = "Location of the GCS bucket" + value = google_storage_bucket.tf_state.location +} + +output "log_bucket_name" { + description = "Name of the bucket receiving access logs — either var.log_bucket, or the module's own auto-created log bucket when that's left unset" + value = local.effective_log_bucket +} diff --git a/infrastructure/gcp/backend/tests/backend.tftest.hcl b/infrastructure/gcp/backend/tests/backend.tftest.hcl new file mode 100644 index 000000000..7d4d16ea4 --- /dev/null +++ b/infrastructure/gcp/backend/tests/backend.tftest.hcl @@ -0,0 +1,333 @@ +mock_provider "google" {} + +variables { + project_id = "myorg-project" +} + +run "bucket_uses_default_prefix" { + # random_id.bucket_suffix is unknown until apply, so the bucket name can't + # be asserted on during plan. + command = apply + + assert { + condition = can(regex("^tofu-state-[0-9a-f]{16}$", google_storage_bucket.tf_state.name)) + error_message = "Bucket name should be bucket_prefix followed by a 16-character random hex suffix" + } +} + +run "custom_bucket_prefix" { + command = apply + + variables { + bucket_prefix = "myorg-tfstate" + } + + assert { + condition = can(regex("^myorg-tfstate-[0-9a-f]{16}$", google_storage_bucket.tf_state.name)) + error_message = "Bucket name should use the custom prefix" + } +} + +run "uppercase_bucket_prefix_is_lowercased" { + command = apply + + variables { + bucket_prefix = "MyOrg-TFState" + } + + assert { + condition = google_storage_bucket.tf_state.name == lower(google_storage_bucket.tf_state.name) + error_message = "Bucket name must be entirely lowercase: GCS rejects uppercase bucket names" + } + + assert { + condition = can(regex("^myorg-tfstate-[0-9a-f]{16}$", google_storage_bucket.tf_state.name)) + error_message = "An uppercase bucket_prefix should be lowercased, not passed through" + } +} + +run "bucket_prefix_over_46_chars_is_rejected" { + command = plan + + variables { + # 47 characters: with the 17-character suffix this would exceed the GCS limit of 63. + bucket_prefix = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + + expect_failures = [var.bucket_prefix] +} + +run "bucket_prefix_with_illegal_characters_is_rejected" { + command = plan + + variables { + bucket_prefix = "myorg tfstate!" + } + + expect_failures = [var.bucket_prefix] +} + +run "bucket_prefix_with_trailing_hyphen_is_rejected" { + command = plan + + variables { + bucket_prefix = "myorg-tfstate-" + } + + expect_failures = [var.bucket_prefix] +} + +run "bucket_prefix_using_reserved_google_name_is_rejected" { + command = plan + + variables { + bucket_prefix = "google-tfstate" + } + + expect_failures = [var.bucket_prefix] +} + +run "location_and_storage_class_defaults" { + command = plan + + assert { + condition = google_storage_bucket.tf_state.location == "US" + error_message = "Location should default to the US multi-region" + } + + assert { + condition = google_storage_bucket.tf_state.storage_class == "STANDARD" + error_message = "Storage class should default to STANDARD" + } +} + +run "invalid_storage_class_is_rejected" { + command = plan + + variables { + storage_class = "SUPERCOLD" + } + + expect_failures = [var.storage_class] +} + +run "uniform_bucket_level_access_enabled_by_default" { + command = plan + + assert { + condition = google_storage_bucket.tf_state.uniform_bucket_level_access == true + error_message = "Uniform bucket-level access should be enabled by default: legacy ACLs must not be reachable on a bucket holding state" + } +} + +run "uniform_bucket_level_access_can_be_disabled" { + command = plan + + variables { + uniform_bucket_level_access = false + } + + assert { + condition = google_storage_bucket.tf_state.uniform_bucket_level_access == false + error_message = "uniform_bucket_level_access should be settable to false" + } +} + +run "versioning_enabled_by_default" { + command = plan + + assert { + condition = google_storage_bucket.tf_state.versioning[0].enabled == true + error_message = "Versioning should be enabled by default" + } +} + +run "versioning_can_be_disabled" { + command = plan + + variables { + versioning_enabled = false + } + + assert { + condition = google_storage_bucket.tf_state.versioning[0].enabled == false + error_message = "versioning_enabled should be settable to false" + } +} + +run "force_destroy_disabled_by_default" { + command = plan + + assert { + condition = google_storage_bucket.tf_state.force_destroy == false + error_message = "force_destroy should default to false to protect state" + } +} + +run "public_access_prevention_enforced_by_default" { + command = plan + + assert { + condition = google_storage_bucket.tf_state.public_access_prevention == "enforced" + error_message = "Public access prevention should be enforced by default" + } +} + +run "public_access_prevention_accepts_inherited" { + command = plan + + variables { + public_access_prevention = "inherited" + } + + assert { + condition = google_storage_bucket.tf_state.public_access_prevention == "inherited" + error_message = "public_access_prevention should accept the documented 'inherited' value" + } +} + +run "invalid_public_access_prevention_is_rejected" { + command = plan + + variables { + # One character off the legal value; without validation this passes plan and + # only fails at apply, leaving the operator believing PAP is set. + public_access_prevention = "enforce" + } + + expect_failures = [var.public_access_prevention] +} + +run "no_encryption_block_by_default" { + command = plan + + assert { + condition = length(google_storage_bucket.tf_state.encryption) == 0 + error_message = "No customer-managed encryption should be configured when kms_key_name is not set" + } +} + +run "empty_kms_key_name_emits_no_encryption_block" { + command = plan + + variables { + # An empty string reaches the module whenever the key is wired from another + # module's output or a TF_VAR; it must fall back to Google-managed encryption. + kms_key_name = "" + } + + assert { + condition = length(google_storage_bucket.tf_state.encryption) == 0 + error_message = "An empty kms_key_name must not emit an encryption block with an empty key" + } +} + +run "encryption_block_when_kms_key_provided" { + command = plan + + variables { + kms_key_name = "projects/myorg-project/locations/us-central1/keyRings/myorg-ring/cryptoKeys/myorg-key" + } + + assert { + condition = google_storage_bucket.tf_state.encryption[0].default_kms_key_name == "projects/myorg-project/locations/us-central1/keyRings/myorg-ring/cryptoKeys/myorg-key" + error_message = "Encryption block should use the provided KMS key" + } +} + +run "log_bucket_is_self_provisioned_by_default" { + command = plan + + assert { + condition = length(google_storage_bucket.tf_state.logging) == 1 + error_message = "Access logging should be configured even when log_bucket is not set — the module provisions its own log bucket" + } + + assert { + condition = length(google_storage_bucket.logs) == 1 + error_message = "The module should create its own log bucket when log_bucket is not set" + } + + assert { + condition = length(google_storage_bucket_iam_member.logs_writer) == 1 + error_message = "The GCS service agent should be granted write access to the self-provisioned log bucket" + } +} + +run "empty_log_bucket_also_self_provisions" { + command = plan + + variables { + log_bucket = "" + } + + assert { + condition = length(google_storage_bucket.tf_state.logging) == 1 + error_message = "An empty log_bucket should be treated the same as unset: the module self-provisions a log bucket" + } + + assert { + condition = length(google_storage_bucket.logs) == 1 + error_message = "The module should create its own log bucket when log_bucket is an empty string" + } +} + +run "logging_block_when_log_bucket_provided" { + command = plan + + variables { + log_bucket = "myorg-access-logs" + } + + assert { + condition = google_storage_bucket.tf_state.logging[0].log_bucket == "myorg-access-logs" + error_message = "Logging block should target the provided log bucket" + } + + assert { + condition = length(google_storage_bucket.logs) == 0 + error_message = "The module should not self-provision a log bucket when the caller already provided one" + } +} + +run "no_iam_bindings_by_default" { + command = plan + + assert { + condition = length(google_storage_bucket_iam_member.allowed_members) == 0 + error_message = "No IAM bindings should be created when allowed_members is empty" + } +} + +run "iam_binding_for_allowed_member" { + command = plan + + variables { + allowed_members = ["user:admin@example.com"] + } + + assert { + condition = google_storage_bucket_iam_member.allowed_members["user:admin@example.com"].role == "roles/storage.objectAdmin" + error_message = "Allowed member should be granted storage.objectAdmin role" + } + + assert { + condition = length(google_storage_bucket_iam_member.allowed_members) == 1 + error_message = "One binding should be created per allowed member" + } +} + +run "tags_applied" { + command = plan + + variables { + tags = { + env = "test" + } + } + + assert { + condition = google_storage_bucket.tf_state.labels["env"] == "test" + error_message = "Labels should be applied from the tags variable" + } +} diff --git a/infrastructure/gcp/backend/variables.tf b/infrastructure/gcp/backend/variables.tf new file mode 100644 index 000000000..de5e626af --- /dev/null +++ b/infrastructure/gcp/backend/variables.tf @@ -0,0 +1,111 @@ +variable "project_id" { + description = "The GCP project ID where the state bucket will be created" + type = string +} + +variable "bucket_prefix" { + description = "Prefix for the GCS bucket name. A random suffix will be appended since bucket names must be globally unique across all of GCP. Lowercased automatically, since GCS bucket names cannot contain uppercase characters" + type = string + default = "tofu-state" + nullable = false + + # The random suffix adds 17 characters ("-" plus 16 hex), and a GCS bucket + # name is capped at 63. + validation { + condition = length(var.bucket_prefix) >= 1 && length(var.bucket_prefix) <= 46 + error_message = "bucket_prefix must be 1-46 characters: the module appends a 17-character random suffix and GCS caps bucket names at 63." + } + + # Dots are legal in GCS names but turn the bucket into a domain-named bucket, + # which requires verified domain ownership — excluded rather than silently failing at apply. + validation { + condition = can(regex("^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$", lower(var.bucket_prefix))) + error_message = "bucket_prefix may contain only letters, numbers, hyphens and underscores, and must start and end with a letter or number." + } + + validation { + condition = !can(regex("^goog", lower(var.bucket_prefix))) && !can(regex("google", lower(var.bucket_prefix))) + error_message = "bucket_prefix cannot start with 'goog' or contain 'google': GCS reserves those names." + } +} + +variable "location" { + description = "GCS location for the bucket (e.g. a multi-region like US, or a region like us-central1)" + type = string + default = "US" + nullable = false +} + +variable "storage_class" { + description = "Storage class for the bucket" + type = string + default = "STANDARD" + nullable = false + + validation { + condition = contains([ + "STANDARD", "NEARLINE", "COLDLINE", "ARCHIVE", + "MULTI_REGIONAL", "REGIONAL", "DURABLE_REDUCED_AVAILABILITY", + ], var.storage_class) + error_message = "storage_class must be one of STANDARD, NEARLINE, COLDLINE, ARCHIVE, or the legacy MULTI_REGIONAL, REGIONAL, DURABLE_REDUCED_AVAILABILITY." + } +} + +variable "force_destroy" { + description = "Allow destruction of the bucket even if it contains objects. Leave false to protect Terraform/OpenTofu state from accidental deletion" + type = bool + default = false + nullable = false +} + +variable "versioning_enabled" { + description = "Enable object versioning on the bucket, so previous state revisions can be recovered. Note that prior revisions persist as non-current object versions until a lifecycle rule removes them, so any secret that ever passed through state remains readable in the bucket" + type = bool + default = true + nullable = false +} + +variable "uniform_bucket_level_access" { + description = "Enable uniform bucket-level access (IAM-only, no legacy ACLs)" + type = bool + default = true + nullable = false +} + +variable "public_access_prevention" { + description = "Public access prevention setting for the bucket (enforced or inherited)" + type = string + default = "enforced" + nullable = false + + validation { + condition = contains(["enforced", "inherited"], var.public_access_prevention) + error_message = "public_access_prevention must be either 'enforced' or 'inherited'." + } +} + +variable "kms_key_name" { + description = "Full resource name of an existing Cloud KMS key used to encrypt the bucket's contents. Leave null or empty to use Google-managed encryption. When set, the project's GCS service agent must already hold roles/cloudkms.cryptoKeyEncrypterDecrypter on the key — this module does not grant it" + type = string + default = null +} + +variable "log_bucket" { + description = "Name of an existing GCS bucket to receive this bucket's access logs. Leave null or empty and the module creates its own dedicated log bucket (and grants the GCS service agent write access to it) — access logging is always on, so reads of state objects leave an audit trail." + type = string + default = null +} + +variable "allowed_members" { + description = "IAM members (e.g. user:..., serviceAccount:..., group:...) additionally granted roles/storage.objectAdmin on the bucket. These bindings are additive: they grant access on top of whatever the project's IAM already allows, and do not restrict or revoke inherited access" + type = list(string) + default = [] + nullable = false +} + +variable "tags" { + description = "A mapping of labels to assign to the bucket" + type = map(string) + default = {} + nullable = false +} diff --git a/infrastructure/gcp/backend/versions.tf b/infrastructure/gcp/backend/versions.tf new file mode 100644 index 000000000..903842095 --- /dev/null +++ b/infrastructure/gcp/backend/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.3" + + required_providers { + google = { + source = "hashicorp/google" + version = ">= 5.0, < 7.0" + } + random = { + source = "hashicorp/random" + version = ">= 3.0" + } + } +} diff --git a/infrastructure/gcp/gke/.terraform.lock.hcl b/infrastructure/gcp/gke/.terraform.lock.hcl index 0640fe96c..a1d1024a2 100644 --- a/infrastructure/gcp/gke/.terraform.lock.hcl +++ b/infrastructure/gcp/gke/.terraform.lock.hcl @@ -2,20 +2,38 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/google" { - version = "6.50.0" - constraints = ">= 5.0.0, >= 5.40.0, < 7.0.0" + version = "5.45.2" + constraints = "~> 5.0, >= 5.40.0, != 5.44.0, != 6.2.0, != 6.3.0, < 7.0.0" hashes = [ - "h1:MAAe4zFFdqS9M5rpmJK/vKgdb6ZMD/s/0Xd97yTDipA=", - "zh:1d4695f807d998f11fcdcfa174766287b82a8093513af857bcdad2d81c642480", - "zh:3173ac5df0294624d113812e49e2a55714aff7db617488168cecdf4168df9e29", - "zh:34d2b3d44c23bd6354fc4ab5917b302872ea1ab8de107034567f955b1717fa5b", - "zh:3a77f3cc2f3664cd5aaeeef4d044e6ec1695a079588fffec3ca03953664e5f04", - "zh:6b444e4b629ea8dc8cb112a39dde098dc5584d26d6de4177558f556a9a226696", - "zh:96545c8cd4d3a57069c5d1799eab5aedd887e16d98b5559a195f6d2c2d9bc674", - "zh:ba464caafde95ee16671d6b5ec90f053ed77a9d06c567456db6efd9160fa3165", - "zh:d876938e5b0d3f57a984d9be72467995f87fef6569968623415dc51d9f54d30b", - "zh:dfd908d873e314ab807d0abc9cfd42d2611cd06dc1b9ec719ebdbb738e8e68d6", - "zh:f9f16819a7738d564afd45fd169ba61004ec4e4e7089d2a4950cb8895be1fe1f", + "h1:YEQOp7Ou1+GtpcKyCX6Cr/mAGqKIogpi85MX51GuG4s=", + "zh:0931f08e81f220ae3132169cfa4ed8e9d8d2045f29ca914afd8ee9e3e9cf56e0", + "zh:31afa45a4c8a0fd4abff564ecff8b69a97ac1813ead61c12f5f0bf5d33cec7f1", + "zh:536979e437aad59ba41465c9398d8e3d7d3702bfe2a51d80571862d48c817959", + "zh:748e14614be32350ece4e9249e09bc1d20e54421983734ded3a0df6d6674ea71", + "zh:7c8fe641666603aad6693207c8eaac679b9be15246d77090c73a1a84326d6084", + "zh:8095a513a0662323d99c25466b5a291c80b2b0c1857c7c7a7b1159f25dbe4439", + "zh:9453db86d14611cab26dba30daf56d1cfef929918207e9e3e78b58299fc8c4fe", + "zh:adaa5df5d40060409b6b66136c0ac37b99fb35ac2cf554c584649c236a18d95b", + "zh:af2f659b4bd1f44e578f203830bdab829b5e635fcf2a59ffa7e997c16e6611ad", + "zh:b75184fe5c162821b0524fa941d6a934c452e815d82e62675bb21bbdc9046dfc", + ] +} + +provider "registry.opentofu.org/hashicorp/google-beta" { + version = "5.45.2" + constraints = "~> 5.0, >= 5.40.0, != 5.44.0, != 6.2.0, != 6.3.0, < 7.0.0" + hashes = [ + "h1:D157MMCsE8DIpK60goSgECYMsWfJb+E2ZH7bDSLSVG4=", + "zh:2df6e40591ceee7ee77d429ea072c9d51fef2dd04015b2604ff332a2af4ac819", + "zh:4096af21991ba76ab81c8cb00c0eb0bd4f22619f7e491d60023fb10b8b33bfb1", + "zh:44ded286956fff5668f1acbf152b62ca8e6a03abc8df12c5c181bc2ca05b4df7", + "zh:7ae19e1b53a0e26bea0acb9a96b4b44038d7c182c3fdd496148fd20e40aa78e1", + "zh:81c9812823b78fd1b12bc0acd6dae35bc573944950e09eaf237b2e83b6b587d7", + "zh:9db6101421b53b9533807928c651e779f5b8129f4a57ff892bf256c84ba6ed29", + "zh:b779729cb08829f621a718ecdfdb503c310ef5411e694996c7cfda7227221134", + "zh:c43edb31aee354317a6181272a961965b93722fd18637f38c395af013aa65617", + "zh:dbb93970a85f2fe84f650b6a4da694ecb1023a99c3b9bbf6953dccd074fa49ce", + "zh:df9d13853269e98651d495571b4d58c883b4386247d0b9c5495c2e82ef721f45", ] } @@ -37,19 +55,24 @@ provider "registry.opentofu.org/hashicorp/kubernetes" { } provider "registry.opentofu.org/hashicorp/random" { - version = "3.7.2" + version = "3.9.0" constraints = ">= 2.1.0" hashes = [ - "h1:cFGCdxTlsrteTiaOV/iOQdql7eJkD3F/vtJxenkj9IE=", - "zh:2ffeb1058bd7b21a9e15a5301abb863053a2d42dffa3f6cf654a1667e10f4727", - "zh:519319ed8f4312ed76519652ad6cd9f98bc75cf4ec7990a5684c072cf5dd0a5d", - "zh:7371c2cc28c94deb9dba62fbac2685f7dde47f93019273a758dd5a2794f72919", - "zh:9b0ac4c1d8e36a86b59ced94fa517ae9b015b1d044b3455465cc6f0eab70915d", - "zh:c6336d7196f1318e1cbb120b3de8426ce43d4cacd2c75f45dba2dbdba666ce00", - "zh:c71f18b0cb5d55a103ea81e346fb56db15b144459123f1be1b0209cffc1deb4e", - "zh:d2dc49a6cac2d156e91b0506d6d756809e36bf390844a187f305094336d3e8d8", - "zh:d5b5fc881ccc41b268f952dae303501d6ec9f9d24ee11fe2fa56eed7478e15d0", - "zh:db9723eaca26d58c930e13fde221d93501529a5cd036b1f167ef8cff6f1a03cc", - "zh:fe3359f733f3ab518c6f85f3a9cd89322a7143463263f30321de0973a52d4ad8", + "h1:U8KXqGCoNI9/guYbTvzgdtVk3fRthoG0UXwm1JoEpIs=", + "zh:03f1114cc20b8913523735ab76e0f0a2b16ce13c92923a53304bf85f07fc0dbc", + "zh:105b678ee72322a3067f105d7e05e940f6143238f377f6e87ff4ec909246ac2a", + "zh:55f3bbf13ea18cbace61a706566a80f25f33fe2b1780b6f3d7b582af2a05b6d2", + "zh:63adf996db48f082f7a6351eb485e219cd88795fc71e6ec60a837263ab0d2cb1", + "zh:7e99550738a4e3cc68b8a467714b0d69371025fe95e3326d5323d026d55653e9", + "zh:8342b54af3a18a37e075eeae61be57f4de2ba71b35d95c5075d402dd2c1f289d", + "zh:83ee18e32ac9dd5fc91298554b7c4cfa4c3a1db50f4c797945637cc93c0844ae", + "zh:993ecc0adbf6bd535a59fbc9b735d8c33950e6f6eb5e621d750da9b71d65d80a", + "zh:ad722bc59d4edbf1415e827fc007c0efe6e0e9462d5568bae20b34be1058a261", + "zh:ae9448e1f87b2f9a6c5197a0e9862162ec6b137cb3a3835e11522995d8939e7c", + "zh:bc9cdd3aac784f759125c6627f6f6416e8726a1c184eb9cf3e55b9edbc94c627", + "zh:c8e35b89572ba1c40a9b20022e033a3395fb8d42e7604d50c900f193ba10382e", + "zh:e2deaa8a9975ef81d9f62baed12c41286918b0a10908e0e031f13f69a3b730a1", + "zh:ee39707557210a0ab1098aa357d2cdfe502e5a312d0dbdffb09d08facc4d3fc5", + "zh:f81afe4eb63e8aa9e0ea71be6c990f0dc69cb360e7191c0742a991f4a5081b64", ] } diff --git a/infrastructure/gcp/gke/locals.tf b/infrastructure/gcp/gke/locals.tf new file mode 100644 index 000000000..1fb6be101 --- /dev/null +++ b/infrastructure/gcp/gke/locals.tf @@ -0,0 +1,13 @@ +locals { + # Strip null-valued attributes before handing the pools to the wrapped module. + # + # A declared `optional(...)` attribute with no default is not absent — it is + # present as a key holding null. The wrapped module decides whether the caller + # opted into cluster-wide autoscaling with `contains(keys(autoscaling.value), + # "total_min_count")` rather than a null check (private-cluster/cluster.tf:558), + # so passing the nulls through would read as "set" for every pool and null out + # both the per-zone and the total node counts, leaving autoscaling unbounded. + node_pools = [ + for pool in var.node_pools : { for k, v in pool : k => v if v != null } + ] +} diff --git a/infrastructure/gcp/gke/main.tf b/infrastructure/gcp/gke/main.tf index 92fc7a920..2b1f20341 100644 --- a/infrastructure/gcp/gke/main.tf +++ b/infrastructure/gcp/gke/main.tf @@ -1,4 +1,17 @@ +# Adding `count` to a module that shipped without one moves its state address +# from module.gke to module.gke[0]. Without this block, a consumer who only bumps +# the module ref — leaving autopilot_enabled at its default false — gets a plan +# that destroys and recreates the cluster, every node pool and the service +# account, because the old address is "not in configuration". +moved { + from = module.gke + to = module.gke[0] +} + +# Standard cluster with manually managed node pools (default mode) module "gke" { + count = var.autopilot_enabled ? 0 : 1 + source = "terraform-google-modules/kubernetes-engine/google//modules/private-cluster" version = "~> 33.0" @@ -23,10 +36,48 @@ module "gke" { master_authorized_networks = var.authorized_ip_ranges - node_pools = var.node_pools + node_pools = local.node_pools + node_pools_taints = var.node_pools_taints + + cluster_resource_labels = var.tags + + # Service account with Artifact Registry access + grant_registry_access = true + create_service_account = true + + # Cloud Logging is disabled for standard clusters. Autopilot cannot disable it, + # so an Autopilot cluster ingests system and workload logs — see the README. + logging_service = "none" +} + +# Autopilot cluster — GCP manages node provisioning/scaling per workload, +# so there is no node_pools equivalent here. +module "gke_autopilot" { + count = var.autopilot_enabled ? 1 : 0 + + source = "terraform-google-modules/kubernetes-engine/google//modules/beta-autopilot-private-cluster" + version = "~> 33.0" + + project_id = var.project_id + name = var.cluster_name + region = var.location + deletion_protection = var.deletion_protection_enabled + + network = var.vpc_name + subnetwork = var.vpc_subnet_name + ip_range_pods = var.ip_range_pods + ip_range_services = var.ip_range_services + + # Private cluster with public endpoint + enable_private_endpoint = false + enable_private_nodes = true + master_ipv4_cidr_block = var.master_ipv4_cidr_block + + master_authorized_networks = var.authorized_ip_ranges + + cluster_resource_labels = var.tags # Service account with Artifact Registry access grant_registry_access = true create_service_account = true - logging_service = "none" } diff --git a/infrastructure/gcp/gke/outputs.tf b/infrastructure/gcp/gke/outputs.tf index 19da734ec..7dd70910e 100644 --- a/infrastructure/gcp/gke/outputs.tf +++ b/infrastructure/gcp/gke/outputs.tf @@ -1,16 +1,16 @@ output "cluster_name" { description = "The name of the GKE cluster" - value = module.gke.name + value = var.autopilot_enabled ? module.gke_autopilot[0].name : module.gke[0].name } output "host" { description = "The API server endpoint" - value = module.gke.endpoint + value = var.autopilot_enabled ? module.gke_autopilot[0].endpoint : module.gke[0].endpoint sensitive = true } output "cluster_ca_certificate" { description = "The cluster CA certificate in base64" - value = module.gke.ca_certificate + value = var.autopilot_enabled ? module.gke_autopilot[0].ca_certificate : module.gke[0].ca_certificate sensitive = true } diff --git a/infrastructure/gcp/gke/providers.tf b/infrastructure/gcp/gke/providers.tf index f9740720f..41c6de4b0 100644 --- a/infrastructure/gcp/gke/providers.tf +++ b/infrastructure/gcp/gke/providers.tf @@ -6,5 +6,14 @@ terraform { source = "hashicorp/google" version = "~> 5.0" } + # The Autopilot path creates its cluster with `provider = google-beta` + # (beta-autopilot-private-cluster/cluster.tf:23). Provider requirements are + # static — count = 0 does not suppress them — so this must be declared and + # constrained to the same major as google, or a fresh init resolves an + # unpinned google-beta and the root's provider credentials never reach it. + google-beta = { + source = "hashicorp/google-beta" + version = "~> 5.0" + } } } diff --git a/infrastructure/gcp/gke/tests/gke.tftest.hcl b/infrastructure/gcp/gke/tests/gke.tftest.hcl new file mode 100644 index 000000000..9d8ee9418 --- /dev/null +++ b/infrastructure/gcp/gke/tests/gke.tftest.hcl @@ -0,0 +1,271 @@ +mock_provider "google" { + mock_resource "google_service_account" { + defaults = { + email = "mock-sa@myorg-project.iam.gserviceaccount.com" + member = "serviceAccount:mock-sa@myorg-project.iam.gserviceaccount.com" + } + } + mock_data "google_compute_zones" { + defaults = { + names = ["us-central1-a", "us-central1-b", "us-central1-c"] + } + } + mock_data "google_container_engine_versions" { + defaults = { + latest_master_version = "1.30.5-gke.1443001" + latest_node_version = "1.30.5-gke.1443001" + valid_master_versions = ["1.30.5-gke.1443001"] + valid_node_versions = ["1.30.5-gke.1443001"] + } + } +} +mock_provider "google-beta" { + mock_resource "google_service_account" { + defaults = { + email = "mock-sa@myorg-project.iam.gserviceaccount.com" + member = "serviceAccount:mock-sa@myorg-project.iam.gserviceaccount.com" + } + } + mock_data "google_compute_zones" { + defaults = { + names = ["us-central1-a", "us-central1-b", "us-central1-c"] + } + } + mock_data "google_container_engine_versions" { + defaults = { + latest_master_version = "1.30.5-gke.1443001" + latest_node_version = "1.30.5-gke.1443001" + } + } +} +mock_provider "kubernetes" {} + +variables { + project_id = "myorg-project" + cluster_name = "myorg-gke" + location = "us-central1" + vpc_name = "myorg-vpc" + vpc_subnet_name = "myorg-subnet" + ip_range_pods = "pods" + ip_range_services = "services" +} + +############################################################################### +# Mode selection +############################################################################### + +run "standard_mode_by_default" { + command = plan + + assert { + condition = length(module.gke) == 1 && length(module.gke_autopilot) == 0 + error_message = "autopilot_enabled defaults to false, so only the standard cluster should be planned" + } +} + +run "autopilot_mode_selects_the_other_module" { + command = plan + + variables { + autopilot_enabled = true + } + + assert { + condition = length(module.gke) == 0 && length(module.gke_autopilot) == 1 + error_message = "autopilot_enabled should plan the Autopilot cluster and nothing else" + } +} + +############################################################################### +# node_pools: null stripping +# +# The wrapped module decides whether the caller opted into cluster-wide +# autoscaling with contains(keys(...)), not a null check, so a declared-but-unset +# optional attribute must not reach it. +############################################################################### + +run "unset_total_counts_do_not_reach_the_wrapped_module" { + command = plan + + assert { + condition = !contains(keys(local.node_pools[0]), "total_min_count") + error_message = "An unset total_min_count must be stripped: the wrapped module reads contains(keys(...)) and would null out the per-zone autoscaling bounds for every pool" + } + + assert { + condition = !contains(keys(local.node_pools[0]), "total_max_count") + error_message = "An unset total_max_count must be stripped for the same reason" + } + + assert { + condition = local.node_pools[0]["min_count"] == 1 + error_message = "Attributes that do have a default must survive the stripping" + } +} + +run "set_total_counts_do_reach_the_wrapped_module" { + command = plan + + variables { + node_pools = [{ + name = "pool-wide" + total_min_count = 3 + total_max_count = 9 + }] + } + + assert { + condition = local.node_pools[0]["total_min_count"] == 3 && local.node_pools[0]["total_max_count"] == 9 + error_message = "Explicit total counts must be passed through" + } +} + +run "total_counts_must_be_set_together" { + command = plan + + variables { + node_pools = [{ + name = "pool-half" + total_min_count = 3 + }] + } + + expect_failures = [var.node_pools] +} + +############################################################################### +# node_pools: spot and preemptible +############################################################################### + +run "spot_and_preemptible_together_is_rejected" { + command = plan + + variables { + node_pools = [{ + name = "pool-both" + spot = true + preemptible = true + }] + } + + expect_failures = [var.node_pools] +} + +run "spot_alone_is_accepted" { + command = plan + + variables { + node_pools = [{ + name = "pool-spot" + spot = true + }] + } + + assert { + condition = local.node_pools[0]["spot"] == true + error_message = "A pool may set spot on its own" + } +} + +run "preemptible_alone_is_accepted" { + command = plan + + variables { + node_pools = [{ + name = "pool-preempt" + preemptible = true + }] + } + + assert { + condition = local.node_pools[0]["preemptible"] == true + error_message = "A pool may set preemptible on its own" + } +} + +run "mixed_on_demand_and_spot_pools_plan" { + command = plan + + variables { + node_pools = [ + { + name = "pool-default" + node_count = 1 + autoscaling = false + }, + { + name = "pool-spot" + spot = true + min_count = 0 + max_count = 2 + }, + ] + } + + assert { + condition = length(local.node_pools) == 2 + error_message = "Both pools should be passed through" + } +} + +############################################################################### +# node_pools_taints +############################################################################### + +run "no_taints_by_default" { + command = plan + + assert { + condition = length(var.node_pools_taints) == 0 + error_message = "node_pools_taints should default to empty so existing pools are untouched" + } +} + +run "spot_pool_can_be_tainted" { + command = plan + + variables { + node_pools = [{ + name = "pool-spot" + spot = true + }] + node_pools_taints = { + pool-spot = [{ + key = "cloud.google.com/gke-spot" + value = "true" + effect = "NO_SCHEDULE" + }] + } + } + + assert { + condition = var.node_pools_taints["pool-spot"][0].effect == "NO_SCHEDULE" + error_message = "Taints must be expressible per pool, so workloads can be kept off preemptible capacity" + } +} + +############################################################################### +# Outputs resolve in both modes +############################################################################### + +run "outputs_resolve_in_standard_mode" { + command = apply + + assert { + condition = output.cluster_name != null && output.cluster_name != "" + error_message = "cluster_name must resolve from the standard module when autopilot is off, not index a zero-count module" + } +} + +run "outputs_resolve_in_autopilot_mode" { + command = apply + + variables { + autopilot_enabled = true + } + + assert { + condition = output.cluster_name != null && output.cluster_name != "" + error_message = "cluster_name must resolve from the Autopilot module when autopilot is on, not index a zero-count module" + } +} diff --git a/infrastructure/gcp/gke/variables.tf b/infrastructure/gcp/gke/variables.tf index b5bd3cfdb..0e3fe49d4 100644 --- a/infrastructure/gcp/gke/variables.tf +++ b/infrastructure/gcp/gke/variables.tf @@ -38,21 +38,74 @@ variable "ip_range_services" { } ############################################################################### -# OPTIONAL VARIABLES - NODE POOLS +# OPTIONAL VARIABLES - AUTOPILOT +############################################################################### + +variable "autopilot_enabled" { + type = bool + description = "Create a GKE Autopilot cluster instead of a standard cluster with manually managed node pools. When true, node_pools is ignored — Autopilot provisions and scales nodes automatically per workload." + default = false +} + +############################################################################### +# OPTIONAL VARIABLES - NODE POOLS (ignored when autopilot_enabled is true) ############################################################################### variable "node_pools" { type = list(object({ name = string machine_type = optional(string, "e2-medium") - min_count = optional(number, 1) - max_count = optional(number, 3) disk_size_gb = optional(number, 100) + # When autoscaling is true (the default), the pool scales between + # min_count and max_count. When false, it holds a fixed node_count. + autoscaling = optional(bool, true) + # PER ZONE. This module creates regional clusters (location is passed as + # region), so the effective cluster-wide count is these values multiplied by + # the number of zones in the region — three, in most regions. Use + # total_min_count/total_max_count instead to express cluster-wide bounds. + min_count = optional(number, 1) + max_count = optional(number, 3) + # PER ZONE, same multiplication as above. Only used when autoscaling is false. + node_count = optional(number, 1) + # Cluster-wide autoscaling bounds. When set, they replace the per-zone + # min_count/max_count. Must be set together. + total_min_count = optional(number) + total_max_count = optional(number) + # spot and preemptible are mutually exclusive lower-cost VM options; + # leave both false for regular on-demand nodes. Note that GKE does NOT taint + # spot nodes in standard clusters — it only labels them — so any pod without + # a nodeSelector can land on preemptible capacity. Use node_pools_taints to + # keep workloads off them. + spot = optional(bool, false) + preemptible = optional(bool, false) })) - description = "List of node pools to create in the GKE cluster" + description = "List of node pools to create in the GKE cluster (ignored when autopilot_enabled is true). min_count, max_count and node_count are PER ZONE and the cluster is regional, so they are multiplied by the number of zones in the region; use total_min_count/total_max_count for cluster-wide bounds" default = [{ name = "default" }] + + validation { + condition = alltrue([for pool in var.node_pools : !(pool.spot && pool.preemptible)]) + error_message = "Each node pool must not set both spot and preemptible to true — they are mutually exclusive lower-cost VM options." + } + + validation { + condition = alltrue([ + for pool in var.node_pools : + (pool.total_min_count == null) == (pool.total_max_count == null) + ]) + error_message = "total_min_count and total_max_count must be set together: setting only one leaves the other bound per-zone, which silently mixes the two scales." + } +} + +variable "node_pools_taints" { + type = map(list(object({ + key = string + value = string + effect = string + }))) + description = "Node taints by node-pool name, plus an optional 'all' key applied to every pool. Needed to keep ordinary workloads off spot/preemptible pools: GKE adds only labels to Spot nodes in standard clusters, and applies the cloud.google.com/gke-spot NoSchedule taint solely through node auto-provisioning, which is not this path. Pools absent from the map get no taints" + default = {} } variable "authorized_ip_ranges" { diff --git a/infrastructure/gcp/security/.terraform.lock.hcl b/infrastructure/gcp/security/.terraform.lock.hcl new file mode 100644 index 000000000..37533dfdb --- /dev/null +++ b/infrastructure/gcp/security/.terraform.lock.hcl @@ -0,0 +1,20 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/google" { + version = "5.45.2" + constraints = "~> 5.0" + hashes = [ + "h1:YEQOp7Ou1+GtpcKyCX6Cr/mAGqKIogpi85MX51GuG4s=", + "zh:0931f08e81f220ae3132169cfa4ed8e9d8d2045f29ca914afd8ee9e3e9cf56e0", + "zh:31afa45a4c8a0fd4abff564ecff8b69a97ac1813ead61c12f5f0bf5d33cec7f1", + "zh:536979e437aad59ba41465c9398d8e3d7d3702bfe2a51d80571862d48c817959", + "zh:748e14614be32350ece4e9249e09bc1d20e54421983734ded3a0df6d6674ea71", + "zh:7c8fe641666603aad6693207c8eaac679b9be15246d77090c73a1a84326d6084", + "zh:8095a513a0662323d99c25466b5a291c80b2b0c1857c7c7a7b1159f25dbe4439", + "zh:9453db86d14611cab26dba30daf56d1cfef929918207e9e3e78b58299fc8c4fe", + "zh:adaa5df5d40060409b6b66136c0ac37b99fb35ac2cf554c584649c236a18d95b", + "zh:af2f659b4bd1f44e578f203830bdab829b5e635fcf2a59ffa7e997c16e6611ad", + "zh:b75184fe5c162821b0524fa941d6a934c452e815d82e62675bb21bbdc9046dfc", + ] +} diff --git a/infrastructure/gcp/security/main.tf b/infrastructure/gcp/security/main.tf index e12cd0e0b..2c22ffd68 100644 --- a/infrastructure/gcp/security/main.tf +++ b/infrastructure/gcp/security/main.tf @@ -13,9 +13,13 @@ locals { # DATA SOURCES - Derive network and CIDR from cluster name ############################################################################### -# Get GKE cluster info +# Get GKE cluster info. +# +# Skipped entirely when the caller supplies both derived values, so the module does +# not require container.clusters.get / compute.subnetworks.get just to build firewall +# rules from values it was handed. data "google_container_cluster" "this" { - count = var.cluster_name != "" ? 1 : 0 + count = var.cluster_name != "" && (var.gcp_network_name == "" || var.network_cidr == "") ? 1 : 0 name = var.cluster_name location = var.gcp_region project = var.gcp_project_id @@ -36,20 +40,60 @@ data "google_container_cluster" "this" { } } +locals { + subnetwork_ref = try(one(data.google_container_cluster.this[*].subnetwork), null) + + # The GKE API always returns networkConfig.subnetwork as a RELATIVE RESOURCE PATH + # (projects/P/regions/R/subnetworks/NAME): the provider normalizes whatever the + # config supplied through RelativeLink() before the call and reads the value back + # from cluster.NetworkConfig.Subnetwork. The field even carries + # DiffSuppressFunc: CompareSelfLinkOrResourceName precisely because the config and + # read shapes differ. So this is not "whatever format the cluster was created + # with" — every cluster with cluster_name set hits the path form. + # + # Capture all three segments rather than only the name. The name alone is not + # enough: google_compute_subnetwork resolves it against the project and region it + # is given, and the path may legitimately name a different region (a zonal cluster + # passes its ZONE as gcp_region, since that doubles as the cluster's location) or a + # different project (Shared VPC, where the subnet lives in the host project). + # Discarding them turns those cases into a 404. + # + # The regex also matches a self_link (the leading (?:.*/)? absorbs the + # https://www.googleapis.com/compute/v1/ prefix). A bare name matches nothing and + # falls through to the configured project/region below. + subnetwork_parts = local.subnetwork_ref == null ? null : try( + regex("^(?:.*/)?projects/(?P[^/]+)/regions/(?P[^/]+)/subnetworks/(?P[^/]+)$", local.subnetwork_ref), + null + ) + + cluster_subnetwork_name = try(local.subnetwork_parts.name, local.subnetwork_ref, "") + cluster_subnetwork_region = try(local.subnetwork_parts.region, var.gcp_region) + cluster_subnetwork_project = try(local.subnetwork_parts.project, var.gcp_project_id) +} + # Get subnetwork info to derive CIDR data "google_compute_subnetwork" "this" { - count = var.cluster_name != "" ? 1 : 0 - name = data.google_container_cluster.this[0].subnetwork - region = var.gcp_region - project = var.gcp_project_id + count = var.cluster_name != "" && var.network_cidr == "" ? 1 : 0 + name = local.cluster_subnetwork_name + region = local.cluster_subnetwork_region + project = local.cluster_subnetwork_project } locals { - # Derived values from data sources - gcp_network_name = var.cluster_name != "" ? data.google_container_cluster.this[0].network : "" - gcp_subnet_cidr = var.cluster_name != "" ? data.google_compute_subnetwork.this[0].ip_cidr_range : "" - - # Use override if provided, otherwise use derived value + # Derived values from data sources. one() yields null when the data source was + # skipped, so the override branches below stay reachable. + # Not coalesce(): it discards empty strings as well as nulls and errors when every + # argument is empty. + derived_network_name = try(one(data.google_container_cluster.this[*].network), null) + derived_subnet_cidr = try(one(data.google_compute_subnetwork.this[*].ip_cidr_range), null) + + gcp_network_name = local.derived_network_name != null ? local.derived_network_name : "" + gcp_subnet_cidr = local.derived_subnet_cidr != null ? local.derived_subnet_cidr : "" + + # Use override if provided, otherwise use derived value. + # google_compute_firewall.network runs its value through ParseGlobalFieldValue, so + # a full projects/P/global/networks/N path is accepted here and deliberately left + # unparsed — only the subnetwork data source lacks that normalization. effective_network_name = var.gcp_network_name != "" ? var.gcp_network_name : local.gcp_network_name effective_network_cidr = var.network_cidr != "" ? var.network_cidr : local.gcp_subnet_cidr } diff --git a/infrastructure/gcp/security/tests/security.tftest.hcl b/infrastructure/gcp/security/tests/security.tftest.hcl new file mode 100644 index 000000000..fa1ad9587 --- /dev/null +++ b/infrastructure/gcp/security/tests/security.tftest.hcl @@ -0,0 +1,239 @@ +mock_provider "google" {} + +variables { + cluster_name = "myorg-cluster" + gcp_project_id = "myorg-project" + gcp_region = "us-central1" +} + +############################################################################### +# Subnetwork reference parsing +# +# The GKE API always returns a relative resource path, so that is the shape that +# matters. The other two are covered so a later "simplification" to a fixed index +# or basename() cannot pass silently. +############################################################################### + +run "relative_resource_path_yields_name_region_and_project" { + command = plan + + override_data { + target = data.google_container_cluster.this + values = { + subnetwork = "projects/host-project/regions/europe-west4/subnetworks/subnet-gke" + network = "myorg-vpc" + } + } + + assert { + condition = local.cluster_subnetwork_name == "subnet-gke" + error_message = "Should extract the bare subnetwork name from a full resource path" + } + + # These two are the point of the change: the path names a project and region that + # differ from the configured ones, and resolving the subnetwork against the + # configured pair would 404. + assert { + condition = local.cluster_subnetwork_region == "europe-west4" + error_message = "The region from the resource path must win over gcp_region, or a zonal cluster (whose gcp_region is a zone) and a cross-region subnet both 404" + } + + assert { + condition = local.cluster_subnetwork_project == "host-project" + error_message = "The project from the resource path must win over gcp_project_id, or a Shared VPC subnet in the host project 404s" + } +} + +run "self_link_is_parsed_too" { + command = plan + + override_data { + target = data.google_container_cluster.this + values = { + subnetwork = "https://www.googleapis.com/compute/v1/projects/host-project/regions/europe-west4/subnetworks/subnet-gke" + network = "myorg-vpc" + } + } + + assert { + condition = local.cluster_subnetwork_name == "subnet-gke" + error_message = "A self_link must parse to the same name" + } + + assert { + condition = local.cluster_subnetwork_region == "europe-west4" && local.cluster_subnetwork_project == "host-project" + error_message = "A self_link must yield the same region and project as the relative path" + } +} + +run "bare_name_falls_back_to_the_configured_project_and_region" { + command = plan + + override_data { + target = data.google_container_cluster.this + values = { + subnetwork = "subnet-gke" + network = "myorg-vpc" + } + } + + assert { + condition = local.cluster_subnetwork_name == "subnet-gke" + error_message = "A bare subnetwork name must pass through unchanged" + } + + assert { + condition = local.cluster_subnetwork_region == "us-central1" && local.cluster_subnetwork_project == "myorg-project" + error_message = "With no path to parse, the configured region and project must be used" + } +} + +############################################################################### +# Firewall wiring +# +# Asserting on counts alone is vacuous: they depend only on the enable flags, so +# reverting the parse leaves them green. These assert the values the derivation +# actually produces. +############################################################################### + +run "public_health_check_allows_the_subnet_cidr_and_gcp_probes" { + command = plan + + variables { + gateways_enabled = true + gateway_internal_enabled = true + } + + override_data { + target = data.google_container_cluster.this + values = { + subnetwork = "projects/myorg-project/regions/us-central1/subnetworks/subnet-gke" + network = "projects/myorg-project/global/networks/myorg-vpc" + } + } + + override_data { + target = data.google_compute_subnetwork.this + values = { + ip_cidr_range = "10.20.0.0/20" + } + } + + assert { + condition = google_compute_firewall.public_gateway_health_check[0].source_ranges == toset(["10.20.0.0/20", "35.191.0.0/16", "130.211.0.0/22"]) + error_message = "The health-check rule must allow the derived subnet CIDR plus both GCP health check ranges" + } + + assert { + condition = google_compute_firewall.private_gateway_https[0].source_ranges == toset(["10.20.0.0/20"]) + error_message = "The private HTTPS rule must be restricted to the derived subnet CIDR" + } + + # The network value is deliberately NOT parsed: google_compute_firewall.network + # runs it through ParseGlobalFieldValue, so a full path is valid there. + assert { + condition = google_compute_firewall.public_gateway_https[0].network == "projects/myorg-project/global/networks/myorg-vpc" + error_message = "The network reference must pass through unparsed" + } + + assert { + condition = length(google_compute_firewall.public_gateway_https) == 1 && length(google_compute_firewall.private_gateway_https) == 1 + error_message = "Both gateways' rules should be created when both flags are true" + } +} + +run "gateways_can_be_disabled_independently" { + command = plan + + variables { + gateways_enabled = false + gateway_internal_enabled = true + } + + override_data { + target = data.google_container_cluster.this + values = { + subnetwork = "projects/myorg-project/regions/us-central1/subnetworks/subnet-gke" + network = "myorg-vpc" + } + } + + assert { + condition = length(google_compute_firewall.public_gateway_https) == 0 && length(google_compute_firewall.private_gateway_https) == 1 + error_message = "gateways_enabled must gate only the public rules" + } +} + +############################################################################### +# Overrides +############################################################################### + +run "network_cidr_override_wins_and_skips_the_subnetwork_lookup" { + command = plan + + variables { + gateway_internal_enabled = true + network_cidr = "192.168.0.0/24" + } + + override_data { + target = data.google_container_cluster.this + values = { + subnetwork = "projects/myorg-project/regions/us-central1/subnetworks/subnet-gke" + network = "myorg-vpc" + } + } + + assert { + condition = local.effective_network_cidr == "192.168.0.0/24" + error_message = "An explicit network_cidr must win over the derived value" + } + + assert { + condition = length(data.google_compute_subnetwork.this) == 0 + error_message = "With network_cidr supplied there is nothing to derive, so the subnetwork lookup must be skipped rather than requiring compute.subnetworks.get" + } +} + +run "both_overrides_skip_the_cluster_lookup_entirely" { + command = plan + + variables { + gateways_enabled = true + gateway_internal_enabled = true + gcp_network_name = "myorg-vpc" + network_cidr = "192.168.0.0/24" + } + + assert { + condition = length(data.google_container_cluster.this) == 0 && length(data.google_compute_subnetwork.this) == 0 + error_message = "With both values supplied the module must not read the cluster at all, so it does not need container.clusters.get" + } + + assert { + condition = google_compute_firewall.private_gateway_https[0].source_ranges == toset(["192.168.0.0/24"]) + error_message = "The rules must still be built from the supplied values" + } +} + +run "gcp_network_name_override_wins" { + command = plan + + variables { + gateways_enabled = true + gcp_network_name = "override-vpc" + } + + override_data { + target = data.google_container_cluster.this + values = { + subnetwork = "projects/myorg-project/regions/us-central1/subnetworks/subnet-gke" + network = "derived-vpc" + } + } + + assert { + condition = google_compute_firewall.public_gateway_https[0].network == "override-vpc" + error_message = "An explicit gcp_network_name must win over the derived network" + } +} diff --git a/infrastructure/gcp/security/variables.tf b/infrastructure/gcp/security/variables.tf index a888f7702..bd17189fc 100644 --- a/infrastructure/gcp/security/variables.tf +++ b/infrastructure/gcp/security/variables.tf @@ -27,12 +27,12 @@ variable "gateway_internal_enabled" { variable "gcp_network_name" { type = string - description = "Override: The VPC network name. If empty, derived from cluster." + description = "Override: The VPC network name. If empty, derived from the cluster. Supplying this together with network_cidr skips the cluster and subnetwork lookups entirely, so the caller does not need container.clusters.get or compute.subnetworks.get. Accepts a bare name or a full projects/P/global/networks/N path — google_compute_firewall normalizes either" default = "" } variable "network_cidr" { type = string - description = "Override: The network CIDR block. If empty, derived from subnet." + description = "Override: The network CIDR block. If empty, derived from the cluster's subnetwork. Supplying it skips the subnetwork lookup. Needed when the derived path cannot be resolved by the caller's credentials, e.g. a Shared VPC subnet in a host project the module cannot read" default = "" } diff --git a/nullplatform/agent/locals.tf b/nullplatform/agent/locals.tf index 6a40edb00..e7bde3127 100644 --- a/nullplatform/agent/locals.tf +++ b/nullplatform/agent/locals.tf @@ -4,17 +4,10 @@ locals { - scope_list = compact([trimspace(coalesce(var.agent_repos_scope, ""))]) - # Parse comma-separated extra repositories and clean whitespace - repos_extra = compact([for s in var.agent_repos_extra : trimspace(s)]) + tags = join(",", [for k in sort(keys(var.tags_selectors)) : "${k}:${var.tags_selectors[k]}"]) - # Merge scope and extra repositories, removing duplicates - final_repo_list = distinct(concat(local.scope_list, local.repos_extra)) - - agent_repos = join(",", local.final_repo_list) - tags = join(",", [for k in sort(keys(var.tags_selectors)) : "${k}:${var.tags_selectors[k]}"]) - - api_key = var.api_key + api_key = var.api_key + agent_repo = join(",", var.agent_repo) default_args = [ "--tags=$(TAGS)", @@ -23,7 +16,7 @@ locals { "--command-executor-env=NP_API_KEY=$(NP_API_KEY)", "--command-executor-debug", "--webserver-enabled", - "--command-executor-git-command-repos $(AGENT_REPOS)" + "--command-executor-git-command-repos=$(AGENT_REPO)" ] cloud_args = { @@ -36,19 +29,10 @@ locals { all_args = concat(local.default_args, lookup(local.cloud_args, var.cloud_provider, [])) default_config = { - NP_API_KEY = local.api_key - TAGS = local.tags - AGENT_REPOS = local.agent_repos - CLUSTER_NAME = var.cluster_name - NAMESPACE = var.namespace - IMAGE_TAG = var.image_tag - DOMAIN = var.domain - DNS_TYPE = var.dns_type - USE_ACCOUNT_SLUG = var.use_account_slug - IMAGE_PULL_SECRETS = var.image_pull_secrets - SERVICE_TEMPLATE = var.service_template - INITIAL_INGRESS_PATH = var.initial_ingress_path - BLUE_GREEN_INGRESS_PATH = var.blue_green_ingress_path + NP_API_KEY = local.api_key + TAGS = local.tags + IMAGE_TAG = var.image_tag + AGENT_REPO = local.agent_repo } cloud_config = { @@ -56,35 +40,106 @@ locals { AWS_IAM_ROLE_ARN = var.aws_iam_role_arn } - gcp = { - PRIVATE_GATEWAY_NAME = var.private_gateway_name - PRIVATE_DOMAIN = var.private_domain - } + gcp = {} + azure = {} + oci = {} + } + + # Drop nulls: a null reaching templatefile fails with an error that names no + # variable, before any precondition gets to report the actual missing input. + all_config = { + for k, v in merge( + local.default_config, + lookup(local.cloud_config, var.cloud_provider, {}), + var.extra_envs, + ) : k => v if v != null + } + + worker_default_env = { + DNS_TYPE = var.dns_type + DOMAIN = var.domain + USE_ACCOUNT_SLUG = var.use_account_slug + K8S_NAMESPACE = var.namespace + SERVICE_TEMPLATE = var.service_template + INITIAL_INGRESS_PATH = var.initial_ingress_path + BLUE_GREEN_INGRESS_PATH = var.blue_green_ingress_path + TRAFFIC_CONTAINER_IMAGE = "${var.agent_traffic_manager_repository}:${var.agent_traffic_manager_tag}" + IMAGE_PULL_SECRETS = var.image_pull_secrets + PRIVATE_GATEWAY_NAME = var.private_gateway_name + PUBLIC_GATEWAY_NAME = var.public_gateway_name + } + worker_cloud_config = { azure = { PRIVATE_HOSTED_ZONE_RG = var.private_hosted_zone_rg - PRIVATE_GATEWAY_NAME = var.private_gateway_name - PUBLIC_GATEWAY_NAME = var.public_gateway_name RESOURCE_GROUP = var.azure_resource_group AZURE_SUBSCRIPTION_ID = var.azure_subscription_id AZURE_CLIENT_SECRET = var.azure_client_secret AZURE_CLIENT_ID = var.azure_client_id AZURE_TENANT_ID = var.azure_tenant_id } + } + + worker_all_config = merge( + local.worker_default_env, + lookup(local.worker_cloud_config, var.cloud_provider, {}), + var.extra_envs, + ) - oci = { - PRIVATE_GATEWAY_NAME = var.private_gateway_name - PRIVATE_DOMAIN = var.private_domain + # Generic identity + resources — one patch per package in + # var.worker_orchestrated_packages, so any worker-orchestrated package's pod + # (not just "containers") gets the agent's own ServiceAccount (to assume its + # role's trusted AWS roles) and enough memory to run its own tooling (e.g. + # tofu init/apply), instead of the chart's own thin defaults. + worker_common_patches = [ + for pkg in var.worker_orchestrated_packages : { + target = { package = pkg } + merge = { + spec = merge( + var.service_account_name != "" ? { serviceAccountName = var.service_account_name } : {}, + { + containers = [ + { name = "worker", resources = { limits = { memory = var.worker_memory_limit } } } + ] + } + ) + } + } + ] + + # k8s-deployment template env vars — specific to the "containers" scope's + # worker only, regardless of what's in var.worker_orchestrated_packages. + worker_container_patch = { + target = { package = "containers" } + merge = { + spec = { + containers = [ + { + name = "worker" + env = [for k, v in local.worker_all_config : { name = k, value = v }] + } + ] + } } } - all_config = merge( - local.default_config, - lookup(local.cloud_config, var.cloud_provider, {}), - var.extra_envs, + worker_defaults = { + backend = "kubernetes" + allowedRegistries = ["public.ecr.aws/nullplatform/*"] + patches = concat(local.worker_common_patches, [local.worker_container_patch]) + } + + worker_final = merge( + local.worker_defaults, + try({ for k, v in var.worker : k => v if !contains(["patches", "allowedRegistries"], k) }, {}), + { + patches = concat(local.worker_defaults.patches, try(var.worker.patches, [])) + allowedRegistries = distinct(concat(local.worker_defaults.allowedRegistries, try(var.worker.allowedRegistries, []))) + } ) - # Template único y simple + # Single combined values document — worker is just another top-level key + # of the same agent chart values, not a second Helm values layer. nullplatform_agent_values = templatefile("${path.module}/templates/nullplatform_agent_values.tmpl.yaml", { args = local.all_args config_values = local.all_config @@ -93,9 +148,6 @@ locals { aws_iam_role_arn = var.cloud_provider == "aws" ? var.aws_iam_role_arn : "" init_scripts = var.init_scripts service_account_name = var.service_account_name + worker = local.worker_final }) - - # Worker-orchestration config as a second Helm values layer, so the nested - # shape (allowedRegistries/patches/rules/pins) passes through verbatim. - worker_values = var.worker != null ? yamlencode({ worker = var.worker }) : null } diff --git a/nullplatform/agent/main.tf b/nullplatform/agent/main.tf index cc26ec895..6af1c9985 100644 --- a/nullplatform/agent/main.tf +++ b/nullplatform/agent/main.tf @@ -28,22 +28,26 @@ resource "terraform_data" "cross_variable_validation" { condition = var.cloud_provider != "azure" || var.azure_resource_group != null error_message = "azure_resource_group is required when cloud_provider is 'azure'." } - precondition { - condition = var.cloud_provider != "azure" || var.private_gateway_name != null - error_message = "private_gateway_name is required when cloud_provider is 'azure'." - } precondition { condition = var.cloud_provider != "azure" || var.private_hosted_zone_rg != null error_message = "private_hosted_zone_rg is required when cloud_provider is 'azure'." } - precondition { - condition = var.cloud_provider != "azure" || var.public_gateway_name != null - error_message = "public_gateway_name is required when cloud_provider is 'azure'." - } precondition { condition = var.cloud_provider != "azure" || var.azure_tenant_id != null error_message = "azure_tenant_id is required when cloud_provider is 'azure'." } + precondition { + condition = lookup(var.extra_envs, "INGRESS_TYPE", "") != "istio" || var.service_template != "" + error_message = "service_template is required when extra_envs.INGRESS_TYPE is 'istio' — the k8s scope's default template is AWS ALB Ingress and won't route traffic correctly through Istio." + } + precondition { + condition = lookup(var.extra_envs, "INGRESS_TYPE", "") != "istio" || var.initial_ingress_path != "" + error_message = "initial_ingress_path is required when extra_envs.INGRESS_TYPE is 'istio' — the k8s scope's default template is AWS ALB Ingress and won't route traffic correctly through Istio." + } + precondition { + condition = lookup(var.extra_envs, "INGRESS_TYPE", "") != "istio" || var.blue_green_ingress_path != "" + error_message = "blue_green_ingress_path is required when extra_envs.INGRESS_TYPE is 'istio' — the k8s scope's default template is AWS ALB Ingress and won't route traffic correctly through Istio." + } } } @@ -55,25 +59,21 @@ resource "helm_release" "agent" { namespace = var.namespace version = var.nullplatform_agent_helm_version - create_namespace = true - disable_webhooks = false - force_update = true - wait = true + # The provider defaults these three to false. Without create_namespace a fresh + # install dies with `namespaces "" not found`; without + # atomic/cleanup_on_fail a failed upgrade sticks in `failed` with orphaned + # resources instead of rolling back. + create_namespace = var.create_namespace + atomic = true + cleanup_on_fail = true + wait_for_jobs = true timeout = 600 - atomic = true - cleanup_on_fail = true - replace = true - recreate_pods = true reset_values = true - reuse_values = false dependency_update = true max_history = 10 - values = concat( - [local.nullplatform_agent_values], - var.worker != null ? [local.worker_values] : [], - ) + values = [local.nullplatform_agent_values] lifecycle { replace_triggered_by = [terraform_data.api_key_trigger] diff --git a/nullplatform/agent/templates/nullplatform_agent_values.tmpl.yaml b/nullplatform/agent/templates/nullplatform_agent_values.tmpl.yaml index ef3711ab3..4785a44d7 100644 --- a/nullplatform/agent/templates/nullplatform_agent_values.tmpl.yaml +++ b/nullplatform/agent/templates/nullplatform_agent_values.tmpl.yaml @@ -35,3 +35,8 @@ initScripts: %{ endfor } %{ endif } +worker: + %{ for line in split("\n", yamlencode(worker)) } + ${line} + %{ endfor } + diff --git a/nullplatform/agent/tests/agent.tftest.hcl b/nullplatform/agent/tests/agent.tftest.hcl new file mode 100644 index 000000000..7e99fb2cc --- /dev/null +++ b/nullplatform/agent/tests/agent.tftest.hcl @@ -0,0 +1,98 @@ +mock_provider "nullplatform" {} +mock_provider "helm" {} + +variables { + api_key = "test-api-key" + tags_selectors = { environment = "test" } + image_tag = "latest" + cloud_provider = "gcp" + nullplatform_agent_helm_version = "2.37.0" + agent_traffic_manager_tag = "1.8.0" +} + +run "no_extra_envs_does_not_require_ingress_templates" { + command = plan +} + +run "ingress_type_not_istio_does_not_require_ingress_templates" { + command = plan + + variables { + extra_envs = { INGRESS_TYPE = "nginx" } + } +} + +run "ingress_type_istio_requires_service_template" { + command = plan + + variables { + extra_envs = { INGRESS_TYPE = "istio" } + initial_ingress_path = "/root/.np/nullplatform/scopes/k8s/deployment/templates/istio/initial-httproute.yaml.tpl" + blue_green_ingress_path = "/root/.np/nullplatform/scopes/k8s/deployment/templates/istio/blue-green-httproute.yaml.tpl" + } + + expect_failures = [ + terraform_data.cross_variable_validation, + ] +} + +run "ingress_type_istio_requires_initial_ingress_path" { + command = plan + + variables { + extra_envs = { INGRESS_TYPE = "istio" } + service_template = "/root/.np/nullplatform/scopes/k8s/deployment/templates/istio/service.yaml.tpl" + blue_green_ingress_path = "/root/.np/nullplatform/scopes/k8s/deployment/templates/istio/blue-green-httproute.yaml.tpl" + } + + expect_failures = [ + terraform_data.cross_variable_validation, + ] +} + +run "ingress_type_istio_requires_blue_green_ingress_path" { + command = plan + + variables { + extra_envs = { INGRESS_TYPE = "istio" } + service_template = "/root/.np/nullplatform/scopes/k8s/deployment/templates/istio/service.yaml.tpl" + initial_ingress_path = "/root/.np/nullplatform/scopes/k8s/deployment/templates/istio/initial-httproute.yaml.tpl" + } + + expect_failures = [ + terraform_data.cross_variable_validation, + ] +} + +run "ingress_type_istio_with_all_ingress_templates_succeeds" { + command = plan + + variables { + extra_envs = { INGRESS_TYPE = "istio" } + service_template = "/root/.np/nullplatform/scopes/k8s/deployment/templates/istio/service.yaml.tpl" + initial_ingress_path = "/root/.np/nullplatform/scopes/k8s/deployment/templates/istio/initial-httproute.yaml.tpl" + blue_green_ingress_path = "/root/.np/nullplatform/scopes/k8s/deployment/templates/istio/blue-green-httproute.yaml.tpl" + } +} + +run "aws_with_ingress_type_istio_still_requires_ingress_templates" { + command = plan + + variables { + cloud_provider = "aws" + aws_iam_role_arn = "arn:aws:iam::123456789012:role/test-role" + extra_envs = { INGRESS_TYPE = "istio" } + } + + expect_failures = [ + terraform_data.cross_variable_validation, + ] +} + +run "oci_succeeds_with_default_gateway_names" { + command = plan + + variables { + cloud_provider = "oci" + } +} diff --git a/nullplatform/agent/tests/agent_values.tftest.hcl b/nullplatform/agent/tests/agent_values.tftest.hcl new file mode 100644 index 000000000..038d0f35f --- /dev/null +++ b/nullplatform/agent/tests/agent_values.tftest.hcl @@ -0,0 +1,327 @@ +mock_provider "helm" {} +mock_provider "nullplatform" {} + +variables { + api_key = "test-api-key" + tags_selectors = { dimension = "prod" } + cloud_provider = "aws" + aws_iam_role_arn = "arn:aws:iam::123456789012:role/agent" + image_tag = "0.9.2" + nullplatform_agent_helm_version = "2.37.0" + agent_traffic_manager_tag = "1.8.0" +} + +################################################################################ +# Traffic manager image +################################################################################ + +# Pinning the traffic manager used to mean passing the whole image string through +# extra_envs. The registry now lives in the module and only the tag is exposed. +# TRAFFIC_CONTAINER_IMAGE is a worker-only var (worker_default_env), so it +# renders as an env list entry, not a flat configuration.values key. +run "traffic_manager_image_is_assembled_from_the_tag" { + command = plan + + assert { + condition = strcontains(helm_release.agent.values[0], "\"name\": \"TRAFFIC_CONTAINER_IMAGE\"") && strcontains(helm_release.agent.values[0], "\"value\": \"public.ecr.aws/nullplatform/k8s-traffic-manager:1.8.0\"") + error_message = "TRAFFIC_CONTAINER_IMAGE should be built from the repository default and the pinned tag, and reach the worker's env" + } +} + +run "traffic_manager_repository_is_overridable" { + command = plan + + variables { + agent_traffic_manager_repository = "my-mirror.example.com/nullplatform/k8s-traffic-manager" + } + + assert { + condition = strcontains(helm_release.agent.values[0], "\"name\": \"TRAFFIC_CONTAINER_IMAGE\"") && strcontains(helm_release.agent.values[0], "\"value\": \"my-mirror.example.com/nullplatform/k8s-traffic-manager:1.8.0\"") + error_message = "the registry must be overridable for a mirrored path" + } +} + +run "extra_envs_still_overrides_the_traffic_manager_image" { + command = plan + + variables { + extra_envs = { + TRAFFIC_CONTAINER_IMAGE = "public.ecr.aws/nullplatform/k8s-traffic-manager@sha256:abc123" + } + } + + # extra_envs is merged last, so the previous way of doing this keeps working. That is what + # makes exposing the tag an addition rather than a breaking change. + assert { + condition = strcontains(helm_release.agent.values[0], "TRAFFIC_CONTAINER_IMAGE: \"public.ecr.aws/nullplatform/k8s-traffic-manager@sha256:abc123\"") + error_message = "extra_envs must keep precedence over the assembled image" + } +} + +# The worker has its own env map (worker_all_config), layered with extra_envs +# the same way all_config is for the agent, so an override reaches both. +run "extra_envs_also_reaches_the_worker" { + command = plan + + variables { + extra_envs = { + TRAFFIC_CONTAINER_IMAGE = "public.ecr.aws/nullplatform/k8s-traffic-manager@sha256:abc123" + } + } + + assert { + condition = strcontains(helm_release.agent.values[0], "\"name\": \"TRAFFIC_CONTAINER_IMAGE\"") && strcontains(helm_release.agent.values[0], "\"value\": \"public.ecr.aws/nullplatform/k8s-traffic-manager@sha256:abc123\"") + error_message = "extra_envs overrides must also reach the worker's env" + } +} + +################################################################################ +# Worker orchestration +################################################################################ + +# DNS_TYPE/DOMAIN/USE_ACCOUNT_SLUG/SERVICE_TEMPLATE/INITIAL_INGRESS_PATH/ +# BLUE_GREEN_INGRESS_PATH are consumed by the worker when it renders a scope's +# k8s deployment, not by the agent's own control loop — they live on the +# worker's env only (worker_default_env), never in the agent's own +# configuration.values (default_config). +run "moved_deploy_vars_are_worker_only" { + command = plan + + variables { + domain = "playground.nullapps.io" + dns_type = "external_dns" + namespace = "nullplatform" + } + + assert { + condition = alltrue([ + for key in ["DNS_TYPE", "DOMAIN", "USE_ACCOUNT_SLUG", "SERVICE_TEMPLATE", + "INITIAL_INGRESS_PATH", "BLUE_GREEN_INGRESS_PATH", "NAMESPACE"] : + !strcontains(helm_release.agent.values[0], "\n ${key}:") + ]) + error_message = "deploy/DNS vars and NAMESPACE must not leak into the agent pod's own configuration.values" + } +} + +run "worker_block_always_present_with_expected_env" { + command = plan + + variables { + domain = "playground.nullapps.io" + dns_type = "external_dns" + namespace = "nullplatform" + } + + assert { + condition = strcontains(helm_release.agent.values[0], "\"backend\": \"kubernetes\"") + error_message = "worker block must always be emitted, even without var.worker set" + } + + assert { + condition = ( + strcontains(helm_release.agent.values[0], "\"name\": \"DNS_TYPE\"") && + strcontains(helm_release.agent.values[0], "\"value\": \"external_dns\"") && + strcontains(helm_release.agent.values[0], "\"name\": \"DOMAIN\"") && + strcontains(helm_release.agent.values[0], "\"value\": \"playground.nullapps.io\"") && + strcontains(helm_release.agent.values[0], "\"name\": \"K8S_NAMESPACE\"") && + strcontains(helm_release.agent.values[0], "\"value\": \"nullplatform\"") && + strcontains(helm_release.agent.values[0], "\"name\": \"TRAFFIC_CONTAINER_IMAGE\"") && + strcontains(helm_release.agent.values[0], "\"value\": \"public.ecr.aws/nullplatform/k8s-traffic-manager:1.8.0\"") + ) + error_message = "worker env must carry the deploy/DNS vars plus namespace and the traffic-manager image" + } +} + +# backend/allowedRegistries have no dedicated variables — they're just keys +# on var.worker, same as idleTTL or any other chart field. +# backend is a plain override (var.worker's value wins outright); allowedRegistries +# is additive like patches — var.worker's entries join the default rather than +# replacing it, so the platform's own scope images keep pulling. +run "worker_backend_overrides_allowed_registries_extends" { + command = plan + + variables { + worker = { + backend = "nomad" + allowedRegistries = ["123456789012.dkr.ecr.us-east-1.amazonaws.com/my-org/*"] + } + } + + assert { + condition = ( + strcontains(helm_release.agent.values[0], "\"backend\": \"nomad\"") && + !strcontains(helm_release.agent.values[0], "\"backend\": \"kubernetes\"") + ) + error_message = "var.worker.backend must override the module default outright" + } + + assert { + condition = ( + strcontains(helm_release.agent.values[0], "public.ecr.aws/nullplatform/*") && + strcontains(helm_release.agent.values[0], "123456789012.dkr.ecr.us-east-1.amazonaws.com/my-org/*") + ) + error_message = "var.worker.allowedRegistries must extend the default registry list, not replace it" + } +} + +# The worker container has no service-account concept of its own — it always +# mirrors the agent's own service_account_name. +run "worker_service_account_mirrors_service_account_name" { + command = plan + + variables { + service_account_name = "my-sa" + } + + assert { + condition = strcontains(helm_release.agent.values[0], "\"serviceAccountName\": \"my-sa\"") + error_message = "worker's serviceAccountName should mirror service_account_name" + } +} + +run "worker_orchestrated_packages_gets_its_own_service_account_patch" { + command = plan + + variables { + worker_orchestrated_packages = ["containers", "aws-s3-bucket"] + } + + assert { + condition = strcontains(helm_release.agent.values[0], "\"package\": \"aws-s3-bucket\"") + error_message = "a package listed in worker_orchestrated_packages must get its own patch target" + } + + assert { + condition = length(regexall("\"serviceAccountName\": \"nullplatform-agent\"", helm_release.agent.values[0])) == 2 + error_message = "each package in worker_orchestrated_packages must get its own serviceAccountName patch (one per package, here 2)" + } + + assert { + condition = length(regexall("\"memory\": \"2Gi\"", helm_release.agent.values[0])) == 2 + error_message = "each package in worker_orchestrated_packages must get its own memory limit patch (one per package, here 2) — a package left out would silently OOM on the chart's thin default" + } +} + +run "worker_memory_limit_is_overridable" { + command = plan + + variables { + worker_orchestrated_packages = ["containers", "aws-s3-bucket"] + worker_memory_limit = "4Gi" + } + + assert { + condition = length(regexall("\"memory\": \"4Gi\"", helm_release.agent.values[0])) == 2 + error_message = "worker_memory_limit must apply to every package in worker_orchestrated_packages" + } +} + +run "worker_defaults" { + command = plan + + assert { + condition = strcontains(helm_release.agent.values[0], "public.ecr.aws/nullplatform/*") + error_message = "allowedRegistries must default to public.ecr.aws/nullplatform/* so the platform's own scope images keep pulling" + } + + assert { + condition = strcontains(helm_release.agent.values[0], "\"memory\": \"2Gi\"") + error_message = "the worker container's memory limit must default to 2Gi" + } + + assert { + condition = strcontains(helm_release.agent.values[0], "\"serviceAccountName\": \"nullplatform-agent\"") + error_message = "the worker's serviceAccountName must default to service_account_name's default (nullplatform-agent)" + } +} + +# var.worker stays available as an extra/override layer on top of the computed +# defaults: its own patches are concatenated (not dropped, so a caller wanting +# a different memory limit adds their own patch rather than replacing ours), +# and its other top-level keys (e.g. idleTTL) pass through. +run "worker_extra_patches_and_overrides_are_merged_not_replaced" { + command = plan + + variables { + worker = { + idleTTL = "30m" + patches = [ + { target = { package = "my-pkg" }, merge = { spec = { serviceAccountName = "np-agent-sa" } } } + ] + } + } + + assert { + condition = ( + strcontains(helm_release.agent.values[0], "\"idleTTL\": \"30m\"") && + strcontains(helm_release.agent.values[0], "\"package\": \"my-pkg\"") && + strcontains(helm_release.agent.values[0], "\"name\": \"worker\"") + ) + error_message = "var.worker's own patches/keys must be merged alongside the computed worker-container patch, not replace it" + } +} + +################################################################################ +# Legacy exec repos +################################################################################ + +run "agent_repo_joins_multiple_repos_with_no_spaces" { + command = plan + + variables { + agent_repo = [ + "https://github.com/nullplatform/scopes.git#v1.15.1", + "https://github.com/nullplatform/services-s-3.git#v0.3.0", + ] + } + + assert { + condition = strcontains(helm_release.agent.values[0], "AGENT_REPO: \"https://github.com/nullplatform/scopes.git#v1.15.1,https://github.com/nullplatform/services-s-3.git#v0.3.0\"") + error_message = "agent_repo entries must be joined with a comma and no spaces" + } +} + +run "agent_repo_defaults_to_empty" { + command = plan + + assert { + condition = strcontains(helm_release.agent.values[0], "AGENT_REPO: \"\"") + error_message = "agent_repo must default to an empty list, joining to an empty string" + } +} + +# atomic/cleanup_on_fail default to false in the provider — without them a +# failed upgrade sticks in "failed" with orphaned resources instead of rolling +# back (observed in production: an "Error upgrading chart" left the release +# stuck until a manual retry). +run "helm_release_rolls_back_failed_upgrades" { + command = plan + + assert { + condition = helm_release.agent.atomic == true + error_message = "atomic must be true so a failed upgrade rolls back instead of sticking in failed" + } + + assert { + condition = helm_release.agent.cleanup_on_fail == true + error_message = "cleanup_on_fail must be true so a failed upgrade cleans up orphaned resources" + } + + assert { + condition = helm_release.agent.create_namespace == true + error_message = "create_namespace must default to true (the pre-existing behavior) so a fresh install doesn't die on a missing namespace" + } +} + +run "create_namespace_is_overridable" { + command = plan + + variables { + create_namespace = false + } + + assert { + condition = helm_release.agent.create_namespace == false + error_message = "create_namespace must be overridable to false for stacks where another module already owns the namespace" + } +} diff --git a/nullplatform/agent/variables.tf b/nullplatform/agent/variables.tf index 07724f3d2..b0ee81afc 100644 --- a/nullplatform/agent/variables.tf +++ b/nullplatform/agent/variables.tf @@ -9,20 +9,21 @@ variable "api_key" { sensitive = true } -# Name of the EKS cluster where the nullplatform agent will be deployed -variable "cluster_name" { - description = "Name of the EKS cluster where the nullplatform agent will be deployed" +# Image tag for the agent container image +variable "image_tag" { + # example: aws-0.10.0-nonroot + description = "Image tag for the agent container image" type = string } -# Nullplatform Resource Name - unique identifier for nullplatform resources. -# Kept as a required input for interface parity with the other nullplatform -# modules; the agent resolves its own scope from the API key, so this module does -# not consume the value directly. -# tflint-ignore: terraform_unused_declarations -variable "nrn" { - description = "Nullplatform Resource Name - unique identifier for nullplatform resources" +# Cloud provider the cluster runs on +variable "cloud_provider" { + description = "Cloud provider to use ('aws', 'gcp', 'azure', or 'oci')" type = string + validation { + condition = contains(["aws", "gcp", "azure", "oci"], var.cloud_provider) + error_message = "cloud_provider must be either 'aws' , 'gcp', 'oci' or 'azure'." + } } # Map of tags used to select and filter channels and agents @@ -31,6 +32,22 @@ variable "tags_selectors" { type = map(string) } +variable "agent_repo" { + description = <<-EOT + Git repositories (each with a ref) the agent clones for its legacy + command-executor exec flow. Joined into a comma-separated AGENT_REPO + value, no spaces. Empty when every scope uses worker_orchestrator instead. + + Example: + agent_repo = [ + "https://github.com/nullplatform/scopes.git#v1.15.1", + "https://github.com/nullplatform/services-s-3.git#v0.3.0", + ] + EOT + type = list(string) + default = [] +} + ################################################################################ # Agent configuration ################################################################################ @@ -46,30 +63,64 @@ variable "release_name" { variable "service_account_name" { description = "Override for the Kubernetes ServiceAccount name created by the Helm chart" type = string - default = "" + default = "nullplatform-agent" +} + +variable "worker_orchestrated_packages" { + description = <<-EOT + Package slugs whose worker-orchestrator (package-exec) pods should run + under var.service_account_name (the same IRSA identity as the agent + itself) and var.worker_memory_limit, via a per-package worker-container + patch. Add a package's slug here whenever its worker needs to assume an + AWS role, or needs more memory than the chart's own default (e.g. to run + tofu/terraform); a worker for a package not listed here falls back to the + namespace's default ServiceAccount and the chart's own memory default. + + This is separate from the "containers" scope's own k8s-deployment env + vars (DNS_TYPE, DOMAIN, etc.), which remain specific to that package + regardless of what's listed here. + EOT + type = list(string) + default = ["containers"] +} + +variable "worker_memory_limit" { + description = "Memory limit for a worker-orchestrated package's pod (packages in var.worker_orchestrated_packages). The chart's own default is small enough to OOM mid-tofu-apply for packages that run real IaC tooling." + type = string + default = "2Gi" } # Version of the nullplatform agent Helm chart to deploy variable "nullplatform_agent_helm_version" { - description = "Version of the nullplatform agent Helm chart to deploy" + # example: 2.37.0 + description = "No default: every install pins this deliberately — see VERSIONS.md. Version of the nullplatform agent Helm chart to deploy" type = string # 2.37.0+ ships the worker orchestrator (patches, per-install isolation, idle - # reaper, insecure default). - default = "2.37.0" + + validation { + condition = var.nullplatform_agent_helm_version != "" && !contains(["latest", "main", "master"], lower(var.nullplatform_agent_helm_version)) + error_message = "nullplatform_agent_helm_version must be a non-empty fixed version, not empty and not a moving reference." + } } variable "worker" { description = <<-EOT - Worker-orchestration config, merged into the agent chart's `worker` block: - backend, security, allowedRegistries (deny-by-default registry guardrail), - patches (standard k8s patching of workers — the preferred way to shape them), - idleTTL (reap idle workers), and the legacy defaults/rules/pins. See the - nullplatform-agent chart values (>= 2.37.0) for the full shape. null = chart - defaults. + Extra worker-orchestration config, merged on top of the module's own computed + worker block: backend ("kubernetes" by default), allowedRegistries + (["public.ecr.aws/nullplatform/*"] by default, so the platform's own scope + images keep working), and a patch for the worker container (2Gi memory + limit, the deploy/DNS env vars below, and a serviceAccountName that always + mirrors service_account_name). allowedRegistries and patches set here are + concatenated with (not replacing) the module defaults — add your own + registries or an extra patch rather than having to repeat the defaults; + set backend here to override it outright. Anything else — security, idleTTL + (reap idle workers), the legacy defaults/rules/pins — passes through as-is. + See the nullplatform-agent chart values (>= 2.37.0) for the full shape. + null = nothing extra. Example: worker = { - allowedRegistries = ["public.ecr.aws/your-org/*"] + allowedRegistries = ["123456789012.dkr.ecr.us-east-1.amazonaws.com/your-org/*"] patches = [{ target = { package = "my-pkg" }, merge = { spec = { serviceAccountName = "np-agent-sa" } } }] idleTTL = "30m" } @@ -85,20 +136,30 @@ variable "namespace" { default = "nullplatform-tools" } -# Git repository URL containing agent scope configurations (format: repo#branch) -variable "agent_repos_scope" { - description = "Git repository URL containing agent scope configurations (format: repo#branch)" +variable "create_namespace" { + description = "Create the namespace if it does not exist. Leave true unless another module already owns it: nullplatform/base declares the same namespace with Helm ownership metadata, so with no ordering edge between the two whichever applies second fails." + type = bool + default = true +} + +variable "agent_traffic_manager_repository" { + description = "Container image repository for the traffic manager. Defaults to the official nullplatform image; override to pull from a mirror. Matches the pattern nullplatform/base uses for its own images." type = string - default = "https://github.com/nullplatform/scopes.git#main" + default = "public.ecr.aws/nullplatform/k8s-traffic-manager" } -# List of additional Git repositories used for extended agent configuration -variable "agent_repos_extra" { - description = "List of additional Git repositories used for extended agent configuration" - type = list(string) - default = [] +variable "agent_traffic_manager_tag" { + # example: 1.8.0 + description = "No default: every install pins this deliberately — see VERSIONS.md. Image tag for the traffic manager, published to the agent as TRAFFIC_CONTAINER_IMAGE. Pinning this used to mean passing the whole image string through extra_envs; the registry lives here so only the tag is exposed. extra_envs still takes precedence for anyone who needs a digest or a mirrored path." + type = string + + validation { + condition = var.agent_traffic_manager_tag != "" && !contains(["latest", "main", "master"], lower(var.agent_traffic_manager_tag)) + error_message = "agent_traffic_manager_tag must be a non-empty fixed version, not empty and not a moving reference." + } } + # List of initialization scripts to execute during agent startup variable "init_scripts" { description = "List of initialization scripts to execute during agent startup" @@ -106,18 +167,24 @@ variable "init_scripts" { default = [] } -# Image tag for the agent container image -variable "image_tag" { - description = "Image tag for the agent container image" +# Container image repository for the agent. Defaults to the official nullplatform image. +variable "image_repository" { + description = "Container image repository for the agent. Defaults to the official nullplatform image." type = string + default = "" } -variable "image_repository" { - description = "Container image repository for the agent. Defaults to the official nullplatform image." +# Flag to determine whether to use the account slug in resource naming +variable "use_account_slug" { + description = "Flag to determine whether to use the account slug in resource naming" type = string default = "" } +################################################################################ +# AWS Configuration +################################################################################ + # ARN of the AWS IAM role assigned to the agent (required when cloud_provider is 'aws') variable "aws_iam_role_arn" { description = "ARN of the AWS IAM role assigned to the agent" @@ -125,16 +192,6 @@ variable "aws_iam_role_arn" { default = "" } -# Cloud provider to use (aws, gcp, or azure) -variable "cloud_provider" { - description = "Cloud provider to use (aws, gcp, or azure)" - type = string - validation { - condition = contains(["aws", "gcp", "azure", "oci"], var.cloud_provider) - error_message = "cloud_provider must be either 'aws' , 'gcp', 'oci' or 'azure'." - } -} - ################################################################################ # Azure Configuration ################################################################################ @@ -168,13 +225,6 @@ variable "azure_resource_group" { default = null } -# Private gateway name for Azure networking (required when cloud_provider is 'azure') -variable "private_gateway_name" { - description = "Private gateway name for Azure networking" - type = string - default = null -} - # Resource group for private hosted zone (required when cloud_provider is 'azure') variable "private_hosted_zone_rg" { description = "Resource group for private hosted zone" @@ -182,13 +232,6 @@ variable "private_hosted_zone_rg" { default = null } -# Public gateway name for Azure networking (required when cloud_provider is 'azure') -variable "public_gateway_name" { - description = "Public gateway name for Azure networking" - type = string - default = null -} - # Azure tenant ID (required when cloud_provider is 'azure') variable "azure_tenant_id" { description = "Azure tenant ID" @@ -196,6 +239,24 @@ variable "azure_tenant_id" { default = null } +################################################################################ +# Gateway Configuration +################################################################################ + +# Name of the private/internal gateway used for routing +variable "private_gateway_name" { + description = "Name of the private/internal gateway used for routing" + type = string + default = "gateway-private" +} + +# Name of the public gateway used for routing +variable "public_gateway_name" { + description = "Name of the public gateway used for routing" + type = string + default = "gateway-public" +} + ################################################################################ # DNS and Domain Configuration ################################################################################ @@ -207,25 +268,13 @@ variable "dns_type" { default = "" } -# Base domain name used across resources (required when cloud_provider is 'azure') +# Base domain name used across resources variable "domain" { description = "Base domain name used across resources" type = string default = "" } -variable "private_domain" { - description = "Private domain name used for internal agent routing" - default = "" - type = string -} -# Flag to determine whether to use account slug in resource naming (required when cloud_provider is 'azure') -variable "use_account_slug" { - description = "Flag to determine whether to use account slug in resource naming" - type = string - default = "" -} - ################################################################################ # Image Configuration ################################################################################ @@ -237,24 +286,32 @@ variable "image_pull_secrets" { default = "" } +################################################################################ +# Ingress / Networking Configuration +################################################################################ + +# Scope service template to use for deployment (required when extra_envs.INGRESS_TYPE is 'istio') variable "service_template" { - description = "Specifies the name or reference of the scope service template to be used for deployment." + description = "Specifies the name or reference of the scope service template to be used for deployment. Required when extra_envs.INGRESS_TYPE is 'istio' — the k8s scope's default template is AWS ALB Ingress and won't route traffic correctly through Istio, so it must be pointed at an Istio-compatible template instead." type = string default = "" } +# Initial ingress path used on first deploy (required when extra_envs.INGRESS_TYPE is 'istio') variable "initial_ingress_path" { - description = "Defines the initial ingress path used when deploying the application for the first time." + description = "Defines the initial ingress path used when deploying the application for the first time. Required when extra_envs.INGRESS_TYPE is 'istio' — the k8s scope's default template is AWS ALB Ingress and won't route traffic correctly through Istio, so it must be pointed at an Istio HTTPRoute template instead." type = string default = "" } +# Blue-green ingress path used to route traffic to the new version (required when extra_envs.INGRESS_TYPE is 'istio') variable "blue_green_ingress_path" { - description = "Specifies the ingress path used for blue-green deployments to route traffic to the new version." + description = "Specifies the ingress path used for blue-green deployments to route traffic to the new version. Required when extra_envs.INGRESS_TYPE is 'istio' — the k8s scope's default template is AWS ALB Ingress and won't route traffic correctly through Istio, so it must be pointed at an Istio HTTPRoute template instead." type = string default = "" } +# Additional environment variables to pass to the agent variable "extra_envs" { description = "Additional environment variables to pass to the agent" type = map(string) diff --git a/nullplatform/api_key/.terraform.lock.hcl b/nullplatform/api_key/.terraform.lock.hcl index af891c76f..ad7ad8161 100644 --- a/nullplatform/api_key/.terraform.lock.hcl +++ b/nullplatform/api_key/.terraform.lock.hcl @@ -2,25 +2,23 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/nullplatform/nullplatform" { - version = "0.0.95" - constraints = "~> 0.0.86" + version = "0.0.101" + constraints = "~> 0.0.101" hashes = [ - "h1:CwWF67wR7+i4kIqtQ6t6wDwJTCnnfwbK60B5zH2xMCo=", - "h1:TOKlvQhgsNAlXDVB9jNfTUTMIOgxgMJgHsr7AqTVtdA=", - "h1:UYs+ehJD8m3YGSb1vZUfFbYBHBRBj/Kn15Lp6OK2M5Y=", - "zh:02ec1e02e738e5f138e919d17391966e4b92d6933b6cd318eaed85e908f5cc8c", - "zh:1b2015fa088a40c4dcaef803d71942909ec45036524c12ca18272bf37dbf9283", - "zh:213f5ab10cc4c95e4568fdd10fec55886615978617b5815c9a900f233b4dca6e", - "zh:2a0ae4273515079385926beedeaf034257c4ee08e78733e19676fac9db4fd8d1", - "zh:356c2f91085ac397c419650a55c2ec8551da811db503a3dc0e510e05ca8fba42", - "zh:40bbd8163228b1ae88d515c643922c0565b015559b01023bc2ababdb3ad13e5c", - "zh:42ab71a0675b06fe3f661704142be8ddb029f052e3a001115fbb3ad0ee5e97f4", - "zh:46c6130dd7688a372a244c388f8568ddd12512e6d70d16b924495a4088f5ed4a", - "zh:5d862aa4ffa4f452a03e9a8ad3922a25674aff0a9770b674e09a68a087db17bb", - "zh:94cfe1a3446c4ea48d16b10c0b50f99bee89218f0a054514a97215c3af43c427", - "zh:aef3d42714928ca4099768efdaaf0d6eedab717d2b133ccb5de67167dfc24332", - "zh:e28d2e0a9b297c84dd18f1ab0a4f986108d2e1571523b15389473be4c267e917", - "zh:f1dd95be9f23c5602fcfca965832984f6d4411258c4ea87f69177a4604a5f39a", + "h1:XPoJp1UoUkgc5wOHFT2QUAjXrKTlZmAktgjIOKAAzfU=", + "zh:1b7fcf7075a3d18afe92bdb9f62420672d7fea7a4cf74dad5cfdd6cdc71d9e6a", + "zh:1bbb2061fa2e5102c0175b3679c93bde5b0f191041e671c2d8b0cc625e169954", + "zh:275d02069d39671bf848d14c258cbae5da88c8ef6b80839721d1fe07392485e0", + "zh:2bbb4d28410843cd949ab879d608fb25698c17ad735eb2fc3197221a041d2f57", + "zh:63dee806c8369f5fa77ff69b20b8cc3c76ce6c073b0b18ef776c89e54cc3cc79", + "zh:66ec8c5f0160cf9ddb0a8fc39a02655dc01baf982d3146c05905b6a2b92d0c4d", + "zh:94a517139d2bcf5b9d33234a00ebc65313952ad1d79958dfdd6dd165fb7952c8", + "zh:97357339d3d6b0dd61d7f05a60fbcb3a2ed9e1775551e875672b20828bcf010c", + "zh:a3ccf69e8dc785e80f1ad4ef4a42980ce3a89afda6f0a547075fffc81d8baea2", + "zh:ccd32a4c72f185f374a95e16a07685cad70224328fc434495d2545481a604c8b", + "zh:d7b50bb2561dafa32e27b92f37efeb450372003425873c5b247c9b7e4c25e219", + "zh:e35f4f870bd060e15e0a3d19e18e50bac346c24ba8c1ec1e198d441febadc696", + "zh:f4fbbed70bacbf83a5748e9d0a229dc07dfc34f683790ea86fb62fcfc9bee508", "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", ] } diff --git a/nullplatform/api_key/locals.tf b/nullplatform/api_key/locals.tf index 6301a721e..34fc9d96c 100644 --- a/nullplatform/api_key/locals.tf +++ b/nullplatform/api_key/locals.tf @@ -19,6 +19,19 @@ locals { "secrets-reader", ] } + # Same roles as the agent key minus secrets-reader: the base chart deploys + # the logs controller and the control plane agent, neither of which reads + # secrets. Keeping the rest aligned means an install can hand base its own + # credential without losing a capability the agent key had. + base = { + name = "BASE" + role_slugs = [ + "controlplane:agent", + "developer", + "ops", + "secops", + ] + } scope_notification = { name = "SCOPE-NOTIFICATION-CHANNEL-${local.slug}" role_slugs = [ diff --git a/nullplatform/api_key/main.tf b/nullplatform/api_key/main.tf index 79961f2d4..37fe5becd 100644 --- a/nullplatform/api_key/main.tf +++ b/nullplatform/api_key/main.tf @@ -3,7 +3,8 @@ ################################################################################ resource "nullplatform_api_key" "this" { - name = local.config.name + name = local.config.name + internal = var.internal dynamic "grants" { for_each = tomap({ for idx, grant in local.grants : tostring(idx) => grant }) @@ -36,7 +37,7 @@ resource "nullplatform_api_key" "this" { precondition { condition = var.type == "custom" || var.nrn != null - error_message = "nrn is required for predefined types (agent, scope_notification, service_notification)" + error_message = "nrn is required for predefined types (agent, base, scope_notification, service_notification)" } precondition { diff --git a/nullplatform/api_key/providers.tf b/nullplatform/api_key/providers.tf index 903a15d7b..0afb9d176 100644 --- a/nullplatform/api_key/providers.tf +++ b/nullplatform/api_key/providers.tf @@ -2,7 +2,7 @@ terraform { required_providers { nullplatform = { source = "nullplatform/nullplatform" - version = "~> 0.0.86" + version = "~> 0.0.101" } } } diff --git a/nullplatform/api_key/tests/api_key.tftest.hcl b/nullplatform/api_key/tests/api_key.tftest.hcl index f422ab176..9148f1349 100644 --- a/nullplatform/api_key/tests/api_key.tftest.hcl +++ b/nullplatform/api_key/tests/api_key.tftest.hcl @@ -18,6 +18,19 @@ run "agent_api_key" { } } +run "base_api_key" { + command = plan + + variables { + type = "base" + } + + assert { + condition = nullplatform_api_key.this.name == "BASE" + error_message = "Base API key name should be 'BASE'" + } +} + run "scope_notification_api_key" { command = plan @@ -100,8 +113,8 @@ run "tags_merge_custom_tags" { command = plan variables { - type = "custom" - custom_name = "MY-KEY" + type = "custom" + custom_name = "MY-KEY" custom_role_slugs = ["developer"] custom_tags = [ { key = "team", value = "platform" }, @@ -147,6 +160,35 @@ run "agent_grants_include_controlplane_agent" { } } +run "base_grants_exclude_secrets_reader" { + command = plan + + variables { + type = "base" + } + + # The base chart does not read secrets, so it must not carry secrets-reader. + # Everything else matches the agent key so a single install can swap one for + # the other without losing a capability. + assert { + condition = length(nullplatform_api_key.this.grants) == 4 + error_message = "Base API key should have 4 grants (controlplane:agent, developer, ops, secops)" + } + + assert { + condition = length([for g in nullplatform_api_key.this.grants : g if g.role_slug == "secrets-reader"]) == 0 + error_message = "Base API key must not grant secrets-reader" + } + + assert { + condition = length(setsubtract( + toset([for g in nullplatform_api_key.this.grants : g.role_slug]), + toset(["controlplane:agent", "developer", "ops", "secops"]), + )) == 0 + error_message = "Base API key grants should be exactly the agent roles minus secrets-reader" + } +} + run "custom_grants_explicit_nrn" { command = plan @@ -170,3 +212,30 @@ run "custom_grants_explicit_nrn" { error_message = "Should have grant for organization=myorg:account=myaccount with role developer" } } + +run "internal_reaches_the_api_key" { + command = plan + + variables { + internal = true + } + + assert { + condition = nullplatform_api_key.this.internal == true + error_message = "internal = true should reach the nullplatform_api_key resource" + } +} + +# An explicit false still travels; the module never decides the mark on the caller's behalf. +run "internal_off_is_explicit" { + command = plan + + variables { + internal = false + } + + assert { + condition = nullplatform_api_key.this.internal == false + error_message = "internal = false should reach the nullplatform_api_key resource" + } +} diff --git a/nullplatform/api_key/variables.tf b/nullplatform/api_key/variables.tf index 89de21a09..d6663a6ae 100644 --- a/nullplatform/api_key/variables.tf +++ b/nullplatform/api_key/variables.tf @@ -3,17 +3,17 @@ ################################################################################ variable "type" { - description = "Type of API key to create. Determines the pre-configured grants and tags. Use 'custom' to define your own roles and tags." + description = "Type of API key to create. Determines the pre-configured grants and tags. 'base' carries the agent roles minus secrets-reader, for the nullplatform base module. Use 'custom' to define your own roles and tags." type = string validation { - condition = contains(["agent", "scope_notification", "service_notification", "custom"], var.type) - error_message = "type must be one of: agent, scope_notification, service_notification, custom" + condition = contains(["agent", "base", "scope_notification", "service_notification", "custom"], var.type) + error_message = "type must be one of: agent, base, scope_notification, service_notification, custom" } } variable "nrn" { - description = "Nullplatform Resource Name (e.g., organization=123:account=456:namespace=789). Required for predefined types (agent, scope_notification, service_notification). Optional for custom type when using custom_grants." + description = "Nullplatform Resource Name (e.g., organization=123:account=456:namespace=789). Required for predefined types (agent, base, scope_notification, service_notification). Optional for custom type when using custom_grants." type = string default = null } @@ -57,3 +57,13 @@ variable "custom_tags" { })) default = [] } + +################################################################################ +# Visibility +################################################################################ + +variable "internal" { + description = "Marks the API key as internal to nullplatform, keeping it out of the API key listing (`GET /api_key` and the UI) while it stays readable by ID — for the plumbing credentials this module creates (agents, notification channels) rather than keys a person manages. Create-only in the API, so changing it replaces the key and rotates its secret. Leave unset for the platform default (not internal)." + type = bool + default = null +} diff --git a/nullplatform/asset/docker_server/main.tf b/nullplatform/asset/docker_server/main.tf index f19ac1cdb..9fb2a85b0 100644 --- a/nullplatform/asset/docker_server/main.tf +++ b/nullplatform/asset/docker_server/main.tf @@ -1,7 +1,6 @@ resource "nullplatform_provider_config" "docker_server" { - nrn = var.nrn - type = "docker-server" - dimensions = var.dimensions + nrn = var.nrn + type = "docker-server" attributes = jsonencode({ "setup" : { "server" : var.login_server, diff --git a/nullplatform/asset/docker_server/variables.tf b/nullplatform/asset/docker_server/variables.tf index ad6dfb398..87126a51e 100644 --- a/nullplatform/asset/docker_server/variables.tf +++ b/nullplatform/asset/docker_server/variables.tf @@ -24,9 +24,3 @@ variable "password" { type = string sensitive = false } - -variable "dimensions" { - description = "Dimensions to segment the nullplatform provider config (e.g. by region, environment)" - type = map(string) - default = {} -} diff --git a/nullplatform/asset/ecr/main.tf b/nullplatform/asset/ecr/main.tf index b999a2107..44f375e04 100644 --- a/nullplatform/asset/ecr/main.tf +++ b/nullplatform/asset/ecr/main.tf @@ -12,7 +12,7 @@ resource "nullplatform_provider_config" "ecr" { nrn = var.nrn type = "ecr" attributes = jsonencode({ - ci = { + ci = { region = data.aws_region.current.region access_key = var.build_workflow_access_key_id secret_key = var.build_workflow_access_key_secret diff --git a/nullplatform/asset/ecr/variables.tf b/nullplatform/asset/ecr/variables.tf index 90b542635..1951763ad 100644 --- a/nullplatform/asset/ecr/variables.tf +++ b/nullplatform/asset/ecr/variables.tf @@ -3,12 +3,6 @@ variable "nrn" { type = string } -variable "dimensions" { - description = "Dimensions to segment the nullplatform provider config (e.g. by region, environment)" - type = map(string) - default = {} -} - variable "application_role_arn" { description = "ARN of the IAM role used by applications to pull ECR images" type = string diff --git a/nullplatform/asset/s3/main.tf b/nullplatform/asset/s3/main.tf index 9818d911e..b44d82a26 100644 --- a/nullplatform/asset/s3/main.tf +++ b/nullplatform/asset/s3/main.tf @@ -1,8 +1,7 @@ resource "nullplatform_provider_config" "s3" { - provider = nullplatform - nrn = var.nrn - type = "s3-configuration" - dimensions = var.dimensions + provider = nullplatform + nrn = var.nrn + type = "s3-configuration" attributes = jsonencode({ bucket = { name = var.bucket_name diff --git a/nullplatform/asset/s3/variables.tf b/nullplatform/asset/s3/variables.tf index 527285b5a..3186b8681 100644 --- a/nullplatform/asset/s3/variables.tf +++ b/nullplatform/asset/s3/variables.tf @@ -3,12 +3,6 @@ variable "nrn" { type = string } -variable "dimensions" { - description = "Dimensions to segment the nullplatform provider config (e.g. by region, environment)" - type = map(string) - default = {} -} - variable "bucket_name" { description = "Name of the existing S3 bucket used as the asset repository, where Lambda/bundle assets are published. Maps to the platform's aws.s3_assets_bucket configuration." type = string diff --git a/nullplatform/base/locals.tf b/nullplatform/base/locals.tf index 74ccbf7ff..2f09bef29 100644 --- a/nullplatform/base/locals.tf +++ b/nullplatform/base/locals.tf @@ -5,6 +5,7 @@ locals { # ---- global ---- k8s_provider = var.k8s_provider installGatewayV2Crd = var.install_gateway_v2_crd ? "true" : "false" + gatewayApiCrdRef = var.gateway_api_crd_ref awsRegion = var.aws_region # ---- tls ---- @@ -58,12 +59,16 @@ locals { # You left secretName empty in the template; if you want to make it configurable, add var.nullplatform_secret_name # ---- controlPlane ---- - controlPlane_enabled = var.control_plane_enabled ? "true" : "false" + controlPlane_enabled = var.control_plane_enabled ? "true" : "false" + control_plane_agent_image_repository = var.control_plane_agent_image_repository + control_plane_agent_image_tag = var.control_plane_agent_image_tag # ---- logging ---- - logging_enabled = var.logging_enabled ? "true" : "false" - logging_application_logs_enabled = var.logging_application_logs_enabled ? "true" : "false" - logging_mount_docker_containers = var.logging_mount_docker_containers ? "true" : "false" + logging_enabled = var.logging_enabled ? "true" : "false" + logging_application_logs_enabled = var.logging_application_logs_enabled ? "true" : "false" + logging_mount_docker_containers = var.logging_mount_docker_containers ? "true" : "false" + logging_controller_image_repository = var.logging_controller_image_repository + logging_controller_image_tag = var.logging_controller_image_tag prometheus_enabled = var.prometheus_enabled ? "true" : "false" exporter_prometheus_port = var.exporter_prometheus_port diff --git a/nullplatform/base/templates/nullplatform_base_values.tmpl.yaml b/nullplatform/base/templates/nullplatform_base_values.tmpl.yaml index 852d4c845..0e798ffeb 100644 --- a/nullplatform/base/templates/nullplatform_base_values.tmpl.yaml +++ b/nullplatform/base/templates/nullplatform_base_values.tmpl.yaml @@ -1,6 +1,7 @@ # Global settings global: installGatewayV2Crd: ${installGatewayV2Crd} + gatewayApiCrdRef: "${gatewayApiCrdRef}" # Kubernetes provider (options: "oke", "gke", "eks", "aks", "aro") provider: "${k8s_provider}" # AWS region (applicable for EKS provider) @@ -82,7 +83,7 @@ nullplatform: controlPlane: enabled: ${controlPlane_enabled} agent: - image: "public.ecr.aws/nullplatform/controlplane-agent:latest" + image: "${control_plane_agent_image_repository}:${control_plane_agent_image_tag}" resources: requests: cpu: 50m @@ -96,7 +97,7 @@ logging: enabled: ${logging_application_logs_enabled} mountDockerContainers: ${logging_mount_docker_containers} controller: - image: "public.ecr.aws/nullplatform/k8s-logs-controller:latest" + image: "${logging_controller_image_repository}:${logging_controller_image_tag}" resources: requests: cpu: 100m diff --git a/nullplatform/base/tests/base_values.tftest.hcl b/nullplatform/base/tests/base_values.tftest.hcl index fb129d113..ce537d6a5 100644 --- a/nullplatform/base/tests/base_values.tftest.hcl +++ b/nullplatform/base/tests/base_values.tftest.hcl @@ -2,8 +2,59 @@ mock_provider "helm" {} mock_provider "kubernetes" {} variables { - np_api_key = "test-api-key" - k8s_provider = "eks" + np_api_key = "test-api-key" + k8s_provider = "eks" + nullplatform_base_helm_version = "2.44.0" + logging_controller_image_tag = "1.6.0" + control_plane_agent_image_tag = "0.9.2" +} + +############################################ +# Gateway API CRD ref +############################################ + +run "gateway_api_crd_ref_defaults_to_istio_1_27_ref" { + command = plan + + assert { + condition = strcontains(output.rendered_values, "gatewayApiCrdRef: \"v1.3.0\"") + error_message = "gatewayApiCrdRef should default to v1.3.0, matching Istio 1.27" + } +} + +run "gateway_api_crd_ref_override" { + command = plan + + variables { + gateway_api_crd_ref = "v1.6.0" + } + + assert { + condition = strcontains(output.rendered_values, "gatewayApiCrdRef: \"v1.6.0\"") + error_message = "gatewayApiCrdRef should reflect the overridden ref" + } +} + +run "install_gateway_v2_crd_defaults_to_true" { + command = plan + + assert { + condition = strcontains(output.rendered_values, "installGatewayV2Crd: true") + error_message = "install_gateway_v2_crd should default to true so CRDs actually reconcile to gateway_api_crd_ref" + } +} + +run "install_gateway_v2_crd_override" { + command = plan + + variables { + install_gateway_v2_crd = false + } + + assert { + condition = strcontains(output.rendered_values, "installGatewayV2Crd: false") + error_message = "install_gateway_v2_crd should still be overridable to false" + } } ############################################ @@ -144,10 +195,10 @@ run "dynatrace_logs_disabled" { command = plan variables { - dynatrace_enabled = true - dynatrace_api_key = "dt-test-key" + dynatrace_enabled = true + dynatrace_api_key = "dt-test-key" dynatrace_environment_id = "dt-env-123" - dynatrace_logs_enabled = false + dynatrace_logs_enabled = false } assert { @@ -310,3 +361,66 @@ run "internal_azure_load_balancer_subnet" { error_message = "internal gateway azure subnet should be wired into the rendered internal block" } } + +############################################ +# Container image repository/tag overrides +############################################ + +run "logs_controller_image_defaults_to_pinned_tag" { + command = plan + + assert { + condition = strcontains(output.rendered_values, "image: \"public.ecr.aws/nullplatform/k8s-logs-controller:1.6.0\"") + error_message = "logs controller image should default to the pinned repository:tag" + } +} + +run "logs_controller_image_tag_overridden" { + command = plan + + variables { + logging_controller_image_tag = "1.7.0" + } + + assert { + condition = strcontains(output.rendered_values, "image: \"public.ecr.aws/nullplatform/k8s-logs-controller:1.7.0\"") + error_message = "logs controller tag should be overridable without touching the repository" + } +} + +run "logs_controller_image_repository_overridden" { + command = plan + + # Redirect to a private mirror/ECR pull-through cache without needing to also + # know or restate the tag. + variables { + logging_controller_image_repository = "123456789012.dkr.ecr.us-east-1.amazonaws.com/k8s-logs-controller" + } + + assert { + condition = strcontains(output.rendered_values, "image: \"123456789012.dkr.ecr.us-east-1.amazonaws.com/k8s-logs-controller:1.6.0\"") + error_message = "logs controller repository should be overridable without touching the tag" + } +} + +run "control_plane_agent_image_defaults_to_pinned_tag" { + command = plan + + assert { + condition = strcontains(output.rendered_values, "image: \"public.ecr.aws/nullplatform/controlplane-agent:0.9.2\"") + error_message = "control plane agent image should default to the pinned repository:tag" + } +} + +run "control_plane_agent_image_tag_overridden" { + command = plan + + variables { + control_plane_agent_image_tag = "0.9.3" + } + + assert { + condition = strcontains(output.rendered_values, "image: \"public.ecr.aws/nullplatform/controlplane-agent:0.9.3\"") + error_message = "control plane agent tag should be overridable without touching the repository" + } +} diff --git a/nullplatform/base/variables.tf b/nullplatform/base/variables.tf index 599de86db..7ff11a292 100644 --- a/nullplatform/base/variables.tf +++ b/nullplatform/base/variables.tf @@ -1,7 +1,12 @@ variable "nullplatform_base_helm_version" { - description = "Helm chart version for the nullplatform base." + # example: 2.44.0 + description = "No default: every install pins this deliberately — see VERSIONS.md. Helm chart version for the nullplatform base." type = string - default = "2.40.0" + + validation { + condition = var.nullplatform_base_helm_version != "" && !contains(["latest", "main", "master"], lower(var.nullplatform_base_helm_version)) + error_message = "nullplatform_base_helm_version must be a non-empty fixed version, not empty and not a moving reference." + } } variable "namespace" { @@ -33,8 +38,14 @@ variable "aws_region" { variable "install_gateway_v2_crd" { type = bool - description = "Install Gateway API v2 CRDs." - default = false + description = "Install/reconcile the Gateway API CRDs (see gateway_api_crd_ref) via the base chart's pre-install/pre-upgrade Job. Defaults to true so CRDs actually track gateway_api_crd_ref instead of staying frozen at whatever was present on first install — matches the base chart's own default. Safe on chart versions before global.gatewayApiCrdRef too: those only install when the CRD is missing, so pre-existing CRDs from another source are left untouched." + default = true +} + +variable "gateway_api_crd_ref" { + type = string + description = "Git ref (tag or commit) of kubernetes-sigs/gateway-api to install when install_gateway_v2_crd is true. Ignored on chart versions older than the one that introduced global.gatewayApiCrdRef. Default (v1.3.0) matches what Istio 1.27 documents installing; re-check istio.io's version-pinned docs when bumping Istio." + default = "v1.3.0" } ############################################ @@ -124,6 +135,23 @@ variable "control_plane_enabled" { default = false } +variable "control_plane_agent_image_repository" { + type = string + description = "Container image repository for the control plane agent." + default = "public.ecr.aws/nullplatform/controlplane-agent" +} + +variable "control_plane_agent_image_tag" { + # example: 0.9.2 + type = string + description = "No default: every install pins this deliberately — see VERSIONS.md. Container image tag for the control plane agent." + + validation { + condition = var.control_plane_agent_image_tag != "" && !contains(["latest", "main", "master"], lower(var.control_plane_agent_image_tag)) + error_message = "control_plane_agent_image_tag must be a non-empty fixed version, not empty and not a moving reference." + } +} + ############################################ # Logging (global flag) ############################################ @@ -146,6 +174,23 @@ variable "logging_mount_docker_containers" { default = false } +variable "logging_controller_image_repository" { + type = string + description = "Container image repository for the logs controller DaemonSet." + default = "public.ecr.aws/nullplatform/k8s-logs-controller" +} + +variable "logging_controller_image_tag" { + # example: 1.6.0 + type = string + description = "No default: every install pins this deliberately — see VERSIONS.md. Container image tag for the logs controller DaemonSet." + + validation { + condition = var.logging_controller_image_tag != "" && !contains(["latest", "main", "master"], lower(var.logging_controller_image_tag)) + error_message = "logging_controller_image_tag must be a non-empty fixed version, not empty and not a moving reference." + } +} + ############################################ # Prometheus Exporter ############################################ diff --git a/nullplatform/container_orchestration/aks/main.tf b/nullplatform/container_orchestration/aks/main.tf index fc12c7ac7..16c4d4240 100644 --- a/nullplatform/container_orchestration/aks/main.tf +++ b/nullplatform/container_orchestration/aks/main.tf @@ -35,7 +35,7 @@ locals { }, length(local.resource_management) > 0 ? { resource_management = local.resource_management } : {}, length(local.security) > 0 ? { security = local.security } : {}, - var.traffic_manager_version != "" ? { traffic_manager = { version = var.traffic_manager_version } } : {}, + { traffic_manager = { version = var.traffic_manager_version } }, length(var.object_modifiers) > 0 ? { object_modifiers = { modifiers = var.object_modifiers } } : {}, ) } diff --git a/nullplatform/container_orchestration/aks/tests/aks.tftest.hcl b/nullplatform/container_orchestration/aks/tests/aks.tftest.hcl index d8d4688ca..f27817908 100644 --- a/nullplatform/container_orchestration/aks/tests/aks.tftest.hcl +++ b/nullplatform/container_orchestration/aks/tests/aks.tftest.hcl @@ -1,10 +1,11 @@ mock_provider "nullplatform" {} variables { - nrn = "organization=myorg:account=myaccount" - cluster_name = "my-aks-cluster" - resource_group = "my-aks-resource-group" - public_gateway_name = "istio-ingress" + nrn = "organization=myorg:account=myaccount" + cluster_name = "my-aks-cluster" + resource_group = "my-aks-resource-group" + public_gateway_name = "istio-ingress" + traffic_manager_version = "1.8.0" } run "aks_provider_type" { @@ -77,10 +78,6 @@ run "optional_fields_excluded_when_empty" { error_message = "Attributes should not contain security when not set" } - assert { - condition = !strcontains(nullplatform_provider_config.aks_config.attributes, "traffic_manager") - error_message = "Attributes should not contain traffic_manager when not set" - } assert { condition = !strcontains(nullplatform_provider_config.aks_config.attributes, "object_modifiers") @@ -227,7 +224,7 @@ run "with_traffic_manager" { command = plan variables { - traffic_manager_version = "latest" + traffic_manager_version = "1.8.0" } assert { @@ -236,7 +233,7 @@ run "with_traffic_manager" { } assert { - condition = strcontains(nullplatform_provider_config.aks_config.attributes, "latest") + condition = strcontains(nullplatform_provider_config.aks_config.attributes, "1.8.0") error_message = "Attributes should contain traffic manager version" } } diff --git a/nullplatform/container_orchestration/aks/variables.tf b/nullplatform/container_orchestration/aks/variables.tf index 649910cc9..211e0d862 100644 --- a/nullplatform/container_orchestration/aks/variables.tf +++ b/nullplatform/container_orchestration/aks/variables.tf @@ -85,9 +85,14 @@ variable "service_account_name" { } variable "traffic_manager_version" { - description = "Tag for the traffic manager sidecar container" + # example: 1.8.0 + description = "No default: every install pins this deliberately — see VERSIONS.md. Tag for the traffic manager sidecar container" type = string - default = "" + + validation { + condition = var.traffic_manager_version != "" && !contains(["latest", "main", "master"], lower(var.traffic_manager_version)) + error_message = "traffic_manager_version must be a non-empty fixed version, not empty and not a moving reference." + } } variable "object_modifiers" { diff --git a/nullplatform/container_orchestration/eks/main.tf b/nullplatform/container_orchestration/eks/main.tf index 6a3842cac..cb3747b30 100644 --- a/nullplatform/container_orchestration/eks/main.tf +++ b/nullplatform/container_orchestration/eks/main.tf @@ -33,6 +33,11 @@ locals { var.service_account_name != "" ? { service_account_name = var.service_account_name } : {}, ) + traffic_manager = merge( + var.traffic_manager_version != "" ? { version = var.traffic_manager_version } : {}, + var.traffic_manager_port != null ? { port = var.traffic_manager_port } : {}, + ) + attributes = merge( { cluster = local.cluster @@ -41,7 +46,7 @@ locals { length(local.network) > 0 ? { network = local.network } : {}, length(local.resource_management) > 0 ? { resource_management = local.resource_management } : {}, length(local.security) > 0 ? { security = local.security } : {}, - var.traffic_manager_version != "" ? { traffic_manager = { version = var.traffic_manager_version } } : {}, + length(local.traffic_manager) > 0 ? { traffic_manager = local.traffic_manager } : {}, length(var.object_modifiers) > 0 ? { object_modifiers = { modifiers = var.object_modifiers } } : {}, ) } diff --git a/nullplatform/container_orchestration/eks/tests/eks.tftest.hcl b/nullplatform/container_orchestration/eks/tests/eks.tftest.hcl index 681d1b40f..864610593 100644 --- a/nullplatform/container_orchestration/eks/tests/eks.tftest.hcl +++ b/nullplatform/container_orchestration/eks/tests/eks.tftest.hcl @@ -1,8 +1,9 @@ mock_provider "nullplatform" {} variables { - nrn = "organization=myorg:account=myaccount" - cluster_name = "my-eks-cluster" + nrn = "organization=myorg:account=myaccount" + cluster_name = "my-eks-cluster" + traffic_manager_version = "1.8.0" } run "eks_provider_type" { @@ -184,7 +185,7 @@ run "with_traffic_manager" { command = plan variables { - traffic_manager_version = "latest" + traffic_manager_version = "1.8.0" } assert { @@ -193,9 +194,32 @@ run "with_traffic_manager" { } assert { - condition = strcontains(nullplatform_provider_config.eks_config.attributes, "latest") + condition = strcontains(nullplatform_provider_config.eks_config.attributes, "1.8.0") error_message = "Attributes should contain traffic manager version" } + + assert { + condition = !strcontains(nullplatform_provider_config.eks_config.attributes, "\"port\"") + error_message = "Attributes should not contain traffic manager port when not set" + } +} + +run "with_traffic_manager_port" { + command = plan + + variables { + traffic_manager_port = 10080 + } + + assert { + condition = strcontains(nullplatform_provider_config.eks_config.attributes, "\"port\":10080") + error_message = "Attributes should contain the traffic manager port" + } + + assert { + condition = strcontains(nullplatform_provider_config.eks_config.attributes, "\"version\":\"1.8.0\"") + error_message = "Setting the port must not drop the traffic manager version" + } } run "with_object_modifiers" { @@ -319,3 +343,24 @@ run "with_all_options" { error_message = "Dimensions should contain Environment=staging" } } + +################################################################################ +# Version pinning +################################################################################ + +# The default used to be "latest", so an apply with no code change could move the deployed +# version. There is no default now -- the caller has to name one -- and this is the guard +# that a moving reference cannot reach the provider config by any route. +run "no_moving_reference_reaches_the_provider_config" { + command = plan + + assert { + condition = !strcontains(nullplatform_provider_config.eks_config.attributes, "latest") + error_message = "no attribute may reference a moving tag" + } + + assert { + condition = strcontains(nullplatform_provider_config.eks_config.attributes, "\"version\":\"1.8.0\"") + error_message = "the version the caller supplied must reach the provider config" + } +} diff --git a/nullplatform/container_orchestration/eks/variables.tf b/nullplatform/container_orchestration/eks/variables.tf index 61934b2c7..a85f0b506 100644 --- a/nullplatform/container_orchestration/eks/variables.tf +++ b/nullplatform/container_orchestration/eks/variables.tf @@ -112,9 +112,25 @@ variable "service_account_name" { } variable "traffic_manager_version" { - description = "Tag for the traffic manager sidecar container" + # example: 1.8.0 + description = "No default: every install pins this deliberately — see VERSIONS.md. Pinned rather than tracking latest: a moving tag means a pod restart can pull a different build with no apply in between. Tag for the traffic manager sidecar container" type = string - default = "latest" + + validation { + condition = var.traffic_manager_version != "" && !contains(["latest", "main", "master"], lower(var.traffic_manager_version)) + error_message = "traffic_manager_version must be a non-empty fixed version, not empty and not a moving reference." + } +} + +variable "traffic_manager_port" { + description = "Port the traffic manager sidecar binds inside the pod. Defaults to 80 when unset. Set a different port (10080 recommended) when the cluster does not allow pod-to-pod traffic on port 80, which surfaces as a healthy pod that receives no traffic because kubelet probes are node-local and bypass the filtering. Open the port for pod-to-pod traffic before setting this value" + type = number + default = null + nullable = true + validation { + condition = var.traffic_manager_port == null || (var.traffic_manager_port >= 1 && var.traffic_manager_port <= 65535) + error_message = "traffic_manager_port must be between 1 and 65535." + } } variable "object_modifiers" { diff --git a/nullplatform/container_orchestration/gke/main.tf b/nullplatform/container_orchestration/gke/main.tf index e7a5b1930..132de9f29 100644 --- a/nullplatform/container_orchestration/gke/main.tf +++ b/nullplatform/container_orchestration/gke/main.tf @@ -30,7 +30,7 @@ locals { }, length(local.resource_management) > 0 ? { resource_management = local.resource_management } : {}, length(local.security) > 0 ? { security = local.security } : {}, - var.traffic_manager_version != "" ? { traffic_manager = { version = var.traffic_manager_version } } : {}, + { traffic_manager = { version = var.traffic_manager_version } }, length(var.object_modifiers) > 0 ? { object_modifiers = { modifiers = var.object_modifiers } } : {}, ) } diff --git a/nullplatform/container_orchestration/gke/tests/gke.tftest.hcl b/nullplatform/container_orchestration/gke/tests/gke.tftest.hcl index f7b24a7d2..028080b9f 100644 --- a/nullplatform/container_orchestration/gke/tests/gke.tftest.hcl +++ b/nullplatform/container_orchestration/gke/tests/gke.tftest.hcl @@ -1,10 +1,11 @@ mock_provider "nullplatform" {} variables { - nrn = "organization=myorg:account=myaccount" - cluster_name = "my-gke-cluster" - location = "us-central1-a" - public_gateway_name = "public-gateway" + nrn = "organization=myorg:account=myaccount" + cluster_name = "my-gke-cluster" + location = "us-central1-a" + public_gateway_name = "public-gateway" + traffic_manager_version = "1.8.0" } run "gke_provider_type" { @@ -72,10 +73,6 @@ run "optional_fields_excluded_when_empty" { error_message = "Attributes should not contain security when not set" } - assert { - condition = !strcontains(nullplatform_provider_config.gke_config.attributes, "traffic_manager") - error_message = "Attributes should not contain traffic_manager when not set" - } assert { condition = !strcontains(nullplatform_provider_config.gke_config.attributes, "object_modifiers") @@ -150,7 +147,7 @@ run "with_traffic_manager" { command = plan variables { - traffic_manager_version = "latest" + traffic_manager_version = "1.8.0" } assert { @@ -159,7 +156,7 @@ run "with_traffic_manager" { } assert { - condition = strcontains(nullplatform_provider_config.gke_config.attributes, "latest") + condition = strcontains(nullplatform_provider_config.gke_config.attributes, "1.8.0") error_message = "Attributes should contain traffic manager version" } } diff --git a/nullplatform/container_orchestration/gke/variables.tf b/nullplatform/container_orchestration/gke/variables.tf index dbd0d72cd..4f51bfb28 100644 --- a/nullplatform/container_orchestration/gke/variables.tf +++ b/nullplatform/container_orchestration/gke/variables.tf @@ -79,9 +79,14 @@ variable "service_account_name" { } variable "traffic_manager_version" { - description = "Tag for the traffic manager sidecar container" + # example: 1.8.0 + description = "No default: every install pins this deliberately — see VERSIONS.md. Tag for the traffic manager sidecar container" type = string - default = "" + + validation { + condition = var.traffic_manager_version != "" && !contains(["latest", "main", "master"], lower(var.traffic_manager_version)) + error_message = "traffic_manager_version must be a non-empty fixed version, not empty and not a moving reference." + } } variable "object_modifiers" { diff --git a/nullplatform/scope_definition/.terraform.lock.hcl b/nullplatform/scope_definition/.terraform.lock.hcl index 4d44513cb..b5f0882de 100644 --- a/nullplatform/scope_definition/.terraform.lock.hcl +++ b/nullplatform/scope_definition/.terraform.lock.hcl @@ -55,25 +55,25 @@ provider "registry.opentofu.org/hashicorp/null" { } provider "registry.opentofu.org/nullplatform/nullplatform" { - version = "0.0.95" - constraints = "~> 0.0.86" + version = "0.0.99" + constraints = ">= 0.0.99" hashes = [ - "h1:CwWF67wR7+i4kIqtQ6t6wDwJTCnnfwbK60B5zH2xMCo=", - "h1:TOKlvQhgsNAlXDVB9jNfTUTMIOgxgMJgHsr7AqTVtdA=", - "h1:UYs+ehJD8m3YGSb1vZUfFbYBHBRBj/Kn15Lp6OK2M5Y=", - "zh:02ec1e02e738e5f138e919d17391966e4b92d6933b6cd318eaed85e908f5cc8c", - "zh:1b2015fa088a40c4dcaef803d71942909ec45036524c12ca18272bf37dbf9283", - "zh:213f5ab10cc4c95e4568fdd10fec55886615978617b5815c9a900f233b4dca6e", - "zh:2a0ae4273515079385926beedeaf034257c4ee08e78733e19676fac9db4fd8d1", - "zh:356c2f91085ac397c419650a55c2ec8551da811db503a3dc0e510e05ca8fba42", - "zh:40bbd8163228b1ae88d515c643922c0565b015559b01023bc2ababdb3ad13e5c", - "zh:42ab71a0675b06fe3f661704142be8ddb029f052e3a001115fbb3ad0ee5e97f4", - "zh:46c6130dd7688a372a244c388f8568ddd12512e6d70d16b924495a4088f5ed4a", - "zh:5d862aa4ffa4f452a03e9a8ad3922a25674aff0a9770b674e09a68a087db17bb", - "zh:94cfe1a3446c4ea48d16b10c0b50f99bee89218f0a054514a97215c3af43c427", - "zh:aef3d42714928ca4099768efdaaf0d6eedab717d2b133ccb5de67167dfc24332", - "zh:e28d2e0a9b297c84dd18f1ab0a4f986108d2e1571523b15389473be4c267e917", - "zh:f1dd95be9f23c5602fcfca965832984f6d4411258c4ea87f69177a4604a5f39a", + "h1:Nw3pJ81u3FB10ODdT0sOIJxudlff9dJJd4ByYaAp6Eg=", + "h1:ecet/9nXRW2p+GlDwxrCRcpctrW783+BmRIA8zZ8poA=", + "h1:uQnumjTqtTYMEKQu8zS6oZLkLQA2VWaIXUlAA0hvfRQ=", + "zh:051c1090c1c974e08a9d2afcce06526a0e885946dfc313c9fca5b78686e847bd", + "zh:0a616dfe25c18dfdb53587b1513e2a74045eb4f3ae8b95972fb59dbba02837fa", + "zh:0f35146746ac00e489dae197d60001850d95cc4f6ffd69a7a043e1d7e7ccf0fe", + "zh:10fd7c9da8e03e84c036d0a6a42e638038e3b8109b3043e39d1a1a513fd869ee", + "zh:478b1f565686c2980f9852fc17140836c617bdb165c506d7eb31f49279e6bfdf", + "zh:59678c1ba91ec5745d864a1f740ab4bafa910d1f2f567031f04dc1bc4aabd8ec", + "zh:5fa4085fe12204f89800cfe8f83ec32722f734f15f687a12540158d84c805304", + "zh:889add641aa0bebf3170ff09cadda08bbf36b779a932e97f7c191d2b49dc9bd7", + "zh:9149e36721d58ec27656fa9a622f0f8a5fa36dceed5863e8b19e8f63c5e9d324", + "zh:b3a6407edf0afc8aad5a5ec34e0da14e35beb0667e44ec6178763b3bf176bfd7", + "zh:b8f216e27bfcb63dcb64e0e7459a2ecc80b3dd28a79f953bffc689ab252b56cb", + "zh:ba1c8cf16220cebf95d9a1515ddec2cceba93cb2c8c5eb8dfc143d5bab691cf5", + "zh:e0f8caf72b5974c8e849b57f3bcce2c2ffbbabe9829328915824ab34501f870e", "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", ] } diff --git a/nullplatform/scope_definition/package.tf b/nullplatform/scope_definition/package.tf index c8509255f..5cd9ef9ff 100644 --- a/nullplatform/scope_definition/package.tf +++ b/nullplatform/scope_definition/package.tf @@ -14,16 +14,29 @@ locals { package_enabled = var.package != null + # oci_image is the only artifact type where "registry"/"repository" mean + # anything — other types (git_repository, blob, oras_artifact) have an + # unrelated meta shape, so this default never applies to them. Overridable + # per implementing module via var.package_oci_default_registry/repository. + package_oci_meta_defaults = { + registry = var.package_oci_default_registry + repository = var.package_oci_default_repository + } + # Artifacts split by intent: # create — `meta` given, lookup=false: register a new revision here # lookup — `meta` given, lookup=true: resolve an existing artifact by identity # pinned — explicit resource ids, taken as-is package_artifacts_to_create = local.package_enabled ? { - for a in var.package.artifacts : a.name => a if a.meta != null && !a.lookup + for a in var.package.artifacts : a.name => merge(a, { + meta = a.type == "oci_image" ? merge(local.package_oci_meta_defaults, a.meta) : a.meta + }) if a.meta != null && !a.lookup } : {} package_artifacts_to_lookup = local.package_enabled ? { - for a in var.package.artifacts : a.name => a if a.meta != null && a.lookup + for a in var.package.artifacts : a.name => merge(a, { + meta = a.type == "oci_image" ? merge(local.package_oci_meta_defaults, a.meta) : a.meta + }) if a.meta != null && a.lookup } : {} package_artifacts_existing = local.package_enabled ? { @@ -44,8 +57,11 @@ resource "nullplatform_artifact" "package" { } # Existing artifacts resolved by identity — no ids in your configuration. -# Identity meta (e.g. registry+repository) selects the artifact; include -# per-revision fields (digest/reference) to pin a revision, else latest wins. +# Identity meta (e.g. registry+repository for oci_image, url for +# git_repository) selects the artifact; include the type's own per-revision +# field to pin a revision — digest ("sha256:<64-hex>") for oci_image, +# reference (e.g. a tag) for git_repository; the API rejects the other +# type's field name — else latest wins. data "nullplatform_artifact" "package" { for_each = local.package_artifacts_to_lookup diff --git a/nullplatform/scope_definition/variables.tf b/nullplatform/scope_definition/variables.tf index 11e264692..7a4ba524a 100644 --- a/nullplatform/scope_definition/variables.tf +++ b/nullplatform/scope_definition/variables.tf @@ -189,21 +189,32 @@ variable "package" { { registry = "ghcr.io", repository = "acme/img", digest = "sha256:…" }); • look up one registered elsewhere BY IDENTITY (no ids needed) — set `lookup = true` + `meta` with the identity fields (e.g. registry + - repository; add digest/reference to pin a specific revision, otherwise - the latest revision is used); + repository for oci_image, or url for git_repository); add the + type's own per-revision field to pin a specific revision (digest, + formatted "sha256:<64-hex>", for oci_image; reference, e.g. a tag, + for git_repository — the API rejects the other type's field name), + otherwise the latest revision is used; • pin explicit ids — set `resource_id` + `resource_revision_id`. + For an "oci_image" artifact (the default type), `name` defaults to + "worker-image" and meta.registry/meta.repository default to + var.package_oci_default_registry/var.package_oci_default_repository — + the platform's own container-scope worker image — when omitted from + `meta`. Only meta.digest needs setting on every release; every other + artifact type gets no meta defaults (their meta shape is unrelated to a + container registry). + Null (the default) keeps the classic module behavior — no package. EOT type = object({ - slug = optional(string) # default: the service specification slug - name = optional(string) # default: var.service_spec_name - version = string # semver of the revision this configuration publishes - default = optional(bool, true) # promote each published revision to the package default - tags = optional(map(string), {}) # release tags: name => version (requires an API with the package release-tag routes) - visible_to = optional(list(string)) # default: [var.nrn] + slug = optional(string) # default: the service specification slug + name = optional(string) # default: var.service_spec_name + version = string # semver of the revision this configuration publishes + default = optional(bool, true) # promote each published revision to the package default + tags = optional(map(string), {}) # release tags: name => version (requires an API with the package release-tag routes) + visible_to = optional(list(string)) # default: [var.nrn] artifacts = optional(list(object({ - name = string + name = optional(string, "worker-image") type = optional(string, "oci_image") # oci_image | oras_artifact | git_repository | blob meta = optional(any) # register (lookup=false) or find (lookup=true) lookup = optional(bool, false) # true: resolve an EXISTING artifact by meta identity @@ -228,3 +239,15 @@ variable "package" { error_message = "`lookup = true` requires `meta` with the identity fields of the existing artifact." } } + +variable "package_oci_default_registry" { + description = "Default meta.registry for an oci_image package artifact whose own meta omits it. See var.package's artifacts docs." + type = string + default = "public.ecr.aws" +} + +variable "package_oci_default_repository" { + description = "Default meta.repository for an oci_image package artifact whose own meta omits it — the platform's own container-scope worker image. See var.package's artifacts docs." + type = string + default = "nullplatform/scopes/containers" +} diff --git a/nullplatform/scope_definition_agent_association/.terraform.lock.hcl b/nullplatform/scope_definition_agent_association/.terraform.lock.hcl index a5513ea3d..9a488d03c 100644 --- a/nullplatform/scope_definition_agent_association/.terraform.lock.hcl +++ b/nullplatform/scope_definition_agent_association/.terraform.lock.hcl @@ -36,25 +36,25 @@ provider "registry.opentofu.org/hashicorp/http" { } provider "registry.opentofu.org/nullplatform/nullplatform" { - version = "0.0.95" - constraints = "~> 0.0.86" + version = "0.0.99" + constraints = ">= 0.0.99" hashes = [ - "h1:CwWF67wR7+i4kIqtQ6t6wDwJTCnnfwbK60B5zH2xMCo=", - "h1:TOKlvQhgsNAlXDVB9jNfTUTMIOgxgMJgHsr7AqTVtdA=", - "h1:UYs+ehJD8m3YGSb1vZUfFbYBHBRBj/Kn15Lp6OK2M5Y=", - "zh:02ec1e02e738e5f138e919d17391966e4b92d6933b6cd318eaed85e908f5cc8c", - "zh:1b2015fa088a40c4dcaef803d71942909ec45036524c12ca18272bf37dbf9283", - "zh:213f5ab10cc4c95e4568fdd10fec55886615978617b5815c9a900f233b4dca6e", - "zh:2a0ae4273515079385926beedeaf034257c4ee08e78733e19676fac9db4fd8d1", - "zh:356c2f91085ac397c419650a55c2ec8551da811db503a3dc0e510e05ca8fba42", - "zh:40bbd8163228b1ae88d515c643922c0565b015559b01023bc2ababdb3ad13e5c", - "zh:42ab71a0675b06fe3f661704142be8ddb029f052e3a001115fbb3ad0ee5e97f4", - "zh:46c6130dd7688a372a244c388f8568ddd12512e6d70d16b924495a4088f5ed4a", - "zh:5d862aa4ffa4f452a03e9a8ad3922a25674aff0a9770b674e09a68a087db17bb", - "zh:94cfe1a3446c4ea48d16b10c0b50f99bee89218f0a054514a97215c3af43c427", - "zh:aef3d42714928ca4099768efdaaf0d6eedab717d2b133ccb5de67167dfc24332", - "zh:e28d2e0a9b297c84dd18f1ab0a4f986108d2e1571523b15389473be4c267e917", - "zh:f1dd95be9f23c5602fcfca965832984f6d4411258c4ea87f69177a4604a5f39a", + "h1:Nw3pJ81u3FB10ODdT0sOIJxudlff9dJJd4ByYaAp6Eg=", + "h1:ecet/9nXRW2p+GlDwxrCRcpctrW783+BmRIA8zZ8poA=", + "h1:uQnumjTqtTYMEKQu8zS6oZLkLQA2VWaIXUlAA0hvfRQ=", + "zh:051c1090c1c974e08a9d2afcce06526a0e885946dfc313c9fca5b78686e847bd", + "zh:0a616dfe25c18dfdb53587b1513e2a74045eb4f3ae8b95972fb59dbba02837fa", + "zh:0f35146746ac00e489dae197d60001850d95cc4f6ffd69a7a043e1d7e7ccf0fe", + "zh:10fd7c9da8e03e84c036d0a6a42e638038e3b8109b3043e39d1a1a513fd869ee", + "zh:478b1f565686c2980f9852fc17140836c617bdb165c506d7eb31f49279e6bfdf", + "zh:59678c1ba91ec5745d864a1f740ab4bafa910d1f2f567031f04dc1bc4aabd8ec", + "zh:5fa4085fe12204f89800cfe8f83ec32722f734f15f687a12540158d84c805304", + "zh:889add641aa0bebf3170ff09cadda08bbf36b779a932e97f7c191d2b49dc9bd7", + "zh:9149e36721d58ec27656fa9a622f0f8a5fa36dceed5863e8b19e8f63c5e9d324", + "zh:b3a6407edf0afc8aad5a5ec34e0da14e35beb0667e44ec6178763b3bf176bfd7", + "zh:b8f216e27bfcb63dcb64e0e7459a2ecc80b3dd28a79f953bffc689ab252b56cb", + "zh:ba1c8cf16220cebf95d9a1515ddec2cceba93cb2c8c5eb8dfc143d5bab691cf5", + "zh:e0f8caf72b5974c8e849b57f3bcce2c2ffbbabe9829328915824ab34501f870e", "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", ] } diff --git a/nullplatform/scope_definition_agent_association/variables.tf b/nullplatform/scope_definition_agent_association/variables.tf index 405ab6487..5bc743f18 100644 --- a/nullplatform/scope_definition_agent_association/variables.tf +++ b/nullplatform/scope_definition_agent_association/variables.tf @@ -113,7 +113,7 @@ variable "tags_selectors" { variable "description" { description = "Description shown for the notification channel." type = string - default = "" + default = "Routes Containers deployments agent" } variable "extra_filters" { diff --git a/nullplatform/service_definition/package.tf b/nullplatform/service_definition/package.tf index 036e34fa5..af98998e7 100644 --- a/nullplatform/service_definition/package.tf +++ b/nullplatform/service_definition/package.tf @@ -19,16 +19,29 @@ locals { package_enabled = var.package != null + # oci_image is the only artifact type where "registry"/"repository" mean + # anything — other types (git_repository, blob, oras_artifact) have an + # unrelated meta shape, so this default never applies to them. Overridable + # per implementing module via var.package_oci_default_registry/repository. + package_oci_meta_defaults = { + registry = var.package_oci_default_registry + repository = var.package_oci_default_repository + } + # Artifacts split by intent: # create — `meta` given, lookup=false: register a new revision here # lookup — `meta` given, lookup=true: resolve an existing artifact by identity # pinned — explicit resource ids, taken as-is package_artifacts_to_create = local.package_enabled ? { - for a in var.package.artifacts : a.name => a if a.meta != null && !a.lookup + for a in var.package.artifacts : a.name => merge(a, { + meta = a.type == "oci_image" ? merge(local.package_oci_meta_defaults, a.meta) : a.meta + }) if a.meta != null && !a.lookup } : {} package_artifacts_to_lookup = local.package_enabled ? { - for a in var.package.artifacts : a.name => a if a.meta != null && a.lookup + for a in var.package.artifacts : a.name => merge(a, { + meta = a.type == "oci_image" ? merge(local.package_oci_meta_defaults, a.meta) : a.meta + }) if a.meta != null && a.lookup } : {} package_artifacts_existing = local.package_enabled ? { @@ -88,8 +101,11 @@ resource "nullplatform_artifact" "package" { } # Existing artifacts resolved by identity — no ids in your configuration. -# Identity meta (e.g. registry+repository, or url) selects the artifact; include -# per-revision fields (digest/reference) to pin a revision, else latest wins. +# Identity meta (e.g. registry+repository for oci_image, or url for +# git_repository) selects the artifact; include the type's own per-revision +# field to pin a revision — digest ("sha256:<64-hex>") for oci_image, +# reference (e.g. a tag) for git_repository; the API rejects the other +# type's field name — else latest wins. data "nullplatform_artifact" "package" { for_each = local.package_artifacts_to_lookup diff --git a/nullplatform/service_definition/variables.tf b/nullplatform/service_definition/variables.tf index 1d83d5b92..a4cbaf844 100644 --- a/nullplatform/service_definition/variables.tf +++ b/nullplatform/service_definition/variables.tf @@ -33,8 +33,18 @@ variable "repository_name" { variable "repository_branch" { type = string - default = "main" - description = "Branch of the service spec repository to use. Must be a short branch name (e.g. \"main\"), not a full ref." + description = <<-EOT + Git ref of the service spec repository to read, as a short name and not a full ref + (e.g. "v1.4.0"). No default and no recommended value: which spec repository an install + points at is its own choice, so there is no version anyone could pick for it. + + Combine with repository_ref_type, which selects the namespace this name lives in. + EOT + + validation { + condition = var.repository_branch != "" && !contains(["main", "master", "head", "latest"], lower(var.repository_branch)) + error_message = "repository_branch must be a non-empty pinned ref, not empty and not a moving branch." + } } variable "service_path" { @@ -92,7 +102,7 @@ variable "dimensions" { variable "repository_ref_type" { type = string - default = "heads" + default = "tags" description = "Git ref namespace for `repository_branch` on GitHub: \"heads\" for a branch, \"tags\" for a tag, or \"\" to treat it as a raw commit SHA. Defaults to \"heads\", preserving previous behaviour." validation { condition = contains(["heads", "tags", ""], var.repository_ref_type) @@ -113,10 +123,26 @@ variable "package" { { url = "https://github.com/acme/svc.git", reference = "main" } for a git_repository, or { registry, repository, digest } for an oci_image); • look up one registered elsewhere BY IDENTITY (no ids needed) — set - `lookup = true` + `meta` with the identity fields (add digest/reference - to pin a specific revision, otherwise the latest revision is used); + `lookup = true` + `meta` with the identity fields (url for + git_repository, or registry+repository for oci_image); add the + type's own per-revision field to pin a specific revision (reference, + e.g. a tag, for git_repository; digest, formatted "sha256:<64-hex>", + for oci_image — the API rejects the other type's field name), + otherwise the latest revision is used; • pin explicit ids — set `resource_id` + `resource_revision_id`. + An artifact's `name` defaults to "impl" and `type` to "git_repository" — + a service package is typically a single artifact pointing at the + service's own implementation repo, so only `meta` (url/reference) needs + setting on every release. + + For an artifact with `type = "oci_image"` (opt-in — not the default + here), meta.registry/meta.repository default to + var.package_oci_default_registry/var.package_oci_default_repository + when omitted from `meta`. Only meta.digest needs setting on every + release in that case; every other artifact type gets no meta defaults + (their meta shape is unrelated to a container registry). + Null (the default) keeps the classic module behavior — no package. EOT type = object({ @@ -127,11 +153,11 @@ variable "package" { tags = optional(map(string), {}) # release tags: name => version (requires an API with the package release-tag routes) visible_to = optional(list(string)) # default: [var.nrn] artifacts = optional(list(object({ - name = string - type = optional(string, "oci_image") # oci_image | oras_artifact | git_repository | blob - meta = optional(any) # register (lookup=false) or find (lookup=true) - lookup = optional(bool, false) # true: resolve an EXISTING artifact by meta identity - resource_id = optional(string) # …or pin explicit ids + name = optional(string, "impl") # default: a single service-implementation artifact + type = optional(string, "git_repository") # oci_image | oras_artifact | git_repository | blob + meta = optional(any) # register (lookup=false) or find (lookup=true) + lookup = optional(bool, false) # true: resolve an EXISTING artifact by meta identity + resource_id = optional(string) # …or pin explicit ids resource_revision_id = optional(string) })), []) }) @@ -152,3 +178,15 @@ variable "package" { error_message = "`lookup = true` requires `meta` with the identity fields of the existing artifact." } } + +variable "package_oci_default_registry" { + description = "Default meta.registry for an oci_image package artifact whose own meta omits it. See var.package's artifacts docs." + type = string + default = "public.ecr.aws" +} + +variable "package_oci_default_repository" { + description = "Default meta.repository for an oci_image package artifact whose own meta omits it. See var.package's artifacts docs." + type = string + default = "nullplatform/scopes/containers" +} diff --git a/nullplatform/service_definition_agent_association/main.tf b/nullplatform/service_definition_agent_association/main.tf index 09bcaeb0c..291c0f4b0 100644 --- a/nullplatform/service_definition_agent_association/main.tf +++ b/nullplatform/service_definition_agent_association/main.tf @@ -1,4 +1,8 @@ +locals { + worker_entrypoint = var.entrypoint != "" ? var.entrypoint : "/app/packages/${var.package_slug}/entrypoint" +} + resource "terraform_data" "api_key_trigger" { input = var.api_key } @@ -14,8 +18,18 @@ resource "nullplatform_notification_channel" "channel_from_template" { agent { api_key = var.api_key command { - type = "exec" - data = { + # Worker-orchestrator: route package-exec to an agent that spawns the + # package's worker image and runs its baked entrypoint (matches + # `np package publish`). Otherwise legacy git-clone exec. + type = var.worker_orchestrator ? "package-exec" : "exec" + data = var.worker_orchestrator ? { + package = var.package_slug + cmdline = local.worker_entrypoint + environment = jsonencode({ + NP_ACTION_CONTEXT = "'$${NOTIFICATION_CONTEXT}'" + NP_PLUGIN = var.package_slug + }) + } : { cmdline = "${var.base_clone_path}/${var.repository_service_spec_repo}${var.service_path != "" ? "/${var.service_path}" : ""}/entrypoint/entrypoint" arguments = jsonencode(var.agent_arguments) environment = jsonencode({ NP_ACTION_CONTEXT = "'$${NOTIFICATION_CONTEXT}'" }) @@ -34,5 +48,13 @@ resource "nullplatform_notification_channel" "channel_from_template" { lifecycle { replace_triggered_by = [terraform_data.api_key_trigger] + precondition { + condition = !var.worker_orchestrator || var.package_slug != "" + error_message = "package_slug is required when worker_orchestrator = true." + } + precondition { + condition = var.worker_orchestrator || var.repository_service_spec_repo != "" + error_message = "repository_service_spec_repo is required when worker_orchestrator = false." + } } } diff --git a/nullplatform/service_definition_agent_association/variables.tf b/nullplatform/service_definition_agent_association/variables.tf index ad6beeb94..377d056a7 100644 --- a/nullplatform/service_definition_agent_association/variables.tf +++ b/nullplatform/service_definition_agent_association/variables.tf @@ -35,24 +35,26 @@ variable "service_specification_slug" { variable "repository_service_spec_repo" { type = string - description = "GitHub repository name containing the service specs (used to build the agent cmdline path)" + default = "" + description = "GitHub repository name containing the service specs (used to build the agent cmdline path). Required when worker_orchestrator = false; unused (the worker's baked entrypoint is used instead) when true." } variable "base_clone_path" { type = string - default = "/root/.np" - description = "Base path where the service repository is cloned inside the agent pod" + default = "/home/agent/.np" + description = "Base path where the service repository is cloned inside the agent pod. Unused when worker_orchestrator = true." } variable "service_path" { type = string - description = "Path to the service directory within the repository (e.g., databases/postgres/k8s)" + default = "" + description = "Path to the service directory within the repository (e.g., databases/postgres/k8s). Only consulted when worker_orchestrator = false — empty omits the path segment." } variable "agent_arguments" { type = list(string) default = [] - description = "Arguments to pass to the agent entrypoint command" + description = "Arguments to pass to the agent entrypoint command. Unused when worker_orchestrator = true." } variable "description" { @@ -60,3 +62,27 @@ variable "description" { type = string default = "" } + +variable "worker_orchestrator" { + description = <<-EOT + Emit a worker-orchestrator (package-exec) channel instead of the legacy + git-clone exec channel. When true, the channel routes package-exec commands + to an agent that spawns the package's worker image and runs its baked + entrypoint — matching what `np package publish` registers. Requires + package_slug; set tags_selectors to select the agent (e.g. {package = slug}). + EOT + type = bool + default = false +} + +variable "package_slug" { + description = "Package/service slug — the package-exec NP_PLUGIN and default entrypoint path. Required when worker_orchestrator = true." + type = string + default = "" +} + +variable "entrypoint" { + description = "Override the worker's baked entrypoint path. Defaults to /app/packages//entrypoint." + type = string + default = "" +} diff --git a/scripts/check-version-pinning.sh b/scripts/check-version-pinning.sh new file mode 100755 index 000000000..4c77edce9 --- /dev/null +++ b/scripts/check-version-pinning.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Rejects a new moving version reference. See VERSIONS.md. +# +# Usage: check-version-pinning.sh ... + +BASELINE="$(dirname "$0")/version-pinning-baseline.txt" +FAILED=0 + +baselined() { + [ -f "$BASELINE" ] || return 1 + grep -qxF "$1" <(grep -v '^[[:space:]]*#' "$BASELINE" | grep -v '^[[:space:]]*$') 2>/dev/null +} + +for file in "$@"; do + [ -f "$file" ] || continue + case "$file" in + */.terraform/*|*/.terragrunt-cache/*) continue ;; + *.tf) ;; + *) continue ;; + esac + + while IFS='|' read -r key message; do + [ -n "$key" ] || continue + if baselined "$key"; then + continue + fi + printf ' %s\n %s\n' "$key" "$message" >&2 + FAILED=1 + done < <( + awk -v F="$file" ' + function emit(key, msg) { print F ":" key "|" msg } + + /^variable[[:space:]]+"/ { + match($0, /"[^"]+"/) + vname = substr($0, RSTART + 1, RLENGTH - 2) + invar = 1; next + } + invar && /^}/ { invar = 0; vname = ""; next } + + invar && $0 ~ /^[[:space:]]*default[[:space:]]*=[[:space:]]*"(latest|main|master|HEAD)"[[:space:]]*$/ { + emit(vname, "defaults to a moving reference. Pin it, or drop the default so the caller has to pin it.") + next + } + invar && $0 ~ /^[[:space:]]*default[[:space:]]*=[[:space:]]*".*#(latest|main|master|HEAD)"[[:space:]]*$/ { + emit(vname, "default pins a git ref to a moving branch. Expose the ref as its own variable without a default.") + next + } + invar && $0 ~ /^[[:space:]]*default[[:space:]]*=[[:space:]]*".*\/refs\/heads"?[[:space:]]*$/ { + emit(vname, "default hardcodes refs/heads, so a caller cannot pin to a tag without also rewriting the URL. Expose the ref namespace as a variable.") + next + } + + /^resource[[:space:]]+"helm_release"[[:space:]]+"/ { + n = $0 + sub(/^resource[[:space:]]+"helm_release"[[:space:]]+"/, "", n) + sub(/".*$/, "", n) + rname = n; inres = 1; hasver = 0; next + } + inres && $0 ~ /^[[:space:]]+version[[:space:]]*=/ { hasver = 1; next } + inres && /^}/ { + if (!hasver) { + emit("helm_release." rname, "has no version argument, so Helm resolves whatever the chart repository serves at apply time with no diff to review. Add version = var..") + } + inres = 0; next + } + ' "$file" + ) +done + +if [ "$FAILED" -ne 0 ]; then + { + echo "" + echo "Version pinning check failed." + echo "Fix the finding, or -- if the fix is deliberately deferred -- add the key to" + echo "scripts/version-pinning-baseline.txt with a comment saying why and where it is tracked." + } >&2 +fi + +exit $FAILED diff --git a/scripts/version-pinning-baseline.txt b/scripts/version-pinning-baseline.txt new file mode 100644 index 000000000..92cb25f64 --- /dev/null +++ b/scripts/version-pinning-baseline.txt @@ -0,0 +1,26 @@ +# Deliberately deferred version-pinning violations. Each group says why. +# This file should only ever shrink. +# +# Format: : + +# The scopes and service-spec repositories, read through ten paths across three +# modules, all defaulting to a moving branch. Fixable without a breaking change; +# deferred for its own review. +nullplatform/parameter_storage_definition/variables.tf:repository_parameter_storage_spec +nullplatform/parameter_storage_definition/variables.tf:repository_parameter_storage_spec_branch +nullplatform/scope_definition/variables.tf:repository_action_templates +nullplatform/scope_definition/variables.tf:repository_action_templates_branch +nullplatform/scope_definition/variables.tf:repository_scope_template +nullplatform/scope_definition/variables.tf:repository_scope_template_branch +nullplatform/scope_definition/variables.tf:repository_service_spec +nullplatform/scope_definition/variables.tf:repository_service_spec_branch +nullplatform/scope_definition_agent_association/variables.tf:repository_notification_channel +nullplatform/scope_definition_agent_association/variables.tf:repository_notification_channel_branch + +# Fixed by the "require an explicit version" PR, whose base does not contain this +# file. Remove these when it merges. +infrastructure/commons/cert_manager/main.tf:helm_release.cert_manager +infrastructure/commons/prometheus/main.tf:helm_release.prometheus +nullplatform/agent/variables.tf:agent_repos_scope +nullplatform/container_orchestration/eks/variables.tf:traffic_manager_version +nullplatform/service_definition/variables.tf:repository_branch