Skip to content

feat: require an explicit version for everything the modules deploy - #540

Merged
gdrojas merged 2 commits into
6.xfrom
feat/require-explicit-versions
Aug 28, 2026
Merged

feat: require an explicit version for everything the modules deploy#540
gdrojas merged 2 commits into
6.xfrom
feat/require-explicit-versions

Conversation

@gdrojas

@gdrojas gdrojas commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

⚠️ Read before bumping

Thirteen inputs lose their default and become required. Any stack that bumps to this release fails at plan with No value for required variable until it declares them. VERSIONS.md lists the current value of each.

Four were previously unpinnable and resolved at deploy time, so read your cluster rather than copying the table: cert_manager_version, prometheus_version, logging_controller_image_tag, and traffic_manager_version on eks.

This is typed feat: rather than feat!: because the repository temporarily blocks breaking-change markers. release-please will therefore cut a minor, and the changelog will not carry this warning — whoever writes the release notes has to add it by hand.

What changes

Every version these modules deploy loses its default and becomes required. A default is a version somebody else chose, and it is why a moving reference survived this long unnoticed: nobody had to look at it.

Thirteen inputs now have no default:

Module Inputs Was
base nullplatform_base_helm_version, logging_controller_image_tag, control_plane_agent_image_tag 2.44.0, 1.6.0, 0.9.2
agent nullplatform_agent_helm_version, agent_traffic_manager_tag, agent_repos_scope_tag, image_tag 2.37.0, new, new, already required
eks traffic_manager_version "latest"
aks, gke traffic_manager_version ""
commons cert_manager_version, prometheus_version never wired / did not exist
service_definition repository_branch "main"

Each rejects an empty value and the well-known moving names. That guard cannot tell a mutable ref from a fixed one by name alone — a tag called beta or a branch called develop still passes — so it catches mistakes, not intent.

Two inputs were not pinnable at all

cert_manager_version was declared and never referenced: grep found one occurrence, its own declaration, and the helm_release had no version argument. prometheus had neither. Both tracked whatever their chart repository served while the README showed a number.

Also

agent_repos_scope is now the repository alone plus a required tag, and rejects an inline # so pasting the old value fails during plan rather than at clone time inside the pod. agent_repos_extra requires a pinned ref on every entry, covering scopes-* and services-* without enumerating them.

repository_ref_type moves to "tags" as a consequence rather than a decision: with a pinned ref required, "heads" would build refs/heads/v1.4.0, which does not exist.

Each variable carries an # example: comment with the current value, so the generated README shows something pasteable instead of your-variable-name — paired with nullplatform/actions-nullplatform#96.

The agent module had no outputs and no tests, so its rendered values could not be asserted on. It now exports rendered_values and agent_repos, matching nullplatform/base.

Migration

VERSIONS.md (added in the stacked PR) lists the current value of every input. The rule is to pin what you are already running — but four of these were previously unpinnable and resolved at deploy time, so read your cluster rather than copying the table: cert_manager_version, prometheus_version, logging_controller_image_tag, and traffic_manager_version on eks.

helm -n <ns> 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}'

Verification

133 tests, 0 failures. Assertions that described the old defaults were updated or removed: aks and gke had a check for "traffic_manager not present when unset", a state that no longer exists.

@gdrojas gdrojas changed the title feat: require an explicit version for everything the modules deploy feat!: require an explicit version for everything the modules deploy Aug 27, 2026
@gdrojas
gdrojas force-pushed the feat/require-explicit-versions branch 3 times, most recently from 6198b6d to 4c4f9c3 Compare August 27, 2026 16:45
Comment thread nullplatform/agent/locals.tf Outdated
Comment thread nullplatform/agent/outputs.tf Outdated
@gdrojas
gdrojas force-pushed the feat/require-explicit-versions branch 3 times, most recently from 1f31bff to fdf0362 Compare August 27, 2026 20:06
@gdrojas gdrojas changed the title feat!: require an explicit version for everything the modules deploy feat: require an explicit version for everything the modules deploy Aug 27, 2026
Every version these modules deploy loses its default and becomes a required
input. A default is a version somebody else chose, and it is why a moving
reference survived this long unnoticed: nobody had to look at it.

Thirteen inputs now have no default:

  base    nullplatform_base_helm_version, logging_controller_image_tag,
          control_plane_agent_image_tag
  agent   nullplatform_agent_helm_version, agent_traffic_manager_tag,
          agent_repos_scope_tag, image_tag
  eks     traffic_manager_version   (was "latest")
  aks     traffic_manager_version   (was "")
  gke     traffic_manager_version   (was "")
  commons cert_manager_version, prometheus_version
  service_definition  repository_branch   (was "main")

Each rejects an empty value and the well-known moving names. That guard cannot
tell a mutable ref from a fixed one by name alone -- a tag called beta or a
branch called develop still passes -- so it catches mistakes, not intent.

cert_manager_version was declared and never referenced: grep found one
occurrence, its own declaration, and the helm_release had no version argument at
all. prometheus had neither. Both tracked whatever their chart repository served
while the README showed a number.

The traffic manager image splits into agent_traffic_manager_repository, which
keeps a default, and the required tag -- the same shape nullplatform/base
already uses for its own images, so the two are no longer inconsistent within
one change.

agent_repos_scope is now the repository alone plus a required tag, and it
rejects an inline # so pasting the old value fails during plan instead of at
clone time inside the pod. agent_repos_extra requires a pinned ref on every
entry, which covers scopes-* and services-* without enumerating them.

repository_ref_type moves to "tags" as a consequence rather than a decision:
with a pinned ref required, "heads" would build refs/heads/v1.4.0, which does
not exist.

The agent module had no tests, so its rendered values were never asserted on.
It has them now, reading helm_release.agent.values[0] directly: an output for
the rendered values adds nothing the resource attribute does not already give,
and would put a test-only value in the module's public interface.

Each variable carries an "# example:" comment with the current value, so the
generated README shows something that can be pasted instead of a placeholder.

Migration required for every consumer. Thirteen inputs have no default and must
be set; see VERSIONS.md for the current value of each. The rule is to pin what
you are already running -- but four of these were previously unpinnable, so read
your cluster rather than copying the table: cert_manager_version,
prometheus_version, logging_controller_image_tag and traffic_manager_version on
eks all resolved to whatever their source served at deploy time.

Note on the commit type: this removes required-input defaults, so it is a
breaking change by any normal reading. It is typed feat: rather than feat!:
because the repository temporarily blocks breaking-change markers, which means
release-please will cut a minor for it. Whoever writes the release notes has to
add the warning by hand -- the changelog will not carry it.
@gdrojas
gdrojas force-pushed the feat/require-explicit-versions branch from fdf0362 to eaf40c3 Compare August 27, 2026 20:10
@gdrojas
gdrojas merged commit 868ad98 into 6.x Aug 28, 2026
53 checks passed
@gdrojas
gdrojas deleted the feat/require-explicit-versions branch August 28, 2026 13:53
release-application Bot added a commit that referenced this pull request Aug 28, 2026
🤖 I have created a release *beep* *boop*
---


##
[6.20.0](v6.19.1...v6.20.0)
(2026-08-28)


### Features

