Skip to content

feat(gcp): private ingress and external-dns on gcp-0 - #1833

Merged
Smana merged 18 commits into
mainfrom
worktree-gcp-private-ingress
Aug 25, 2026
Merged

feat(gcp): private ingress and external-dns on gcp-0#1833
Smana merged 18 commits into
mainfrom
worktree-gcp-private-ingress

Conversation

@Smana

@Smana Smana commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Workstream 10 of the GCP support design: private ingress for gcp-0 — two Tailscale-backed Gateways serving *.priv.gcp.ogenki.io, TLS from the OpenBao PKI, and DNS records maintained in the private Cloud DNS zone.

Design: 2026-08-25-gcp-private-ingress-design.md · Plan: 2026-08-25-gcp-private-ingress.md

⚠️ Two things to read before merging

1. Deployed and verified — 7 of 8 criteria PASS, 1 PARTIAL. Full evidence in
2026-08-25-gcp-private-ingress-verification.md.
The whole GCP stack was built from scratch on gcp-0, verified, and torn down; the teardown
was confirmed against the GCP API rather than an exit code (0 instances, 0 clusters, 0
forwarding rules, 0 addresses, 0 disks, DNS zone gone, 0 tailnet devices left).

The one PARTIAL is criterion 7, the ACL split. 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. Recorded as unverified rather than asserted from configuration.

2. This must merge AFTER #1831. docs/gcp-bootstrap.md section 1 documents the GCS state backend from ADR-0018, which lands with that PR. A dependency sentence names it, so the doc is accurate either way — but merging in the right order is what lets that sentence read as provenance rather than as a forward reference.

What it builds

Three cluster-side components. The tailnet foundation already existed, so this is smaller than the parent design's table implies — the ACLs, tagOwners, autoApprovers, search domains and advertised routes all live in opentofu/shared/tailscale/ already, and this workstream changes no ACL at all.

Component Where
tailscale-operator security/gcp-0/tailscale-operator/ — chart + both ProxyClasses referenced by file from security/base/, with GCP's own OAuth ExternalSecret
Gateway API layer infrastructure/gcp-0/gapi/ — six cloud-neutral manifests from infrastructure/base/gapi/, excluding the AWS-shaped public Gateway
external-dns infrastructure/gcp-0/external-dns/google provider, private zone only, identity from a GCPWorkloadIdentity claim

The wildcard Certificate needed no GCP variant: it already asks the openbao ClusterIssuer that workstream 11 verified end-to-end on this cluster.

Using loadBalancerClass: tailscale rather than a GCP forwarding rule means the private gateways incur no cloud load-balancer charges.

What it deliberately does not build

Public certificates and public ingress. The parent design's open question 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 doesn't manage, and Let's Encrypt must resolve the challenge publicly. It stays open because it isn't 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.

Also no ProxyGroup — AWS runs two egress proxies; nothing on gcp-0 egresses through the tailnet yet.

The one change that touches AWS

Both Gateways' Tailscale hostnames become gateway-{general,admin}-priv-${cluster_name}, and the operator's own device becomes tailscale-operator-${cluster_name}. The tailnet is shared and a Tailscale hostname is tailnet-unique — two clusters claiming the same name doesn't error, it silently suffixes one of them.

Parameterising rather than suffixing only GCP is deliberate: a scheme where one cloud is the unlabelled default is exactly what ADR-0017 rejects, and is why the clusters were renamed aws-0/gcp-0 in #1832.

Cost, stated rather than buried: the AWS operator registers new Tailscale devices on next reconcile. external-dns rewrites the *.priv.aws.ogenki.io records after its sync interval, so there is a brief window where private AWS services are unreachable, and the superseded devices linger in the tailnet and need deleting by hand. Apply this in a window where that is acceptable.

Identity — the opposite call from workstream 11, deliberately

external-dns gets its permissions through the GCPWorkloadIdentity composition — its first consumer. Workstream 11 rejected that same composition for External Secrets and bound per-secret instead, because ProjectIAMMember is project-scoped and would have granted read of every secret in the project including the intermediate CA's private key.

external-dns's access genuinely is project-shaped: it must discover which zone owns a name, which needs dns.managedZones.list across the project and cannot be expressed per zone. xplane_dns_editor excludes zone create and delete. The comments say plainly that the role is project-wide and that domainFilters is a client-side filter, not a security boundary.

Defects found and fixed before any deploy

Six reviews ran — one per task plus a whole-branch review. They found two things static validation could not:

  • A Kustomization missing postBuild.substituteFrom (Critical). ${project_id} would have reached the API server literally, failing the GCPWorkloadIdentity claim's roles[] pattern and taking the whole infrastructure Kustomization down. Found independently by both the implementer and the reviewer.
  • healthChecks on a Gateway asserts existence, not readiness (Important). Gateway API v1 has no Ready condition and no top-level observedGeneration, so kstatus reports Current the moment the object exists — and the comment claimed the opposite. Now gated on the Programmed condition via healthCheckExprs, the form clusters/aws-0-llm-platform/security-llm-epi.yaml already documents.
  • The infrastructure Kustomization had no edge to crossplane-configuration (Important) while applying a Crossplane claim, because the plan asserted an edge that did not exist.
  • Plus four Minor comment/accuracy fixes, all cases of a comment describing something the code does not do.

Two validation blind spots surfaced, both filed as follow-ups rather than fixed here:

  1. ./scripts/validate-manifests.sh cannot catch a missing postBuild block — render-bundle.py substitutes its fixtures unconditionally, regardless of whether the real Kustomization declares substitution. The gate passed while the manifest was undeployable.
  2. CI never helm templates the GCP external-dns values: renderable HelmReleases are keyed by (namespace, name) and both clouds resolve to kube-system/external-dns, so the bundle renders AWS's values only. Nothing is broken today — the merged GCP values were rendered manually and produce --provider=google correctly.

Both are the same shape as the SPEC-007 problem that gate was built to remove.

Gates

./scripts/validate-manifests.sh   exit 0 — 1238 resources / 205 files, Valid: 1238, Invalid: 0, Skipped: 0
./scripts/validate-links.sh       exit 0
./scripts/validate-doc-claims.sh  exit 0 — 6 claims, 10 page checks
tofu validate (gke/init)          Success
kustomize build infrastructure/gcp-0/gapi   OK
kustomize build infrastructure/aws-0        OK   ← shared file touched
kustomize build security/aws-0              OK   ← shared file touched

Still open

  • Task 6 has not run. The eight success criteria in the design are unverified against a cluster.
  • One manual bootstrap step is outstanding: the Tailscale OAuth client in GCP Secret Manager. All three GCP prerequisites are now collected in docs/gcp-bootstrap.md rather than scattered across three files.
  • crossplane-configuration has no health gate on its Configuration package going Healthy, so the new edge orders but does not prove the XRD exists; the 1m retry is the backstop. A narrower window than before, not the same defect.

Smana added 15 commits August 25, 2026 10:41
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
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
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.
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.
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).
…zation

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).
…uster

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).
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).
- 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.
@github-actions

Copy link
Copy Markdown
Contributor

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

7 changed · 3 added · 0 removed

Rendered with kustomize build + helm template (source of truth = git), so Helm-expanded workloads are included. Shows what Flux will apply — not a diff against live cluster state (drift is alerted on separately), and not CRD-defaulted / webhook-mutated output. Secret values are redacted; per-render noise (webhook caBundles, checksum/* annotations, render timestamps) is normalized out.

🟡 changed — apps/v1/Deployment/tailscale/operator
--- a/apps/v1/Deployment/tailscale/operator
+++ b/apps/v1/Deployment/tailscale/operator
@@ -20,7 +20,7 @@
         - name: OPERATOR_INITIAL_TAGS
           value: tag:k8s-operator
         - name: OPERATOR_HOSTNAME
-          value: tailscale-operator
+          value: tailscale-operator-foobar
         - name: OPERATOR_SECRET
           value: operator
         - name: OPERATOR_LOGGING
🟢 added — cloud.ogenki.io/v1alpha1/GCPWorkloadIdentity/kube-system/xplane-external-dns
--- a/cloud.ogenki.io/v1alpha1/GCPWorkloadIdentity/kube-system/xplane-external-dns
+++ b/cloud.ogenki.io/v1alpha1/GCPWorkloadIdentity/kube-system/xplane-external-dns
@@ -0,0 +1,10 @@
+apiVersion: cloud.ogenki.io/v1alpha1
+kind: GCPWorkloadIdentity
+metadata:
+  name: xplane-external-dns
+  namespace: kube-system
+spec:
+  roles:
+  - projects/ogenki-435905/roles/xplane_dns_editor
+  serviceAccount:
+    name: external-dns
🟡 changed — external-secrets.io/v1/ExternalSecret/tailscale/tailscale-operator-oauth-client
--- a/external-secrets.io/v1/ExternalSecret/tailscale/tailscale-operator-oauth-client
+++ b/external-secrets.io/v1/ExternalSecret/tailscale/tailscale-operator-oauth-client
@@ -7,7 +7,7 @@
   dataFrom:
   - extract:
       conversionStrategy: Default
-      key: tailscale/k8s-operator/oauth-client
+      key: tailscale-k8s-operator-oauth
   refreshInterval: 1h
   secretStoreRef:
     kind: ClusterSecretStore
🟡 changed — gateway.networking.k8s.io/v1/Gateway/infrastructure/platform-tailscale-admin
--- a/gateway.networking.k8s.io/v1/Gateway/infrastructure/platform-tailscale-admin
+++ b/gateway.networking.k8s.io/v1/Gateway/infrastructure/platform-tailscale-admin
@@ -10,7 +10,7 @@
   infrastructure:
     annotations:
       tailscale.com/funnel: 'false'
-      tailscale.com/hostname: gateway-admin-priv
+      tailscale.com/hostname: gateway-admin-priv-foobar
       tailscale.com/tags: tag:admin
   listeners:
   - allowedRoutes:
🟡 changed — gateway.networking.k8s.io/v1/Gateway/infrastructure/platform-tailscale-general
--- a/gateway.networking.k8s.io/v1/Gateway/infrastructure/platform-tailscale-general
+++ b/gateway.networking.k8s.io/v1/Gateway/infrastructure/platform-tailscale-general
@@ -10,7 +10,7 @@
   infrastructure:
     annotations:
       tailscale.com/funnel: 'false'
-      tailscale.com/hostname: gateway-general-priv
+      tailscale.com/hostname: gateway-general-priv-foobar
       tailscale.com/tags: tag:k8s
   listeners:
   - allowedRoutes:
🟡 changed — helm.toolkit.fluxcd.io/v2/HelmRelease/kube-system/external-dns
--- a/helm.toolkit.fluxcd.io/v2/HelmRelease/kube-system/external-dns
+++ b/helm.toolkit.fluxcd.io/v2/HelmRelease/kube-system/external-dns
@@ -26,9 +26,10 @@
       zoneMatchParent: false
       zoneType: ''
     domainFilters:
-    - cluster.local
     - priv.cluster.local
     extraArgs:
+    - --google-project=ogenki-435905
+    - --google-zone-visibility=private
     - --gateway-namespace=infrastructure
     - --gateway-label-filter=external-dns=enabled
     - --min-event-sync-interval=30s
@@ -37,6 +38,8 @@
     logFormat: json
     logLevel: info
     policy: sync
+    provider:
+      name: google
     resources:
       limits:
         memory: 100Mi
🟡 changed — helm.toolkit.fluxcd.io/v2/HelmRelease/tailscale/tailscale-operator
--- a/helm.toolkit.fluxcd.io/v2/HelmRelease/tailscale/tailscale-operator
+++ b/helm.toolkit.fluxcd.io/v2/HelmRelease/tailscale/tailscale-operator
@@ -21,6 +21,7 @@
   values:
     installCRDs: false
     operatorConfig:
+      hostname: tailscale-operator-foobar
       resources:
         limits:
           memory: 256Mi
🟡 changed — kustomize.toolkit.fluxcd.io/v1/Kustomization/flux-system/infrastructure
--- a/kustomize.toolkit.fluxcd.io/v1/Kustomization/flux-system/infrastructure
+++ b/kustomize.toolkit.fluxcd.io/v1/Kustomization/flux-system/infrastructure
@@ -6,8 +6,13 @@
 spec:
   dependsOn:
   - name: namespaces
+  - name: crossplane-configuration
   interval: 1m0s
   path: ./infrastructure/gcp-0
+  postBuild:
+    substituteFrom:
+    - kind: ConfigMap
+      name: gke-gcp-0-vars
   prune: true
   sourceRef:
     kind: ExternalArtifact
🟢 added — kustomize.toolkit.fluxcd.io/v1/Kustomization/flux-system/infrastructure-gapi
--- a/kustomize.toolkit.fluxcd.io/v1/Kustomization/flux-system/infrastructure-gapi
+++ b/kustomize.toolkit.fluxcd.io/v1/Kustomization/flux-system/infrastructure-gapi
@@ -0,0 +1,35 @@
+apiVersion: kustomize.toolkit.fluxcd.io/v1
+kind: Kustomization
+metadata:
+  name: infrastructure-gapi
+  namespace: flux-system
+spec:
+  dependsOn:
+  - name: security-openbao
+  - name: security-tailscale
+  healthCheckExprs:
+  - apiVersion: gateway.networking.k8s.io/v1
+    current: status.conditions.filter(c, c.type == 'Programmed').all(c, c.status == 'True')
+    failed: status.conditions.filter(c, c.type == 'Programmed').all(c, c.status == 'False')
+    kind: Gateway
+  healthChecks:
+  - apiVersion: gateway.networking.k8s.io/v1
+    kind: Gateway
+    name: platform-tailscale-general
+    namespace: infrastructure
+  - apiVersion: gateway.networking.k8s.io/v1
+    kind: Gateway
+    name: platform-tailscale-admin
+    namespace: infrastructure
+  interval: 4m0s
+  path: ./infrastructure/gcp-0/gapi
+  postBuild:
+    substituteFrom:
+    - kind: ConfigMap
+      name: gke-gcp-0-vars
+  prune: true
+  retryInterval: 30s
+  sourceRef:
+    kind: ExternalArtifact
+    name: infra-artifact
+  timeout: 10m0s
🟢 added — kustomize.toolkit.fluxcd.io/v1/Kustomization/flux-system/security-tailscale
--- a/kustomize.toolkit.fluxcd.io/v1/Kustomization/flux-system/security-tailscale
+++ b/kustomize.toolkit.fluxcd.io/v1/Kustomization/flux-system/security-tailscale
@@ -0,0 +1,25 @@
+apiVersion: kustomize.toolkit.fluxcd.io/v1
+kind: Kustomization
+metadata:
+  name: security-tailscale
+  namespace: flux-system
+spec:
+  dependsOn:
+  - name: security-openbao
+  healthChecks:
+  - apiVersion: helm.toolkit.fluxcd.io/v2
+    kind: HelmRelease
+    name: tailscale-operator
+    namespace: tailscale
+  interval: 4m0s
+  path: ./security/gcp-0/tailscale-operator
+  postBuild:
+    substituteFrom:
+    - kind: ConfigMap
+      name: gke-gcp-0-vars
+  prune: true
+  retryInterval: 30s
+  sourceRef:
+    kind: ExternalArtifact
+    name: security-artifact
+  timeout: 8m0s

Smana added 3 commits August 25, 2026 13:31
`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.
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 b0e478b into main Aug 25, 2026
9 checks passed
@Smana
Smana deleted the worktree-gcp-private-ingress branch August 25, 2026 12:07
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