Skip to content

refactor(gcp): per-cloud OpenTofu state — GCP in GCS, AWS in S3 - #1831

Merged
Smana merged 2 commits into
mainfrom
worktree-gcp-state-split
Aug 25, 2026
Merged

refactor(gcp): per-cloud OpenTofu state — GCP in GCS, AWS in S3#1831
Smana merged 2 commits into
mainfrom
worktree-gcp-state-split

Conversation

@Smana

@Smana Smana commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Reverses the single-bucket state decision recorded in opentofu/gcp/network/backend.tf on 2026-08-23. ADR: 0018.

Why

The recorded reason for putting GCP state in the AWS S3 bucket was sound but argued against the wrong thing. The GCS bucket it replaced sat inside ogenki-435905 — the very project whose resources it tracked — so deleting that project would have taken the state describing it along too. That is an argument against a state bucket in the workload project, not against GCS.

The S3 move fixed the self-reference by introducing a different coupling, and the comment named it as an accepted cost: "running the GCP stacks now requires AWS credentials as well as GCP ones, and an S3 outage blocks GCP applies."

Three things make it worth reversing:

  1. GCP stacks need GCP credentials only. The same cross-cloud coupling was rejected on principle elsewhere — the GCP OpenBao design turned down sharing AWS's OpenBao precisely because it would make GCP certificate issuance depend on AWS. This was that coupling underneath all of it.
  2. Teardown survives an AWS outage. A suspended AWS account previously blocked GCP destroy as well as apply, and teardown is the operation most needed when something is already wrong.
  3. openbao/management state holds a live credential — cert-manager's AppRole secret_id, as its own secrets.tf says. Under a shared bucket, an AWS-side compromise handed over a working GCP credential.

Project ogenki-tfstate holds the bucket and nothing else, closing the original loop properly instead of relocating it.

Scope

Stacks Backend Where
opentofu/gcp/** gcs gs://ogenki-cloud-native-ref-tfstate, project ogenki-tfstate, europe-west4
opentofu/aws/** s3 unchanged
opentofu/shared/** s3 unchanged — the tailnet belongs to neither cloud, the one case the single-bucket rationale gets right

Safe because no GCP stack reads AWS state and no AWS stack reads GCP state. Every terraform_remote_state in the GCP tree is GCP → GCP (gke/init and openbao/cluster read gcp/network; gke/configure reads gke/init), so splitting severed no data flow. Checked before touching anything.

Timing — why now and not later

Every GCP state file contained resources: 0, because the platform had just been torn down after verifying workstream 11:

network            resources=0 serial=35
gke/init           resources=0 serial=29
gke/configure      resources=0 serial=17
openbao/cluster    resources=0 serial=17
openbao/management resources=0 serial=3

State migration is normally the risky half of a change like this. In that window there was nothing to lose track of, so the new backends were initialised clean rather than copied. The window closes the moment GCP runs anything again.

The cost, stated rather than buried

A second hand-created bootstrap prerequisite, neither IaC-managed. That was the second recorded reason for one bucket and it does not disappear — it gets documented in network/backend.tf with the commands, the way the Cloud KMS key ring already is.

The old GCP state objects remain in S3 under cloud-native-ref/gcp/. They hold no resources, but they are now misleading — anything pointing at those keys reads empty state. Worth deleting once this settles; left in place here rather than destroying state objects in a refactor PR.

Evidence

tofu init (all 5 stacks)          OK — backend=gcs confirmed from .terraform/terraform.tfstate
gs://…/…/default.tfstate          5 objects present
tofu validate (all 5 stacks)      Success
trivy config opentofu/gcp         exit 0
./scripts/validate-links.sh       exit 0
./scripts/validate-doc-claims.sh  exit 0 — 4 claims, 8 page checks

Bucket settings verified: versioning=True, uniform_bucket_level_access=true, public_access_prevention=enforced.

GCP state lived in the AWS S3 bucket alongside every other stack. The
reason recorded at the time was sound but argued against the wrong thing:
the GCS bucket it replaced sat INSIDE ogenki-435905, the very project
whose resources it tracked, so deleting that project would have taken the
state describing it along too. That is an argument against a state bucket
in the WORKLOAD project, not against GCS.

Moving to S3 fixed the self-reference by introducing a different coupling,
and the backend comment named it as an accepted cost: "running the GCP
stacks now requires AWS credentials as well as GCP ones, and an S3 outage
blocks GCP applies."

Three things make that worth reversing:

1. GCP stacks now need GCP credentials only. The same cross-cloud coupling
   was rejected on principle elsewhere in this platform -- the GCP OpenBao
   design turned down sharing AWS's OpenBao precisely because it would
   make GCP certificate issuance depend on AWS. This was that coupling
   under all of it.
2. Teardown survives an AWS outage. A suspended AWS account previously
   blocked GCP `destroy` as well as `apply`, and teardown is the operation
   most needed when something is already wrong.
3. openbao/management's state holds cert-manager's live AppRole secret_id
   -- its own secrets.tf says so. Under a shared bucket an AWS-side
   compromise handed over a working GCP credential.

Project `ogenki-tfstate` holds the bucket and nothing else, which closes
the original self-reference loop properly rather than relocating it.
`opentofu/shared/*` deliberately stays in S3: the tailnet belongs to
neither cloud, and that is the one case the single-bucket rationale gets
exactly right.

Safe because no GCP stack reads AWS state and no AWS stack reads GCP
state -- every terraform_remote_state in the GCP tree is GCP -> GCP, so
splitting severed no data flow. Verified before touching anything.

Done NOW rather than later on purpose. Every GCP state file contained
`resources: 0` (network serial=35, gke/init 29, gke/configure 17,
openbao/cluster 17, openbao/management 3) because the platform had just
been torn down after verifying workstream 11. State migration is normally
the risky half of this change; in that window there was nothing to lose
track of, so the new backends were initialised clean rather than copied.
That window closes as soon as GCP runs anything again.

The cost, stated rather than buried: a SECOND hand-created bootstrap
prerequisite, neither IaC-managed. Documented in network/backend.tf with
the commands, the way the Cloud KMS key ring already is.

ADR-0018 records the decision and supersedes the single-bucket rationale.

Evidence:
- tofu init against GCS -> all 5 stacks OK, backend=gcs confirmed from
  .terraform/terraform.tfstate; 5 default.tfstate objects present in
  gs://ogenki-cloud-native-ref-tfstate
- tofu validate -> Success on all 5 stacks
- trivy config opentofu/gcp -> exit 0
- ./scripts/validate-links.sh -> exit 0
- ./scripts/validate-doc-claims.sh -> exit 0, 4 claims / 8 page checks
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Rendered manifest diff — this PR vs main (desired state)

No changes to the rendered desired state. ✅

Smana added a commit that referenced this pull request Aug 25, 2026
Task 6 ran end to end against a real cluster and everything was torn down.
7 of 8 criteria PASS, 1 PARTIAL.

The two fixes this branch's reviews produced were both exercised live:

- healthCheckExprs on the Gateways. `infrastructure-gapi` reached Ready
  after both Gateways were genuinely `Programmed=True`, not on object
  creation -- which is what the old `healthChecks` form would have done.
- The crossplane-configuration edge. `infrastructure` visibly waited on it
  rather than racing the Configuration package, so the GCPWorkloadIdentity
  claim applied against an installed CRD.

Headline result: external-dns on the google provider created both the A
record and its TXT registry (`owner=gcp-0`, `resource=httproute/apps/probe`)
and PRUNED both within ~30s of the route being deleted. That is the
GCPWorkloadIdentity composition's first consumer working -- claim Ready in
kube-system, identity by subject, no key material anywhere.

Criterion 7 is PARTIAL and recorded as such rather than claimed. The admin
Gateway accepts a kube-system route and serves it over the tailnet with a
valid chain, but proving a NON-admin device is denied needs a tailnet
device outside group:admin, and the only device available belongs to the
tailnet owner. Asserting the denial from configuration would not be
evidence.

Two defects the deploy found, neither visible to static validation:

- opentofu/gcp/openbao/management/variables.tfvars was never committed, so
  that stack cannot deploy from a clean checkout. Fixed in 9dc52b2; it is
  a defect in merged main (#1830), invisible because the file existed
  untracked in the worktree where the stack was first verified.
- `kubectl wait --for=condition=Accepted httproute/...` times out on a
  healthy route -- an HTTPRoute's conditions live under status.parents[],
  not at the top level. Same shape as the Gateway healthChecks finding:
  a readiness assertion that silently checks nothing.

Also records two credentials the plan never listed as prerequisites: AWS
credentials (needed by a GCP-only deploy purely because GCP state still
lives in S3 -- the coupling #1831 removes, demonstrated rather than
argued) and TF_VAR_tailscale_api_key.

Teardown verified against the GCP API rather than an exit code: 0
instances, 0 clusters, 0 forwarding rules, 0 addresses, 0 disks, the DNS
zone gone, and 0 tailnet devices left behind. No pvc-* disk leak this time,
unlike the previous GCP teardown.
@Smana
Smana merged commit 5e533bc into main Aug 25, 2026
9 checks passed
@Smana
Smana deleted the worktree-gcp-state-split branch August 25, 2026 12:05
Smana added a commit that referenced this pull request Aug 25, 2026
* docs(gcp): design for private ingress and external-dns on GCP

Workstream 10 of the GCP support design, scoped down from what the parent
design's table implied.

Two things shrank it. The tailnet singletons -- ACLs, tagOwners for
tag:k8s / tag:admin / tag:k8s-operator, autoApprovers, search domains,
advertised routes -- were already extracted into opentofu/shared/tailscale
so neither cloud authorises the other's devices. So this workstream
changes no ACL at all. And the Gateway API layer is already
cloud-neutral: six of the seven manifests in infrastructure/base/gapi
render correctly on GCP unchanged, including the wildcard Certificate,
which asks the `openbao` ClusterIssuer that workstream 11 verified.

What is left is three cluster-side components: the tailscale-operator,
the Gateway API layer consumed by file, and external-dns with the google
provider.

PUBLIC certificates are deliberately excluded. The parent design's open
question already observed that DNS-01 has nothing to solve against on
GCP -- the Cloud DNS zone is private, cloud.ogenki.io is a Route53 zone
this repo does not manage, and Let's Encrypt must resolve the challenge
publicly. It stays open because it is not blocking: GCP has no public
endpoint to put a certificate on, and workstream 12 owns that. Settling a
registrar-level decision on speculation is the failure mode here.

external-dns gets its permissions through the GCPWorkloadIdentity
composition, which is the opposite call from workstream 11 and
deliberately so. There, External Secrets needed exactly two named secrets
and the composition's project-scoped ProjectIAMMember would have granted
read of every secret in the project, including the intermediate CA's
private key -- so it was bound per secret and the composition was left
without a consumer. external-dns's access genuinely IS project-shaped: it
must discover which zone owns a name, which needs managedZones.list
across the project and cannot be expressed per zone.

Records two costs rather than burying them: the Gateway hostname change
touches AWS, and manual bootstrap steps are now accumulating (Cloud KMS
key ring, the OpenTofu state bucket and its project, and now a Tailscale
OAuth client) -- three undocumented steps is how a repository stops being
reproducible, so the design requires them collected into one procedure.

./scripts/validate-links.sh -> exit 0

* docs(gcp): implementation plan for private ingress on gcp-0

Six tasks. Task 1 parameterises the Gateway Tailscale hostnames (the one
change touching AWS-consumed manifests), 2 creates the bootstrap OAuth
client plus a single consolidated docs/gcp-bootstrap.md, 3-5 build the
three cluster-side components, and 6 verifies live against all eight
success criteria and tears everything down.

Also fixes two broken links in the design, caught by validate-links.sh
rather than by reading: ADR-0005's filename was invented
(0005-gke-standard-with-cilium.md; the real one is
0005-gke-standard-self-managed-cilium.md), and ADR-0018 is on the
unmerged #1831 branch so it cannot be linked from here yet.

The plan carries three things forward that this session paid for:

- Ordering edges are dependsOn plus health checks, not retry timers.
  Workstream 11 shipped a layer whose ExternalSecrets sat unsynced behind
  an admission webhook with no endpoints, backing off exponentially while
  Flux reported Ready.
- Verification reads logs and re-queries the cloud API, never exit codes.
  A teardown in this repo has already reported success while destroying
  nothing.
- Task 6 ends with a verified teardown, per the standing rule that no test
  infrastructure survives.

./scripts/validate-links.sh -> exit 0

* docs(gcp): pre-flight corrections to the private-ingress plan

Three defects found by the SDD pre-flight scan before any task was
dispatched, two of them in the plan I wrote:

- Task 5 patched `global.imageRegistry` and justified it with a claim that
  the base makes GCP image pulls depend on an AWS registry. Verified against
  `helm show values external-dns/external-dns --version 1.21.1`: neither
  `global.imageRegistry` nor a top-level `aws:` key is a chart value at all
  (`global` supports only `imagePullSecrets`). Both are inert on both
  clouds. The override is dropped and the comment now says what is true.
- Task 6's ACL-split criterion curled `hubble-gcp-0.priv.gcp.ogenki.io`, but
  Hubble UI is not deployed on gcp-0 -- the criterion would have tested a
  hostname that never exists. Replaced with a purpose-made admin probe in
  kube-system, which is in the admin Gateway's allowedRoutes where `apps` is
  not.

The scan table and all three rulings are in the plan's SDD ledger.

* refactor(gapi): per-cluster Tailscale hostnames on the private Gateways

* docs(gapi): fix stale Tailscale hostname example, pin it as a doc claim

* feat(gcp): Tailscale OAuth bootstrap secret, and one bootstrap doc

* feat(gcp): tailscale-operator on gcp-0

* feat(gcp): Tailscale Gateways and the private wildcard certificate

* feat(gcp): external-dns on the google provider via GCPWorkloadIdentity

* fix(gcp): substitute vars in the infrastructure Kustomization

external-dns's claim and HelmRelease under infrastructure/gcp-0/ are the
first manifests on that path to reference ${var}; without postBuild the
literal text reaches the API server and fails the GCPWorkloadIdentity
XRD's roles[] pattern.

* fix(gcp): gate Gateway readiness on Programmed, not existence

healthChecks on a Gateway only asserts the object exists -- Gateway API v1 has
no Ready condition and no top-level observedGeneration, so kstatus reports
Current the instant the Gateway is created, before the operator has serviced
the LoadBalancer, before Cilium has accepted the GatewayClass, before the TLS
secret resolves. Add healthCheckExprs gating on the Programmed condition
Cilium actually sets, and correct the comment that claimed the literal
healthChecks form already proved the whole chain.

Same defective block existed in the plan (the durable artifact once
.superpowers/ is gitignored away); fixed there too.

Final review finding 1 (Important).

* fix(gcp): add crossplane-configuration edge to infrastructure Kustomization

infrastructure/gcp-0's path now applies a GCPWorkloadIdentity claim (for
external-dns) whose CRD and gke-environment EnvironmentConfig both come from
the crossplane-configuration Kustomization, three stages down that chain. The
edge was missing, so a fresh cluster's `infrastructure` reconciles before the
Configuration package lands and fails with `no matches for kind
"GCPWorkloadIdentity"` until it self-heals on retry. Add
`- name: crossplane-configuration` to dependsOn (minimum fix; not the
per-claim-Kustomization restructure the review floated as "better" -- out of
scope for this wave).

Also rewrite the two comments in the same file that described this path as
holding "only a ComputeClass" -- it now also holds a namespaced HelmRelease and
a namespaced Crossplane claim, which is exactly why the missing edge went
unnoticed. Correct the plan's matching false sentence ("Its claim does need
Crossplane, which `infrastructure` already sequences behind") -- that
sentence is why nobody added the edge in the first place.

Final review findings 2 and 3 (Important, Minor).

* fix(security): parameterize tailscale-operator device hostname per cluster

The chart default (OPERATOR_HOSTNAME=tailscale-operator, verified via `helm
template tailscale/tailscale-operator --version 1.90.6`) is not tailnet-unique.
Both aws-0 and gcp-0 now run this shared HelmRelease against one shared
tailnet, so gcp-0's operator claims a hostname aws-0's operator already holds
and Tailscale silently suffixes one of them -- the same collision the Gateway
hostnames were already parameterized against.

Fixed in the shared HelmRelease, not a GCP-only patch, so cluster_name
substitutes identically on both clouds and the AWS operator device is renamed
once, in the same cutover window the Gateway rename already requires.

Final review finding 4 (Minor).

* fix(docs): correct two comments that overstated what they describe

infrastructure/gcp-0/external-dns/kustomization.yaml: the base sets no
`provider` key at all (`provider: aws` is the chart default, not something the
base writes) and global.imageRegistry is documented three files away, in this
directory's own helmrelease.yaml, as inert on chart 1.21.1 -- so this comment
contradicted its sibling file. Reduce the parenthetical to what the base
actually sets: two domain filters and an inert `aws:` block.

security/gcp-0/tailscale-operator/kustomization.yaml: record, rather than
silently leave unconverged, that the two ProxyClasses' CRD arrives from
crds-tailscale-operator, a child Flux Kustomization the parent `crds` does not
wait on -- the same gap clusters/gcp-0/security/security-openbao.yaml already
documents for the ExternalSecret CRDs, in the same shape.

Final review findings 5 and 6 (Minor).

* fix(docs): three deferred-minor accuracy fixes on this branch

- private-access.md: the mechanism is postBuild.substituteFrom, not
  postBuild.substitute.
- design doc: the OAuth Secret Manager entry is `tailscale-k8s-operator-oauth`
  everywhere else (plan, docs/gcp-bootstrap.md, the ExternalSecret,
  variables.tf) -- drop the stray `-gcp` suffix.
- plan Task 5 Step 5 "Expected": the render DOES contain the base's `aws:`
  block and `zoneType`, deliberately (ruling 2 -- both are inert on chart
  1.21.1, so patching them out would be a no-op edit). The plan claimed the
  opposite, which would send a future reader to "fix" correct code.

Final review's three deferred-minor list, marked MUST-FIX.

* fix(gcp): track the openbao/management tfvars, missing since #1830

`opentofu/gcp/openbao/management/variables.tfvars` was never committed, so
that stack cannot be deployed from a clean checkout:

  Error: Failed to read variables file
  Given variables file variables.tfvars does not exist.

`*.tfvars` is gitignored repo-wide (.gitignore:55). Every other GCP stack
-- network, gke/init, gke/configure, openbao/cluster -- has its
variables.tfvars force-added past that rule. This one was missed, and the
gap was invisible because the file existed untracked in the worktree where
the stack was originally written and verified.

Found by deploying workstream 10 from a fresh worktree: network, both GKE
stages and openbao/cluster all applied, then management failed instantly.
It is a defect in merged main (#1830), not in this branch -- carried here
because it blocks this branch's own live verification, and splitting it
into its own PR would mean workstream 10 could not be verified at all.

Contents are identical to the file the stack was verified against: only
project_id, which has no default; everything else stays on the defaults in
variables.tf.

* docs(gcp): live verification of private ingress on gcp-0

Task 6 ran end to end against a real cluster and everything was torn down.
7 of 8 criteria PASS, 1 PARTIAL.

The two fixes this branch's reviews produced were both exercised live:

- healthCheckExprs on the Gateways. `infrastructure-gapi` reached Ready
  after both Gateways were genuinely `Programmed=True`, not on object
  creation -- which is what the old `healthChecks` form would have done.
- The crossplane-configuration edge. `infrastructure` visibly waited on it
  rather than racing the Configuration package, so the GCPWorkloadIdentity
  claim applied against an installed CRD.

Headline result: external-dns on the google provider created both the A
record and its TXT registry (`owner=gcp-0`, `resource=httproute/apps/probe`)
and PRUNED both within ~30s of the route being deleted. That is the
GCPWorkloadIdentity composition's first consumer working -- claim Ready in
kube-system, identity by subject, no key material anywhere.

Criterion 7 is PARTIAL and recorded as such rather than claimed. The admin
Gateway accepts a kube-system route and serves it over the tailnet with a
valid chain, but proving a NON-admin device is denied needs a tailnet
device outside group:admin, and the only device available belongs to the
tailnet owner. Asserting the denial from configuration would not be
evidence.

Two defects the deploy found, neither visible to static validation:

- opentofu/gcp/openbao/management/variables.tfvars was never committed, so
  that stack cannot deploy from a clean checkout. Fixed in 9dc52b2; it is
  a defect in merged main (#1830), invisible because the file existed
  untracked in the worktree where the stack was first verified.
- `kubectl wait --for=condition=Accepted httproute/...` times out on a
  healthy route -- an HTTPRoute's conditions live under status.parents[],
  not at the top level. Same shape as the Gateway healthChecks finding:
  a readiness assertion that silently checks nothing.

Also records two credentials the plan never listed as prerequisites: AWS
credentials (needed by a GCP-only deploy purely because GCP state still
lives in S3 -- the coupling #1831 removes, demonstrated rather than
argued) and TF_VAR_tailscale_api_key.

Teardown verified against the GCP API rather than an exit code: 0
instances, 0 clusters, 0 forwarding rules, 0 addresses, 0 disks, the DNS
zone gone, and 0 tailnet devices left behind. No pvc-* disk leak this time,
unlike the previous GCP teardown.
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.

1 participant