* require an explicit version for everything the modules deploy
([#540](#540))
([868ad98](868ad98))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
sebastiancorrea81 pushed a commit that referenced this pull request Sep 3, 2026
…540)

Every version these modules deploy loses its default and becomes a required
input. A default is a version somebody else chose, and it is why a moving
reference survived this long unnoticed: nobody had to look at it.

Thirteen inputs now have no default:

  base    nullplatform_base_helm_version, logging_controller_image_tag,
          control_plane_agent_image_tag
  agent   nullplatform_agent_helm_version, agent_traffic_manager_tag,
          agent_repos_scope_tag, image_tag
  eks     traffic_manager_version   (was "latest")
  aks     traffic_manager_version   (was "")
  gke     traffic_manager_version   (was "")
  commons cert_manager_version, prometheus_version
  service_definition  repository_branch   (was "main")

Each rejects an empty value and the well-known moving names. That guard cannot
tell a mutable ref from a fixed one by name alone -- a tag called beta or a
branch called develop still passes -- so it catches mistakes, not intent.

cert_manager_version was declared and never referenced: grep found one
occurrence, its own declaration, and the helm_release had no version argument at
all. prometheus had neither. Both tracked whatever their chart repository served
while the README showed a number.

The traffic manager image splits into agent_traffic_manager_repository, which
keeps a default, and the required tag -- the same shape nullplatform/base
already uses for its own images, so the two are no longer inconsistent within
one change.

agent_repos_scope is now the repository alone plus a required tag, and it
rejects an inline # so pasting the old value fails during plan instead of at
clone time inside the pod. agent_repos_extra requires a pinned ref on every
entry, which covers scopes-* and services-* without enumerating them.

repository_ref_type moves to "tags" as a consequence rather than a decision:
with a pinned ref required, "heads" would build refs/heads/v1.4.0, which does
not exist.

The agent module had no tests, so its rendered values were never asserted on.
It has them now, reading helm_release.agent.values[0] directly: an output for
the rendered values adds nothing the resource attribute does not already give,
and would put a test-only value in the module's public interface.

Each variable carries an "# example:" comment with the current value, so the
generated README shows something that can be pasted instead of a placeholder.

Migration required for every consumer. Thirteen inputs have no default and must
be set; see VERSIONS.md for the current value of each. The rule is to pin what
you are already running -- but four of these were previously unpinnable, so read
your cluster rather than copying the table: cert_manager_version,
prometheus_version, logging_controller_image_tag and traffic_manager_version on
eks all resolved to whatever their source served at deploy time.

Note on the commit type: this removes required-input defaults, so it is a
breaking change by any normal reading. It is typed feat: rather than feat!:
because the repository temporarily blocks breaking-change markers, which means
release-please will cut a minor for it. Whoever writes the release notes has to
add the warning by hand -- the changelog will not carry it.
sebastiancorrea81 pushed a commit that referenced this pull request Sep 3, 2026
🤖 I have created a release *beep* *boop*
---


##
[6.20.0](v6.19.1...v6.20.0)
(2026-08-28)


### Features

* require an explicit version for everything the modules deploy
([#540](#540))
([868ad98])

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
sebastiancorrea81 added a commit that referenced this pull request Sep 3, 2026
…#554)

* fix(azure/aks_route_table): stable trigger instead of timestamp() (#474) (#493)

`terraform_data.trigger` used `triggers_replace = timestamp()`, so it replaced
on every plan and dragged `azapi_update_resource.aks_subnet_route_table` with it
through `replace_triggered_by`. Combined with the vnet AVM subnet proposing
`routeTable -> null` every plan, the azure stack never reached `No changes`
(#474) and every apply detached/re-attached the route table on a live kubenet
cluster.

Key the trigger on the values that actually matter -- the node subnet id and the
discovered route table id -- so it re-attaches only when the attachment really
changes. The other half of #474 (the vnet detaching the route table) is already
addressed by the `route_table` passthrough on `subnets_definition` (#475);
document it here as the preferred, converging approach.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(azure): make the internal gateway LB subnet configurable and grantable (#494)

An AKS internal gateway sat at PROGRAMMED=False for days on a real install,
with the service-controller retrying 582 times. Two module defects, and the
first one disguises itself as the second.

`internal_azure_load_balancer_subnet` defaulted to "load_balancer". That is the
key a subnet typically has in a `subnets_definition` map, not its resource name,
so the internal gateway got annotated with a subnet that does not exist. Azure
answers a missing scope with

    403 AuthorizationFailed ... over scope '.../subnets/load_balancer'
    or the scope is invalid

which reads like missing RBAC and is not. Default to "" instead, matching the
sibling `gateway_public_azure_load_balancer_subnet` ("empty by default, in which
case Azure picks the subnet automatically"). The two variables disagreed.

Also quote the value in the values template, like the public one already is, so
an empty subnet renders `azure_load_balancer_subnet: ""` rather than a YAML null.

And fixing the name alone still 403s, for real: the aks module hardcoded
`network_contributor_role_assigned_subnet_ids` to the node subnet, so any other
subnet the cloud-provider must write into -- the one an internal LB is pinned to
-- had no permissions. Callers can now pass extra subnet IDs; the node subnet is
still granted automatically and the default is empty, so nothing changes for
existing callers.

Tests: two runs mirroring the public-subnet ones, asserting the empty default
and that a set value reaches the rendered internal block. 20 passed, 0 failed.

Needs nullplatform/helm-charts#172 to ship first: the chart emits the subnet
annotation unconditionally, so an empty value would render a null annotation
until that guard lands.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Sebastian Correa <correa.sebasti@gmail.com>

* ci: temporary dual release line (package -> 7.x, resto -> 6.x) (#498)

* ci: allow package/* branches and enable checks on 6.x line

* ci: enforce correct base branch per branch type (package vs 6.x)

* ci: temporarily block breaking-change commits on all branches

* chore: allow package/* branches and exempt 6.x in local pre-commit hook

* ci: run release-please on both main and 6.x lines

* ci: auto-merge release PRs from either the main or 6.x release line

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(ci): prevent script injection in auto-merge-release workflow

Move github.event.workflow_run.head_branch into env block to prevent
direct interpolation into bash script. Aligns with patterns used in
commitlint.yml and no-breaking-changes.yml.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* ci: run tofu lint on PRs targeting 6.x too

* ci: run tofu tests on PRs targeting 6.x too

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* ci: run trivy scan on the 6.x line too

* ci: run unused-declaration check on PRs targeting 6.x too

* docs: add executable rollback runbook for the temporary dual release line

---------

Co-authored-by: sebas_correa <sebastian.correa@nullplatform.io>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* fix(ci): correct release-please target-branch resolution for the dual release line scheme (#504)

* fix(ci): correct release-please target-branch resolution for the dual release line scheme

release-please-action's target-branch input defaults to the repository's
default branch, not github.ref_name, so pushes to 6.x were silently
computing versions against main's history instead of 6.x's. Also hardens
the breaking-change scanner (paginate past 30 commits, also check the PR
title since this repo squash-merges using it) and hoists github.ref_name
out of two run: blocks into env vars.

* fix(docs): scope rollback runbook's commit search to main/6.x only

Paso 1's git log --all --grep picked up a false positive from a stale
pre-squash feature branch, with no way for an unsupervised agent to tell
it apart from the real main/6.x commits. Scope the search per-branch
instead, and clarify Paso 2's "repetir por cada SHA" flow accordingly.

---------

Co-authored-by: sebas_correa <sebastian.correa@nullplatform.io>

* chore(6.x): release 6.11.3

* docs: regenerate READMEs for changed modules and update versions

* ci: verify a branch actually forked from its declared base line (#507)

* ci: verify a branch actually forked from its declared base line

base-branch-check only validates that a branch's NAME is paired with the
right base — a feat/* branch created from main (instead of 6.x) still
passes that check, but merging it would smuggle every main-only change
(including the breaking change 6.x is meant to be free of) into 6.x.
This adds a job that checks the real git ancestry via merge-base.

* fix(ci): use the branch's real fork point in the rebase hint, not the historical fork point

Using $(git merge-base origin/main origin/6.x) in the remediation message
replayed every main-only commit onto 6.x -- exactly what the check exists
to prevent -- and made the check pass afterward. Use the already-computed
$MB_OTHER instead. Also derives OTHER_BRANCH from BASE_BRANCH rather than
from HEAD_BRANCH's name (avoids a vacuous pass if this ever runs on a
branch type not covered by the naming rule), drops the now-unnecessary
ci/* exemption, adds least-privilege permissions, and rewords the error
to describe the observation rather than assert a cause.

* docs(ci): clarify fork-point-check's ci/* comment

---------

Co-authored-by: sebas_correa <sebastian.correa@nullplatform.io>

* feat(eks): add traffic_manager_port variable (#509)

* chore(6.x): release 6.12.0 (#510)

* chore(6.x): release 6.12.0

* docs: regenerate READMEs for changed modules and update versions

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* chore(nullplatform): refresh provider lockfiles for the >= 0.0.99 constraint (#490)

scope_definition and scope_definition_agent_association require nullplatform
>= 0.0.99, but their committed .terraform.lock.hcl still pinned 0.0.95. The
tofu-validate pre-commit hook runs `tofu init` with -lockfile=readonly, so it
could not reconcile the two and failed for anyone committing a change in
either module:

    Could not resolve provider nullplatform/nullplatform: locked provider
    registry.opentofu.org/nullplatform/nullplatform 0.0.95 does not match
    configured version constraint >= 0.0.99

CI was unaffected, so this only showed up locally.

Re-locked with `tofu providers lock` scoped to that provider, keeping the three
platforms the files already covered (linux_amd64, darwin_amd64, darwin_arm64).
Plain `tofu init -upgrade` would also have bumped http and external, which is
unrelated here, and would have narrowed the hashes to the local platform.

* feat(aks): support disabling local accounts with an Entra ID authorization path (#461)

The module hardcoded rbac_aad_azure_rbac_enabled = false and exposed neither
local_account_disabled nor the admin group ids, so a cluster whose local
accounts were disabled to meet a security baseline could not be expressed in
configuration. Consumers hit two problems: a plan reverts the hardening back to
the provider default, and the admin_* outputs go empty, which surfaces as
"x509: apiserver certificate is not trusted" rather than as a missing credential.

Adds local_account_disabled, azure_rbac_enabled and admin_group_object_ids, all
passed through to the upstream module. Defaults preserve today's behaviour:
local_account_disabled is null and azure_rbac_enabled keeps the previous
hardcoded false.

A precondition rejects local_account_disabled = true unless Azure RBAC or an
admin group is configured. Without one of those, no identity is authorized
against the API server, and the cluster is reachable only through an admin
kubeconfig issued beforehand — unrecoverable from configuration once that
credential stops working.

The README documents the hardened setup, including the kubelogin exec block
consumers need once the admin_* outputs are empty.

* chore(6.x): release 6.13.0 (#516)

* chore(6.x): release 6.13.0

* docs: regenerate READMEs for changed modules and update versions

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(gcp/security): handle full resource path in cluster subnetwork lookup (#512)

data.google_container_cluster.this[0].subnetwork echoes back whatever
format the cluster was created with. When the cluster comes from
terraform-google-modules/kubernetes-engine (as in infrastructure/gcp/gke),
that's a full "projects/.../regions/.../subnetworks/NAME" path, not a
bare name — but data.google_compute_subnetwork.this only accepts a bare
name in its `name` argument, so every apply combining infrastructure/gcp/gke
with infrastructure/gcp/security failed with a 400 "Invalid value for
field 'subnetwork'" error. This is 100% reproducible, not a race
condition, and none of the module's existing override variables
(gcp_network_name, network_cidr) avoid it, since the subnetwork data
source's count doesn't depend on them.

Take the last "/"-separated segment of the cluster's subnetwork
attribute before using it, which is correct whether the value is
already a bare name or a full path.

Co-authored-by: sebas_correa <sebastian.correa@nullplatform.io>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* chore(6.x): release 6.13.1 (#517)

* chore(6.x): release 6.13.1

* docs: regenerate READMEs for changed modules and update versions

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* feat(nullplatform/agent): require ingress templates for non-aws clouds (#515)

* feat(nullplatform/agent): require ingress templates for non-aws clouds

The k8s scope's default ingress templates are AWS-specific (ALB Ingress
annotations); on any other cloud they silently produce no working route.
Add preconditions requiring service_template, initial_ingress_path, and
blue_green_ingress_path whenever cloud_provider is not "aws", matching the
existing cross_variable_validation pattern for aws/azure requirements.

* fix(nullplatform/agent): key ingress-template requirement off INGRESS_TYPE, not cloud_provider

cloud_provider was the wrong axis — a cluster can use Istio (or not) on any
cloud, and cloud_provider != "aws" doesn't actually imply Istio routing.
Require service_template, initial_ingress_path, and blue_green_ingress_path
only when extra_envs.INGRESS_TYPE == "istio", matching the real signal that
determines which ingress mechanism the k8s scope needs to target.

* fix(nullplatform/agent): drop dead PRIVATE_DOMAIN, validate private_gateway_name for gcp/oci

PRIVATE_DOMAIN was never read anywhere in the nullplatform/scopes k8s
scope's scripts (only documented, never consumed) — removed from
cloud_config.gcp and cloud_config.oci, and dropped the now-unused
private_domain variable entirely.

private_gateway_name, by contrast, is actively read by the k8s scope's
DNS/gateway routing scripts for any private-visibility deployment — widen
its existing precondition (previously azure-only) to also require it for
gcp and oci.

* feat(nullplatform/agent): default private/public gateway names, dedupe cloud_config

private_gateway_name and public_gateway_name were duplicated identically
across azure/gcp/oci despite not being cloud-specific behavior — moved to
default_config (applies to every cloud provider) and given real defaults
("gateway-private"/"gateway-public") instead of null, matching the naming
convention already used consistently across real deployments. Dropped the
now-redundant azure/gcp/oci-scoped precondition requiring them, and cleaned
up the resulting dead cloud_config entries (empty gcp block, duplicate oci
PRIVATE_GATEWAY_NAME).

* docs(nullplatform/agent): fix stale Azure-only comments on gateway name variables

* refactor(nullplatform/agent): drop unused nrn variable

nrn was kept only for "interface parity" with other nullplatform modules
via a tflint-ignore, but the agent resolves its own scope from the API key
and never actually read it. Removed outright instead of suppressing the
lint warning — no caller-side behavior depends on it.

* fix(nullplatform/agent): remove redundant public_gateway_name precondition

public_gateway_name now defaults to "gateway-public" (same change already
applied to private_gateway_name) — the azure-only precondition requiring
it non-null is unreachable in practice, same reasoning as the
private_gateway_name precondition removed earlier in this branch.

* refactor(nullplatform/agent): reorganize variables.tf into coherent sections

Group variables by what actually gates them, and fix descriptions that had
drifted from reality:
- image_tag and cloud_provider moved into Required Variables (no default,
  same as api_key/cluster_name/tags_selectors — they were previously buried
  mid-file among optional ones).
- cluster_name's description said "EKS cluster" (AWS-only language) even
  though this module supports gcp/azure/oci too — generalized to
  "Kubernetes cluster".
- private_gateway_name/public_gateway_name moved out of "Azure
  Configuration" into their own "Gateway Configuration" section — they're
  universal (in default_config), not azure-specific.
- domain and use_account_slug's "(required when cloud_provider is 'azure')"
  comments were false — no precondition ever enforced that. Dropped the
  claim; use_account_slug moved to Agent configuration (naming behavior,
  not DNS).
- service_template/initial_ingress_path/blue_green_ingress_path/extra_envs
  moved into a new "Ingress / Networking Configuration" section instead of
  trailing after "Image Configuration", and given the same one-line leading
  comment style as every other variable in the file.

* fix(nullplatform/scope_definition_agent_association): default description non-empty

Give the notification channel a sensible default description instead of
an empty string.

---------

Co-authored-by: sebas_correa <sebastian.correa@nullplatform.io>

* chore(6.x): release 6.14.0

* docs: regenerate READMEs for changed modules and update versions

* feat(gcp/artifact-registry): optional static service account key (#514)

* feat(gcp/artifact-registry): optional static service account key

Add generate_key to create a google_service_account_key and expose it via
service_account_key_base64, for Docker clients outside the cluster (e.g. a
nullplatform docker-server provider config) that can't use Workload Identity.

* fix(gcp/artifact-registry): pin the key output, add a rotation lever, document the exposure

Review follow-ups on the static service account key. No change to the default
behavior: generate_key still defaults to false and creates nothing.

- The test suite did not pin which key attribute the output exposes. Swapping
  `private_key` for `public_key` in outputs.tf — a real, also-base64, also-computed
  attribute on the same resource — kept all 7 runs green while shipping a public
  key as the Docker password, which only fails at runtime. `key_created_when_requested`
  now compares the output against `private_key` directly and runs as `apply`, since
  both sides are computed.
- `nullable = false` on `generate_key`. It was unset, so a consumer threading an
  optional root variable (`generate_key = var.maybe_key`) hit `Error: Null condition`
  at plan instead of the default.
- Added `key_rotation_token`, wired to the resource's `keepers`. GCP user-managed
  keys never expire and there was no supported way to rotate — a consumer had to
  know to run `tofu apply -replace`. Left unset by default so the key stays
  deterministic; three tests cover unset, empty and provided.
- Documented what the key costs before you enable it: the private key is stored in
  plaintext in state (`sensitive = true` redacts display, not state); the credential
  is project-scoped, so a leak can overwrite tags in every Artifact Registry
  repository in the project, not just this one; rotation is manual; and the key is
  unrecoverable after state loss because the provider only populates `private_key`
  on create, so the output silently becomes empty rather than erroring.
- Noted that `_json_key` (as opposed to `_json_key_base64`) needs `base64decode()`.
  The existing GCP stack uses that form, so this was a real footgun.
- Aligned the new usage example's `?ref=` with the rest of the README (v6.14.0);
  the 6.x merge had updated the other block and left this one behind.

Mutation-tested: the `public_key` swap, inverting the `count` guard, making
`keepers` unconditional, and dropping `sensitive` from the output each fail the
suite now. 7 runs to 10.

---------

Co-authored-by: sebas_correa <sebastian.correa@nullplatform.io>
Co-authored-by: Gonzalo Rojas <gonzalo.rojas@nullplatform.io>

* chore(6.x): release 6.15.0

* docs: regenerate READMEs for changed modules and update versions

* feat(gcp/gke): support Autopilot mode and flexible/spot node pools (#513)

* feat(gcp/gke): support Autopilot mode and flexible/spot node pools

Add autopilot_enabled to switch between a standard cluster with manually
managed node pools and a GKE Autopilot cluster. Widen node_pools to allow
either an autoscaling min/max range or a fixed node_count, and spot or
preemptible VMs per pool, with a validation guarding against setting both.
Also wire the previously-unused tags variable into cluster_resource_labels
on both submodules.

* fix(gcp/gke): add moved block, declare google-beta, cover both modes with tests

Review follow-ups. The first item is a data-loss bug on a default-config upgrade.

- Adding `count` to the pre-existing `module "gke"` moves its state address from
  `module.gke` to `module.gke[0]`, and nothing migrated it. A consumer who bumped
  only the module ref, leaving `autopilot_enabled` at its default false, would get
  a plan that DESTROYS the live cluster, every node pool and the service account,
  because the old address reads as "not in configuration" — and
  `deletion_protection_enabled` defaults to false, so nothing blocks it. Added a
  `moved` block. Verified with a state-migration harness on OpenTofu 1.10.7: the
  old shape applied, then the new shape planned `1 to add, 1 to destroy` without
  the block and `0 to add, 0 to change, 0 to destroy` with it.
- Declared `google-beta` in `providers.tf`. The Autopilot submodule creates its
  cluster with `provider = google-beta`, and provider requirements are static —
  `count = 0` does not suppress them — so it was being resolved unpinned and with
  an empty default configuration, meaning the root's credentials or
  impersonation never reached it. It was also resolving to a different major than
  `google` (6.50.0 alongside 5.45.2). Constrained to `~> 5.0` and regenerated the
  lock; both now resolve to 5.45.2 and a clean `init` succeeds, which it did not
  before this commit once the constraint was added.
- `total_min_count`/`total_max_count` are now accepted per pool, because
  `min_count`/`max_count`/`node_count` are PER ZONE and this module always creates
  regional clusters — a pool asking for `node_count = 1` in a three-zone region
  gets three nodes, so the README's own examples understated capacity and cost by
  3x. They must be set together, which is validated.
  Note the subtlety this required: a declared `optional(number)` with no default is
  present as a key holding null, and the wrapped module decides with
  `contains(keys(autoscaling.value), "total_min_count")`, not a null check
  (private-cluster/cluster.tf:558). Passing the nulls through would read as "set"
  for every pool and null out both the per-zone and total counts, leaving
  autoscaling unbounded. `local.node_pools` strips null-valued keys; a test guards it.
- Exposed `node_pools_taints`. GKE adds only labels to Spot nodes in standard
  clusters — the `cloud.google.com/gke-spot` NoSchedule taint comes solely from node
  auto-provisioning, which is not this path — so the README's spot example let any
  pod without a nodeSelector, including nullplatform system workloads, be scheduled
  onto capacity that is reclaimed on 15 seconds' notice. The standard "critical
  workloads on on-demand only" pattern was inexpressible through this module.
- README: the Architecture and Features sections claimed the module "sets up logging
  and monitoring", qualified as "(standard mode)". Both were backwards. Standard mode
  sets `logging_service = "none"` (logging DISABLED); the Autopilot submodule has no
  `logging_service` input at all and Autopilot cannot disable logging, so flipping
  the flag adds ingestion cost. Also documented that switching modes destroys the
  cluster, added `authorized_ip_ranges` to the Autopilot example (without it the
  public control-plane endpoint accepts 0.0.0.0/0), and aligned the stale `?ref=`.

Tests: the module had none and was in no `tofu-test.yml` allowlist, so the green
"All module tests passed" check was vacuous for this path. Added
`infrastructure/gcp/gke` to `test-gcp-modules` and a 13-run suite covering both
modes, the two validations, the null-stripping, and taint passthrough. The PR body
said plan-level tests were impractical because the wrapped module's internal
`google_compute_zones`/`google_container_engine_versions` need API-shaped responses
— they are reachable with `mock_data`, plus a `mock_resource` default for
`google_service_account.member`, which the provider validates.

---------

Co-authored-by: sebas_correa <sebastian.correa@nullplatform.io>
Co-authored-by: Gonzalo Rojas <gonzalo.rojas@nullplatform.io>

* feat(gcp/backend): add GCS terraform state bucket module (#511)

* feat(gcp/backend): add module for GCS terraform state bucket

Fills a gap in the GCP module set: infrastructure/aws/backend and
infrastructure/oci/backend both have a state-bucket module, but there
was no GCP equivalent. Creates a google_storage_bucket with a random
suffix (bucket names must be globally unique across GCP), versioning
and uniform bucket-level access enabled by default, force_destroy
defaulted to false to protect state, optional customer-managed
encryption via an existing KMS key, and optional IAM member
restrictions via roles/storage.objectAdmin bindings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(gcp/backend): fix usage example version ref to match the 6.x line

Was v7.0.0 (from when this branch was based on main); this branch is
now based on 6.x, whose current release is v6.11.3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(gcp/backend): validate inputs, lowercase the prefix, cover the security defaults

Review follow-ups on the new module. All plan-time correctness, no change to the
resource surface except two optional additions.

- `lower()` wrapped `random_id.bucket_suffix.hex`, which is already lowercase by
  construction, instead of `bucket_prefix`. An uppercase prefix planned clean and
  failed at apply with an invalid GCS bucket name.
- `kms_key_name = ""` still emitted an `encryption` block with an empty key
  instead of falling back to Google-managed encryption as documented. An empty
  string reaches the module whenever the value is wired from another module's
  output or a TF_VAR.
- No validation on `bucket_prefix` (a prefix over 46 chars pushed the name past
  the GCS 63-char limit; illegal characters and the reserved `goog`/`google`
  names passed), `storage_class`, or `public_access_prevention` (`"enforce"`
  planned fine and failed at apply, leaving the operator believing PAP was set).
- `allowed_members` was documented as restricting bucket access.
  `google_storage_bucket_iam_member` is additive: every project-level
  `roles/editor` or `roles/storage.admin` holder keeps full read on state. The
  resource choice is correct — `_iam_binding` would be authoritative and wipe
  unmanaged bindings — so the docs were the defect.
- Added optional `log_bucket` for access logging, so reads of state objects leave
  an audit trail. Closes the Trivy GCP-0077 finding on this module.
- `nullable = false` on every input with a non-null default, so an explicit
  `null` cannot bypass the default.
- Renamed `labels` to `tags`, matching gke, artifact-registry and cloud-dns.
  Free to do while the module is unreleased.

Tests: 10 runs to 26, and the suite now has teeth. The security defaults were
untested — deleting `uniform_bucket_level_access` from main.tf kept all 10 runs
green, and the provider default is false. Every new assertion was mutation-tested:
reverting each fix above, dropping either validation, and flipping the
`public_access_prevention` and `versioning_enabled` defaults each fail the suite.

Docs: added a bootstrap section (the name embeds a random suffix, so it cannot
feed a `backend "gcs"` block directly), the CMEK service-agent grant prerequisite
this module does not create, the versioning/secret-retention caveat, and the
fact that changing `bucket_prefix` replaces the bucket. Regenerated the
terraform-docs block, which also picks up the pinned provider versions the
committed lock file implies — matching artifact-registry and cloud-dns.

* docs(gcp/backend): explain why Trivy still flags logging and CMEK

Corrects the record: the previous commit claimed adding the optional `log_bucket`
closes the Trivy GCP-0077 finding. It does not. Trivy evaluates the static
configuration with default variable values, and both `log_bucket` and
`kms_key_name` default to null, so the `dynamic` blocks produce nothing and the
scanner correctly sees a bucket with neither logging nor CMEK. GCP-0077 and
GCP-0066 are still reported on this module.

Documented rather than suppressed. The repo's .trivyignore is a flat ID list with
no path scoping, so adding GCP-0077 would silence bucket-logging findings for
every module including infrastructure/aws/backend. Making either control
mandatory is not an option either: each needs a resource this module does not
create (an existing log bucket, an existing KMS key).

---------

Co-authored-by: sebas_correa <sebastian.correa@nullplatform.io>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Gonzalo Rojas <gonzalo.rojas@nullplatform.io>

* chore(6.x): release 6.16.0

* docs: regenerate READMEs for changed modules and update versions

* fix(gcp/security): resolve the subnetwork in its own project and region (#520)

Follow-up to #512, which fixed the shape of the subnetwork reference but kept
resolving it against the configured project and region.

The parse reduced `projects/P/regions/R/subnetworks/NAME` to `NAME` and then handed
it to `google_compute_subnetwork` with `project = var.gcp_project_id` and
`region = var.gcp_region`, discarding the authoritative pair the string carried. Two
cases still 404 after #512:

- A zonal cluster. `gcp_region` must hold the ZONE, because the same variable is the
  cluster data source's `location`. The path says `regions/us-central1`; the lookup
  asks for `us-central1-a`.
- A Shared VPC subnet. The path names the host project; the lookup asks the cluster
  project.

The comment's justification for discarding them was also wrong on both counts. It
said the attribute "echoes back whatever format the cluster was created with", but
the provider normalizes any input through `RelativeLink()` before the call and reads
it back from `cluster.NetworkConfig.Subnetwork` — the field even carries
`DiffSuppressFunc: CompareSelfLinkOrResourceName` because config and read shapes
differ. So it is always the path form, which means #512 fixed a total outage for
every consumer with `cluster_name` set, not just clusters created by
terraform-google-modules. It also said `google_compute_subnetwork` "only accepts a
bare name"; the data source has an optional `self_link`, and its read path derives
project, region and name from the link.

Now one regex captures all three segments (and tolerates a self_link, since the
leading group absorbs the API prefix), with the configured project and region as the
fallback for a bare name. Replaces the double `split()` whose two lines had to keep
their index arithmetic in sync, and whose null input failed inside `locals` with
`argument must not be null`.

Also gates the data sources on whether their results are actually needed. Both were
gated only on `cluster_name != ""`, so the documented `gcp_network_name` and
`network_cidr` overrides did not avoid the reads — every consumer had to hold
container.clusters.get and compute.subnetworks.get even when supplying all derived
values, and a consumer hitting a shape the parse mishandles had no escape hatch.
`network_cidr` now skips the subnetwork read, and both overrides together skip the
cluster read as well.

Tests: 3 runs to 8. One of the three was vacuous — its two assertions were
`length(...) == 1` on values that depend only on the enable flags, so reverting the
parse left it green (verified). Nothing asserted `source_ranges` or `network`, which
are what the CIDR derivation actually produces, so a regression there would have
shipped: `source_ranges = concat([""], ...)` plans fine under mock_provider and only
fails at apply — exactly how the original bug escaped. Now asserts the derived
CIDR reaches both health-check and private-HTTPS rules, that the network reference
passes through unparsed (`google_compute_firewall.network` runs it through
`ParseGlobalFieldValue`, so a full path is valid there and must not be trimmed),
both override paths, and the self_link and bare-name shapes.

Mutation-tested: reverting to the name-only parse, ungating either data source,
inverting the network-name override precedence, and trimming the network reference
each fail the suite.

Co-authored-by: Sebastian Correa <correa.sebasti@gmail.com>

* chore(6.x): release 6.16.1

* docs: regenerate READMEs for changed modules and update versions

* feat(base): make logs controller and control plane agent image tags configurable (#527)

Both images were rendered as a hardcoded ":latest" in the values template,
so every apply could silently change which build a cluster runs, with no
way to override the tag without editing the .tmpl.yaml file directly.

Adds logging_controller_image_repository/_tag and
control_plane_agent_image_repository/_tag variables, following the same
image_repository/image_tag pattern the sibling agent module already uses.
The template composes them into the same flat "repo:tag" string the base
chart already expects, so this stays entirely on the tofu side — no
change to the nullplatform-base chart's values contract.

Defaults pin to the tag already running today, verified against
public.ecr.aws before choosing them:

- controlplane-agent: ":latest" and ":0.9.2" resolve to the SAME digest
  (sha256:95b7b13b...), so the default is a no-op for existing installs.

- k8s-logs-controller: ":latest" (sha256:2fde6c39...) matched NO released
  version tag when checked against all 82 tags in the repo, and its image
  config was built 2025-12-15, vs 1.6.0 built 2026-08-12 -- the tag the
  base chart itself already pins in its own values.yaml. Clusters have
  been running an ~8-month-old orphan build. Defaulting to 1.6.0 is
  therefore a real image change on the next apply, and an upgrade rather
  than a downgrade (v2.0.2 also exists but is older, built 2026-02-09).

Co-authored-by: sebas_correa <sebastian.correa@nullplatform.io>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* chore(6.x): release 6.17.0

* docs: regenerate READMEs for changed modules and update versions

* feat(istio): expose istio_ingressgateway_replicas to guarantee HA for node drains (#379)

* feat(istio): expose istio_ingressgateway_replicas to guarantee HA for node drains

* chore(istio): drop verbose comment on ingressgateway HA set block

---------

Co-authored-by: Sebastian Correa <correa.sebasti@gmail.com>

* chore(6.x): release 6.18.0

* docs: regenerate READMEs for changed modules and update versions

* feat(external_dns): add google provider support (#532)

* feat(external_dns): add google provider support

* docs(external_dns): document google provider, cover it in cross-provider test

* fix(external_dns): use --google-project chart flag instead of unsupported google.project value key

---------

Co-authored-by: sebas_correa <sebastian.correa@nullplatform.io>

* chore(6.x): release 6.19.0

* docs: regenerate READMEs for changed modules and update versions

* fix(base): bump default nullplatform_base_helm_version to 2.44.0 (#534)

2.40.0 (the current default) predates commit ca796ca "fix(base): stop
the namespaces from deleting themselves on upgrade" (first released in
2.43.1): the gateways namespace was only rendered into the chart's
manifest while absent, with no protection against Helm pruning it once
omitted on a later upgrade -- deleting it and everything inside (both
Gateways, HPAs, PDBs, plus any cert-manager Certificates/Secrets a
caller's cert-manager-config release targets at it).

Hit this for real: upgrading a live GKE cluster from an older
nullplatform/base ref to 6.x's default (2.40.0) wiped the gateways
namespace on the second `helm upgrade`, taking down both public and
private ingress and the TLS certificates with it. 2.44.0 includes the
fix plus three more chart releases of unrelated, purely additive
changes (PDB selector fix, logs controller image bumps, IRSA/azure
annotation support) -- diffed against 2.40.0's values.yaml, nothing
removed or renamed.

Co-authored-by: sebas_correa <sebastian.correa@nullplatform.io>

* chore(6.x): release 6.19.1

* docs: regenerate READMEs for changed modules and update versions

* ci: reject new moving version defaults, and document what to pin (#539)

check-version-pinning.sh rejects a new moving default, a repository URL pinned
to a branch, or a helm_release with no version. Without it the pinning erodes on
its own: someone adds default = "latest" in six months and nothing notices.

It runs in pre-commit and again as a step in the terraform-lint workflow --
folded into the existing job rather than given its own, because it needs the
same changed-file list that job already computes and adding it there costs no
new check. That mirrors what the workflow already does for tflint, whose comment
calls itself the CI backstop for anyone committing with --no-verify.

Findings are keyed by path plus variable name rather than line number so the
baseline survives files moving, and the patterns use POSIX classes rather than
\s, which neither POSIX awk nor GNU grep supports and which would have made the
check silently useless on the runner. It found three violations a manual sweep
had missed.

Fifteen findings are baselined. Ten are the scopes and service-spec
repositories, which can be pinned without a breaking change and are deferred for
their own review. The other five are fixed by the separate "require an explicit
version" PR, whose base does not contain this file and so cannot remove the
entries itself.

VERSIONS.md lists every version to pin with its current value, and records the
one trap worth knowing before bumping an image by hand: k8s-traffic-manager
publishes a v2.0.2 built five months before 1.8.0, so the higher version number
is the older build from a line that was not continued.

Nothing here bumps a version automatically. Doing that would put the drift back
in documentation form and contradict the rule of pinning what you already run.

The root README pointed its Usage example at v6.11.0, eight releases behind this
line, because the generator rewrites every module README's source ref but never
touches the root one.

* feat: require an explicit version for everything the modules deploy (#540)

Every version these modules deploy loses its default and becomes a required
input. A default is a version somebody else chose, and it is why a moving
reference survived this long unnoticed: nobody had to look at it.

Thirteen inputs now have no default:

  base    nullplatform_base_helm_version, logging_controller_image_tag,
          control_plane_agent_image_tag
  agent   nullplatform_agent_helm_version, agent_traffic_manager_tag,
          agent_repos_scope_tag, image_tag
  eks     traffic_manager_version   (was "latest")
  aks     traffic_manager_version   (was "")
  gke     traffic_manager_version   (was "")
  commons cert_manager_version, prometheus_version
  service_definition  repository_branch   (was "main")

Each rejects an empty value and the well-known moving names. That guard cannot
tell a mutable ref from a fixed one by name alone -- a tag called beta or a
branch called develop still passes -- so it catches mistakes, not intent.

cert_manager_version was declared and never referenced: grep found one
occurrence, its own declaration, and the helm_release had no version argument at
all. prometheus had neither. Both tracked whatever their chart repository served
while the README showed a number.

The traffic manager image splits into agent_traffic_manager_repository, which
keeps a default, and the required tag -- the same shape nullplatform/base
already uses for its own images, so the two are no longer inconsistent within
one change.

agent_repos_scope is now the repository alone plus a required tag, and it
rejects an inline # so pasting the old value fails during plan instead of at
clone time inside the pod. agent_repos_extra requires a pinned ref on every
entry, which covers scopes-* and services-* without enumerating them.

repository_ref_type moves to "tags" as a consequence rather than a decision:
with a pinned ref required, "heads" would build refs/heads/v1.4.0, which does
not exist.

The agent module had no tests, so its rendered values were never asserted on.
It has them now, reading helm_release.agent.values[0] directly: an output for
the rendered values adds nothing the resource attribute does not already give,
and would put a test-only value in the module's public interface.

Each variable carries an "# example:" comment with the current value, so the
generated README shows something that can be pasted instead of a placeholder.

Migration required for every consumer. Thirteen inputs have no default and must
be set; see VERSIONS.md for the current value of each. The rule is to pin what
you are already running -- but four of these were previously unpinnable, so read
your cluster rather than copying the table: cert_manager_version,
prometheus_version, logging_controller_image_tag and traffic_manager_version on
eks all resolved to whatever their source served at deploy time.

Note on the commit type: this removes required-input defaults, so it is a
breaking change by any normal reading. It is typed feat: rather than feat!:
because the repository temporarily blocks breaking-change markers, which means
release-please will cut a minor for it. Whoever writes the release notes has to
add the warning by hand -- the changelog will not carry it.

* chore(6.x): release 6.20.0

* docs: regenerate READMEs for changed modules and update versions

* feat(api_key): add base type (#538)

Same roles as the agent key minus secrets-reader. nullplatform/base deploys the
logs controller and the control plane agent; neither reads secrets, so it does
not need that role, and until now it consumed the agent's credential.

Additive: no existing type changes.

* chore(6.x): release 6.21.0

* docs: regenerate READMEs for changed modules and update versions

* feat(istio): remove legacy istio-ingressgateway helm release (#543)

The classic istio-ingressgateway (Istio 'gateway' chart) is no longer
part of the ingress path: nullplatform-base declares Gateway API
resources (gatewayClassName: istio) and istiod auto-provisions the
gateway data-plane pods from them. The standalone ingressgateway ran
with zero routes configured (no Gateway/VirtualService selects it) and
only cost an unused cloud load balancer per cluster.

Removes the helm_release, its values template, the gateway-only
variables (service_type, ports, http2, replicas, cloud_provider/OCI
annotations) and their tests. istio-base and istiod are unchanged.

Note: applying this version uninstalls the istio-ingressgateway release
and deletes its cloud load balancer. Callers passing the removed
variables must drop those arguments.

* chore(6.x): release 6.22.0

* docs: regenerate READMEs for changed modules and update versions

* fix(base): expose gateway_api_crd_ref, default to v1.3.0 for Istio 1.27 (#544)

* fix(base): expose gateway_api_crd_ref for the Gateway API CRD install ref

Wires the new global.gatewayApiCrdRef chart value through so operators
can pin/bump the kubernetes-sigs/gateway-api ref applied by the base
chart's CRD installer Job (nullplatform/helm-charts, chart >= the
version that added global.gatewayApiCrdRef), instead of it being
hardcoded in the chart and frozen after the first install.

Defaults to the chart's previously hardcoded commit ref, so behavior
is unchanged until a caller overrides it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(base): default gateway_api_crd_ref to v1.3.0, matching Istio 1.27

Istio 1.27's version-pinned docs (istio.io/v1.27) document installing
Gateway API CRDs at v1.3.0, not the latest release. A CRD version
Istio's controller doesn't understand yet unlocks nothing, so this
should track what the pinned Istio version actually validates
against rather than the newest Gateway API tag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(base): default install_gateway_v2_crd to true

With the false default, the base chart's CRD-installer Job never ran,
so Gateway API CRDs were frozen at whatever was present on first
install and gateway_api_crd_ref had nothing to reconcile — matches
the underlying nullplatform-base chart's own default (true), which
this module was overriding.

Verified safe against a live cluster still pinned to chart 2.44.0
(pre-dating gatewayApiCrdRef): that version's Job only installs when
the CRD is missing, so flipping this default is a no-op there and
only starts mattering once callers move to a chart version carrying
the reconcile-on-every-upgrade Job from helm-charts#183 (which needs
--force-conflicts, see that PR).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sebas_correa <sebastian.correa@nullplatform.io>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* chore(6.x): release 6.22.1

* docs: regenerate READMEs for changed modules and update versions

* fix(asset): remove unused dimensions variable across ecr/s3/docker_server

dimensions was declared and (in s3/docker_server) wired into the
nullplatform_provider_config resource, but never used by any caller
in this repo. Removes it consistently from all three sibling asset
modules instead of leaving ecr as the odd one out.

* feat(agent)!: move deploy/DNS env vars from the agent pod to the worker

DNS_TYPE, DOMAIN, USE_ACCOUNT_SLUG, SERVICE_TEMPLATE, INITIAL_INGRESS_PATH,
and BLUE_GREEN_INGRESS_PATH are consumed when the worker renders a scope's
k8s deployment, not by the agent's own control loop. They move off the
agent pod's env and onto the worker container's env only. CLUSTER_NAME and
NAMESPACE stay on the agent (it still needs them to operate) and are also
added to the worker's env.

Adds four typed variables for worker fields that had no variable before:
worker_backend (default "kubernetes"), worker_allowed_registries (default
null — omitted, not [], since an empty list under a deny-by-default
guardrail could otherwise block the base scope images), worker_memory_limit,
and worker_service_account_name (falls back to service_account_name).

The worker values layer is now always emitted (previously gated on
var.worker != null, which is an unadopted, untested passthrough — gating on
it would have made these vars vanish entirely for the many callers who
don't set var.worker). var.worker remains available as an extra/override
layer merged on top of the computed base: its own patches are concatenated
with, not replaced by, the computed worker-container patch.

BREAKING CHANGE: DNS_TYPE, DOMAIN, USE_ACCOUNT_SLUG, SERVICE_TEMPLATE,
INITIAL_INGRESS_PATH, and BLUE_GREEN_INGRESS_PATH are no longer set on the
agent pod's own env — only on the worker's. Callers relying on those keys
being present in the agent pod's configuration.values must move that
dependency to the worker.

* fix(agent): drop worker_service_account_name, require real defaults

The worker container has no service-account identity of its own — it
always mirrors service_account_name, so a separate worker_service_account_name
variable was dead weight (declared, never read once locals.tf derived it
directly from service_account_name).

Also: service_account_name and worker_memory_limit now default to
concrete values ("nullplatform-agent" and "2Gi") instead of ""/null —
both must always resolve to something real rather than silently
omitting the field.

Updates the affected tests to match.

* refactor(agent): fold worker into the single agent values document

worker was a second, independently-constructed Helm values layer
(local.worker_values, yamlencode'd and appended to the helm_release's
values list), built from four separate variables (worker_backend,
worker_allowed_registries, worker_memory_limit, plus the already-removed
worker_service_account_name). That's more machinery than needed: worker
is already an existing variable of this module.

worker now renders as just another top-level key inside the same
templatefile-produced values document (the .tmpl.yaml renders it via
yamlencode(worker) line by line, so nested content like patches still
serializes correctly without hand-templating it). values goes back to a
single-element list.

Drops worker_backend/worker_allowed_registries/worker_memory_limit:
backend and allowedRegistries are now just keys the caller can set
inside var.worker itself (same as idleTTL already worked), with
"kubernetes" as the module's own default when unset. The worker
container's memory limit (2Gi) and serviceAccountName (mirrors
service_account_name) are module defaults on the computed patch; a
caller wanting a different memory limit adds their own patch targeting
the same container via var.worker.patches, which concatenates with
(not replaces) the computed one.

Updates tests accordingly (single values[0], var.worker for overrides
instead of the removed variables).

* fix(agent): drop unused cluster_name variable

cluster_name had no real consumer left in the module — it was only
read into the agent pod's own configuration.values (CLUSTER_NAME),
which the running chart doesn't actually use (verified against a live
deployment: the pod's declared env vars are NP_WORKER_*/AWS IRSA vars
only, no CLUSTER_NAME/NAMESPACE). NAMESPACE is dropped from the agent's
own configuration.values for the same reason; it stays on the worker's
env as K8S_NAMESPACE, which the worker orchestrator does read.

BREAKING CHANGE: cluster_name is no longer an accepted input. Callers
passing it will get "An argument named cluster_name is not expected
here" and must remove it.

* fix(agent): default worker allowedRegistries to the nullplatform ECR org

Without any allowedRegistries, the worker orchestrator denies every
dynamic package-exec (verified against a live deployment: "no worker
registries allowed and no pins ... every dynamic package-exec will be
refused"). Defaults allowedRegistries to
["public.ecr.aws/nullplatform/*"] so the platform's own scope images
keep pulling out of the box.

allowedRegistries set via var.worker is now concatenated with (not
replacing) that default, same as patches already worked — an
implementation adds its own registries instead of having to repeat
the default to avoid breaking the platform images.

* feat(scope_definition): default the oci_image package artifact to the platform's worker image

package.artifacts[].name now defaults to "worker-image", and
meta.registry/meta.repository default to "public.ecr.aws" and
"nullplatform/scopes/containers" for oci_image artifacts (the default
artifact type) when the caller's meta omits them. Only meta.digest
needs setting on every release; explicit values still win, and the
default never applies to non-oci_image artifacts (git_repository,
blob, oras_artifact have an unrelated meta shape).

slug intentionally keeps no type-level default — it already falls
back to the derived service specification slug via
coalesce(var.package.slug, local.service_slug) in package.tf, which a
hardcoded default would have silently broken for every scope type
other than "containers".

Verified the merge/for-expression logic in isolation (the module has
no test suite and its full resource graph needs live HTTP template
fetches, so a real `tofu test` wasn't practical here): defaults apply
correctly, explicit overrides win, and non-oci_image artifacts are
untouched.

* feat(agent): add TRAFFIC_CONTAINER_IMAGE to the worker's env

The worker executes traffic-management actions (blue-green switches,
etc.) that need to know the traffic-manager image, same as the agent
already does via its own configuration.values. Mirrors the existing
assembly (repository:tag from agent_traffic_manager_repository/_tag).

* fix(agent): give worker_env the same cloud_config/extra_envs layering as all_config

worker_env only carried its own hardcoded defaults, unlike the agent's
own all_config (default_config -> cloud_config -> extra_envs). A
caller overriding something via extra_envs (e.g. TRAFFIC_CONTAINER_IMAGE
pinned to a digest) had that reach the agent but not the worker, where
deployment actions actually execute.

Renames the raw defaults to worker_default_config and rebuilds
worker_env as the same three-layer merge all_config already does,
extra_envs last so it still wins.

* refactor(agent): dedupe the traffic-manager image expression

TRAFFIC_CONTAINER_IMAGE's assembly was duplicated verbatim in
default_config and worker_default_config. Extracted to
local.traffic_container_image, referenced by both.

* fix(agent): give the agent the deploy/DNS vars too, share one config map

Fixes a regression from the previous commit: worker_env had been
rebuilt as merge(default_config, cloud_config, extra_envs), but
default_config never carried DNS_TYPE/DOMAIN/USE_ACCOUNT_SLUG/
K8S_NAMESPACE/SERVICE_TEMPLATE/INITIAL_INGRESS_PATH/BLUE_GREEN_INGRESS_PATH
(they lived only in the now-removed worker_default_config) — so the
worker silently lost the very env vars the Istio template-path fix
depends on.

Resolution: fold those 7 keys into default_config so the agent and the
worker share one config map (all_config), instead of keeping two maps
in sync. worker_container_patch now reads env from local.all_config
directly; the separate worker_env/worker_default_config locals are
gone.

NAMESPACE is renamed to K8S_NAMESPACE (verified against a live worker
pod's actual env) since there's now only one map — this is what the
worker reads and nothing else in the agent's own args/config
depended on the literal key "NAMESPACE".

Updates the tests that encoded the old separation.

* Revert "fix(agent): give the agent the deploy/DNS vars too, share one config map"

This reverts commit 62a27d1a7f1bd719d43eb39ed54318bc59c23327.

* refactor(agent): split agent-pod env from worker env into two maps

default_config/all_config (the agent pod's own configuration.values) now
carries only NP_API_KEY/TAGS/IMAGE_TAG plus cloud_config; everything the
worker needs to render a scope's k8s deployment or run traffic-management
actions (DNS_TYPE, DOMAIN, USE_ACCOUNT_SLUG, K8S_NAMESPACE,
SERVICE_TEMPLATE, INITIAL_INGRESS_PATH, BLUE_GREEN_INGRESS_PATH,
TRAFFIC_CONTAINER_IMAGE, IMAGE_PULL_SECRETS, PRIVATE_GATEWAY_NAME,
PUBLIC_GATEWAY_NAME) moves to its own worker_default_env/worker_all_config,
merged with a worker-specific cloud_config (currently just azure) and
var.extra_envs the same way all_config is. Keeping two maps in sync (rather
than sharing one, as this branch briefly had) is intentional: the agent and
the worker have different consumers and don't need each other's variables.

Also drops agent_repos_scope/agent_repos_scope_tag/agent_repos_extra and
the AGENT_REPOS arg they fed — no longer needed — and restores
local.worker_defaults (backend="kubernetes") as the base of worker_final,
which had been dropped while reworking this, leaving the worker block
without a backend default.

Updates tests to match: two obsolete scope-repo tests removed, three
rewritten for the new split (traffic-manager image now asserted in the
worker's env, not the agent's flat config; deploy/DNS vars asserted absent
from the agent's own config).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(agent): restore a minimal agent_repo escape hatch, tidy formatting

agent_repos_scope/agent_repos_scope_tag/agent_repos_extra (and the
AGENT_REPOS arg) were dropped in the previous commit on the assumption
that every consumer had moved to worker_orchestrator — not verified,
and most of the known callers of scope_definition_agent_association
still use the legacy exec flow. Re-adds a single agent_repo variable
(with description/type) so the legacy
--command-executor-git-command-repos flow still has an escape hatch,
without restoring the old multi-repo/validation complexity that's no
longer needed.

Also cleans up stray double-blank-lines and misaligned spacing left
over from the previous commit's edits.

* feat(agent): agent_repo accepts a list, joined into AGENT_REPO

agent_repo is now list(string) instead of a single string, so the
legacy exec flow can point the agent at more than one repo (e.g. the
scopes repo plus a service-specific one) — joined with a comma and no
spaces, same format the old AGENT_REPOS used.

* fix(gcp/backend): self-provision a log bucket when log_bucket is unset

Resolves a Trivy finding on the PR (GCP-0077, MEDIUM): the state
bucket had no access logging by default. log_bucket was already an
opt-in variable, but requiring every caller to bring their own
centralized log bucket meant most installs shipped with logging off.

When log_bucket is left null/empty, the module now creates its own
dedicated log bucket and grants the project's GCS service agent
roles/storage.objectCreator on it (required for delivery — see
https://cloud.google.com/storage/docs/access-logs#delivery), so state
bucket access logging is on out of the box. Passing log_bucket still
works exactly as before and skips the self-provisioned bucket.

GCP-0066 (LOW, customer-managed encryption key) stays opt-in via
kms_key_name — enabling it by default would require every caller to
pre-create a KMS key and grant IAM on it, which is a heavier default
than warranted for a LOW-severity finding on a generic module.

Adds a log_bucket_name output and updates/extends the test suite for
the new default (26 tests, was 24).

* fix(gcp/backend): enable versioning on the self-provisioned log bucket

Closes a Trivy finding (GCP-0078) the previous commit introduced: the
auto-created log bucket had no versioning, unlike tf_state. Reuses
var.versioning_enabled, same as the state bucket.

* feat(service_definition): default package artifact name/type to impl/git_repository

Mirrors the scope_definition package-artifact defaults. A service
package is typically one artifact pointing at the service's own
implementation repo, so name defaults to "impl" and type to
"git_repository" (was "oci_image", copied from scope_definition's
mirror). Every other field — slug, version, default, tags, visible_to,
and the artifact's own meta/lookup/resource_id/resource_revision_id —
stays exactly as caller-configurable as before; only meta.url/reference
need setting per release.

Verified the defaulting behavior in isolation (this module has no test
suite, and its provider is pinned below nullplatform_package/artifact
support, so a real `tofu validate`/`test` wasn't practical here).

* docs(scope_definition,service_definition): digest/reference are type-specific, not interchangeable

The package variable's description and the lookup-artifact data
source's comment implied digest and reference were two equivalent
ways to pin any artifact revision. Verified against the live API
against a real oci_image artifact: it's stricter than that —

  - digest must match "sha256:<64-hex>" and only oci_image accepts it
  - reference (e.g. a tag) is what git_repository uses instead
  - each type rejects the other type's field with a validation error
    ("meta has unexpected field(s): reference" / "meta.digest must
    match sha256:<64-hex>")

Clarifies both spots in scope_definition and service_definition
(mirrors of each other) to say which field belongs to which type.

* fix(service_definition_agent_association): correct base_clone_path default

The nullplatform-agent image clones service repos under /home/agent/.np,
not /root/.np — the previous default produced a cmdline pointing at a path
that doesn't exist in the running agent container.

BREAKING CHANGE: base_clone_path now defaults to /home/agent/.np. Callers
relying on the old /root/.np default must now pass it explicitly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(agent): join --command-executor-git-command-repos with = like every other flag

The flag was rendered as "flag value" (space) instead of "flag=value" like
every sibling entry in default_args. Kubernetes passes each args[] entry as
a single argv token, and the agent image's entrypoint shell re-splits it on
that embedded space via unquoted expansion — the second word ($(AGENT_REPO))
then undergoes the shell's own command substitution (not k8s $(VAR)
substitution), fails silently, and vanishes, leaving the flag with no value.
Go's flag parser then aborts at startup: "flag needs an argument:
-command-executor-git-command-repos", crash-looping the agent pod.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(scope_definition,service_definition): make package oci_image meta defaults overridable

The registry/repository defaults for oci_image package artifacts were
hardcoded locals in scope_definition. Turn them into
var.package_oci_default_registry/var.package_oci_default_repository so an
implementing module can override them per instantiation, and add the same
merge to service_definition (which had no oci_image defaults at all).

Per-artifact meta still wins over the default when explicitly set — only
values omitted from meta fall back to the variable defaults.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(service_definition_agent_association): add worker_orchestrator support

Mirrors scope_definition_agent_association: when worker_orchestrator = true,
the channel routes package-exec to an agent that spawns the package's worker
image and runs its baked entrypoint (/app/packages/<package_slug>/entrypoint
by default), instead of the legacy git-clone exec flow. Required for a
service whose package registers an oci_image artifact to actually spawn an
np-worker pod.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(agent): worker serviceAccountName patch was hardcoded to the containers package

worker_container_patch (the only place serviceAccountName got set on a
worker pod) targeted package = "containers" unconditionally, so any other
worker-orchestrated package's pod fell back to the namespace's default
ServiceAccount — no IRSA identity, so sts:AssumeRole failed with "Unable to
locate credentials" (surfaced by the aws-s3-bucket service's worker trying
to assume its permissions role).

Add var.worker_orchestrated_packages (default ["containers"], preserving
current behavior) and emit one serviceAccountName-only patch per listed
package, separate from the containers-specific k8s-deployment env vars
patch. Callers add a package's slug to the list to give its worker the
agent's own ServiceAccount.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(service_definition_agent_association): drop legacy-exec-only required variables

repository_service_spec_repo and service_path had no default, forcing every
caller to set them even when worker_orchestrator = true — where they're
never read (the worker's baked entrypoint is used instead). Give both a
default of "" and add a precondition requiring repository_service_spec_repo
(the one whose absence would produce a broken cmdline) only when
worker_orchestrator = false, mirroring the existing package_slug-required-
when-true precondition.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(agent): worker memory limit was also hardcoded to the containers package

Same bug class as the serviceAccountName fix: the 2Gi memory limit only
applied via a patch targeting package = "containers", so any other
worker-orchestrated package's pod fell back to the chart's own thin default
(256Mi) — observed OOM-adjacent (255Mi/256Mi) on the aws-s3-bucket service's
worker mid-tofu-apply, which manifested as the agent's package-exec command
hanging forever with no output.

Fold the memory-limit patch into the same var.worker_orchestrated_packages
loop as serviceAccountName (new var.worker_memory_limit, default 2Gi
preserves current behavior for "containers"), leaving the containers-only
patch with just its k8s-deployment env vars.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(agent): port the safe parts of 6.x's #519 (helm reliability + deprecated inputs)

Cherry-picks the isolated, non-conflicting pieces of 6.x commit 747cb461
("restore the inputs and Helm flags dropped in v6.14.0"). The rest of that
commit (agent_repos_scope/agent_repos_extra restoration, ingress_type) is
skipped — this branch already replaced that system with agent_repo and
handles service_template/ingress paths differently; porting it wholesale
would revert today's worker_orchestrated_packages/worker_memory_limit work.

- create_namespace/atomic/cleanup_on_fail on helm_release.agent: the provider
  defaults all three to false, so a fresh install can die on a missing
  namespace and a failed upgrade sticks in "failed" with orphaned resources
  instead of rolling back — the exact "Error upgrading chart: context
  deadline exceeded" stuck-release state hit earlier in this same session.
- nrn/private_domain restored as deprecated no-op inputs, so a caller on an
  older module version passing them doesn't fail at init.
- all_config drops null values before reaching templatefile(), which
  otherwise fails with an error naming no variable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(api_key): add the internal option (port from 6.x #547)

Ports 6.x commit 1189c569 as-is — small, additive, opt-in (default null),
no overlap with anything touched on this branch. The keys this module
creates (agent, notification-channel) are platform plumbing and show up in
the API key listing alongside user-managed keys; var.internal keeps a key
out of that listing via the provider's internal mark (create-only, so
changing it replaces the key and rotates its secret).

Bumps the provider floor to ~> 0.0.101, which publishes the internal
attribute (already the version resolved elsewhere on this branch).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(agent): drop the nrn/private_domain deprecated no-ops

Their only purpose was 6.x backward compatibility (a caller on an older
module version passing them shouldn't fail at init) — this branch targets
main/7.x, a new major where breaking changes are allowed, and no caller
visible from this repo or its consumers passes either. Keeping an unused
compatibility shim for a constraint that doesn't apply to this line serves
no purpose.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Gonzalo Rojas <gonzalo.rojas@nullplatform.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: sebas_correa <sebastian.correa@nullplatform.io>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Federico Maleh <fedemaleh@yahoo.com.ar>
Co-authored-by: Javier …
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants