diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 076652e..1a668ea 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,6 +6,9 @@ on:
jobs:
make:
+ # Job name doubles as the status-check context required by the
+ # "PR Required" ruleset — keep it in sync if you rename it.
+ name: CI
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
diff --git a/docs/migrations/sso/README.md b/docs/migrations/sso/README.md
new file mode 100644
index 0000000..be75b7e
--- /dev/null
+++ b/docs/migrations/sso/README.md
@@ -0,0 +1,180 @@
+# Migration: App Login — LDAP/AD Simple-Bind to Cloud IdP SSO (OIDC)
+
+Mock interview solution artifact for the scenario in [`objective.md`](./objective.md).
+
+## Assumptions
+
+1. **Cloud IdP stays generic.** Okta / Azure AD (Entra ID) / Google Workspace are interchangeable
+ for this plan's purposes, matching the "e.g." in the objective. Where the real directory-sync
+ mechanism differs by vendor (Okta AD Agent, Microsoft Entra Connect / Cloud Sync, Google Cloud
+ Directory Sync), that's a swap-in detail, not a design decision.
+2. **LDAP is never decommissioned.** Only the *app's direct* LDAP dependency (simple-bind +
+ group queries on every login) goes away. The objective is explicit that LDAP remains the
+ durable source of truth for other on-prem systems — directory sync is deployed once (phase 1)
+ and stays in place through and after phase 4.
+3. **MFA is out of scope.** The cloud IdP makes MFA trivial to add later, but the objective's
+ intended state doesn't ask for it here — a natural fast-follow, not a success criterion for
+ this migration.
+4. **Authorization shape is unchanged.** The same group names/IDs should drive the same access
+ decisions before and after — only the *source* of the group claim moves (LDAP query → OIDC
+ token claim). Any group renaming/remapping introduced by the sync tooling is a defect to catch
+ in validation, not an intended side effect.
+5. **"Sessions must not all invalidate at once" → rolling, expiry-based cutover.** No forced mass
+ logout at any point. LDAP-authenticated sessions ride out their natural TTL; phase 4 sets a
+ hard sunset date so the tail doesn't drag indefinitely — bounded, but never instant.
+6. **Rollout is a user-facing choice, not a feature flag.** Both login paths are exposed to
+ *every* user at once via a login-method chooser rather than cohort targeting; "progressive"
+ means *which option is the default button* (phase 3), not *who is allowed to see it*. Cost of
+ that trade-off: no per-cohort staged exposure, so phase 2 reaches 100% of users on day one —
+ deployment-level canarying (rolling the app build to a subset of instances/traffic, same as
+ any other release) is the only risk-reduction lever left.
+
+## Preflight
+
+No infrastructure or app change — this is the checklist that confirms the assumptions above are
+actually true before anything is touched. Topology is still
+[the objective's existing infrastructure](./objective.md#existing-infrastructure).
+
+- Inventory every other consumer of this LDAP instance by name (the objective states there are
+ others).
+- Dump the current LDAP schema: user attributes in use, group structure, nesting depth. This
+ becomes the source-of-truth diff every later phase's sync validation is checked against.
+- Confirm an outbound network path exists from wherever a directory-sync agent would run to
+ LDAP (most sync agents are on-prem/outbound-only — don't assume no firewall change is needed).
+- Capture the current session TTL/max-age config — phase 4's sunset date is only meaningful
+ relative to this number.
+- Confirm the app's current authorization logic: exactly which LDAP group attribute it reads and
+ how group membership maps to permissions, so phase 2's claim mapping can be checked against it.
+
+## Phase summary
+
+| Phase | Topology change | User-facing change | Reversible? |
+|---|---|---|---|
+| 1 | + Directory Sync Agent, + Cloud IdP (populated, unused) | none | yes — nothing reads from the IdP yet |
+| 2 | App exposes a login-method chooser | every user can pick LDAP or SSO; LDAP stays the default | yes — revert to a single login form |
+| 3 | none (same components as phase 2) | SSO becomes the default option; LDAP demoted to a secondary "sign in another way" link | yes — swap the default back |
+| 4 | LDAP option and its code path removed from the login screen | everyone authenticates via SSO; stale LDAP sessions get one final sunset deadline | no — point of no return for the app's LDAP code path (LDAP server itself is untouched) |
+
+---
+
+## Phase 1 — Establish directory sync (LDAP → IdP, one-way)
+
+Pure plumbing: the IdP starts holding a mirror of identity/group data, and nothing authenticates
+against it yet.
+
+**Pre-validation**
+- Dry-run the sync agent against a non-prod or scoped OU first; confirm it is read-only against
+ LDAP (one-way, per the objective — a misconfigured bidirectional sync writing back to LDAP
+ would affect every other consumer of this instance, not just this app).
+- Confirm the sync agent's service-account credentials have the minimum LDAP read scope needed
+ (bind + read on user/group OUs), not domain-admin-equivalent access.
+
+```mermaid
+graph LR
+ User --> App@{shape: procs}
+ App --> LDAP[(Self-hosted LDAP/AD)]
+ LDAP -- "reads users + groups" --> DirSync{{Directory Sync Agent}} -- one-way sync --> IdP{{Cloud IdP}}
+```
+
+**Post-validation**
+- Reconcile: diff the full user/group set from the preflight dump against the IdP, attributes
+ included — this dataset drives every phase after this one.
+- Confirm sync latency/cadence is compatible with how quickly the org expects group-membership
+ changes (e.g. offboarding) to take effect once phase 4 lands and the IdP is authoritative.
+- Confirm nothing in the app or its auth path changed — this phase should be invisible to users;
+ any observed behavior change here is a bug.
+
+## Phase 2 — Login-method chooser: both paths available to everyone
+
+The login screen offers both paths to 100% of users from the moment this ships, with LDAP still
+presented as the default.
+
+**Pre-validation**
+- Confirm the OIDC client registration's requested scopes/claims actually return a group claim,
+ and that its values match the group names captured in preflight name-for-name — this is the
+ single most likely silent authorization regression in this whole migration.
+- Confirm session issuance is unified: both paths must set the same cookie attributes/claims
+ shape, or downstream code branches on "how did this session get created" and that branching
+ outlives the migration as permanent tech debt.
+- Since there's no user-level flag, the only staged-exposure lever left is the deployment itself
+ — confirm the app release carrying this change can be canaried to a subset of instances/traffic
+ the normal way, and that a canary failure is cheap to roll back.
+- Confirm the chooser UI itself is unambiguous (which button is "the old way," which is SSO) —
+ with no cohort restricting who sees it, a confusing screen becomes a support-ticket spike on
+ day one, not a contained pilot-group problem.
+
+```mermaid
+graph LR
+ User --> App@{shape: procs}
+ App -- "LDAP option (default)" --> LDAP[(Self-hosted LDAP/AD)]
+ App -. "SSO option (opt-in)" .-> IdP{{Cloud IdP}}
+ LDAP -- "reads users + groups" --> DirSync{{Directory Sync Agent}} -- one-way sync --> IdP
+```
+
+**Post-validation**
+- For a sample of real SSO logins: the redirect completes, the token validates (signature,
+ issuer, audience, expiry), and the resulting session's effective permissions match what the
+ same user would have gotten via LDAP (diff against the preflight baseline for those users).
+- Confirm LDAP-path logins saw zero behavior change.
+- Watch for a UX-driven support-load spike specific to this design: failed logins from users
+ picking the unfamiliar option by mistake, or confusion about which credentials to use where.
+ This category of signal didn't exist under a flagged-cohort design and is a direct consequence
+ of exposing the choice to everyone at once.
+- Rate-check the IdP's token endpoint isn't a new latency/availability dependency users notice.
+
+## Phase 3 — Flip the default to SSO
+
+Topology is identical to phase 2; only the emphasis inverts — SSO becomes the primary button,
+LDAP a secondary "sign in another way" link, and both still work.
+
+**Pre-validation**
+- Confirm phase 2's real-world usage data (organic SSO adoption, error rate, claim-mapping
+ accuracy) is healthy at whatever volume it received before biasing more traffic toward it.
+- Confirm a comms/support-readiness plan exists for the default flip — users with muscle memory
+ for the old primary button are the population most likely to be confused by this change,
+ specifically because nothing was hidden from them before.
+- Confirm the now-secondary LDAP option is still fully functional — don't let the de-emphasized
+ path silently bit-rot before it's actually removed in phase 4.
+
+**Post-validation**
+- Measure the adoption shift: SSO's share of logins should rise measurably after the default
+ flip — this is the confirmation that the nudge actually worked, not just that it shipped.
+- Confirm no increase in failed logins or support tickets attributable to the flip itself.
+- Re-run the phase-2 permission-parity check against the now-larger SSO population, not just the
+ original organic adopters.
+
+## Phase 4 — Full cutover: retire the app's direct LDAP dependency
+
+The LDAP login option and the app's simple-bind + group-query code path behind it are removed;
+LDAP itself and directory sync keep running for the other on-prem consumers, and any session
+still on the old path gets one announced sunset deadline rather than an instant kill.
+
+**Pre-validation**
+- Confirm LDAP-path usage has dropped to a negligible floor (or a fixed calendar deadline has
+ been reached and communicated) — a deliberate go/no-go against a pre-agreed threshold.
+- Identify and individually resolve any remaining LDAP-only users before removing the option —
+ e.g. service accounts, or edge-case devices/browsers that can't complete an OIDC redirect.
+ Removing the choice entirely is only safe once no one is depending on it being there.
+- Confirm the sunset deadline gives every remaining LDAP-session holder at least one full normal
+ usage cycle to hit the app and get silently re-issued an SSO session before forced re-auth.
+- Confirm removing the LDAP simple-bind *login* code path doesn't also remove or break LDAP
+ *service-account* credentials the directory-sync agent (or anything else) still legitimately
+ needs — this deletion should be scoped to the login/authz code, nothing else.
+
+```mermaid
+graph LR
+ User --> App@{shape: procs}
+ App -- OIDC redirect --> IdP{{Cloud IdP}}
+ LDAP[(Self-hosted LDAP/AD)] -- "reads users + groups" --> DirSync{{Directory Sync Agent}} -- one-way sync --> IdP
+```
+
+**Post-validation**
+- LDAP simple-bind traffic from the app is zero — check LDAP's own bind logs/audit trail for
+ this app's identifier, not just "the code was deleted so it must be zero."
+- LDAP's other consumers (the preflight inventory) show unchanged traffic, confirming this app's
+ removal didn't collaterally affect them.
+- After the sunset deadline passes: no active session remains that was issued via the old path;
+ every active session is traceable to an SSO login.
+- Spot-check authorization outcomes one more time post-deletion — confirm the group-claim-based
+ path alone (no LDAP fallback left in the code) still produces the same permissions as the
+ preflight baseline.
diff --git a/docs/migrations/sso/objective.md b/docs/migrations/sso/objective.md
new file mode 100644
index 0000000..714ecaa
--- /dev/null
+++ b/docs/migrations/sso/objective.md
@@ -0,0 +1,41 @@
+# Migration Scenario: App Login — Self-Hosted LDAP/AD to Cloud IdP SSO
+
+## Objective
+
+Migrate an application's authentication away from direct LDAP simple-binds against a
+self-hosted LDAP/Active Directory instance, to SSO via a cloud-hosted Identity Provider
+(e.g. Okta, Azure AD, Google Workspace) over OIDC.
+
+# Existing Infrastructure
+
+```mermaid
+graph LR
+ User --> App@{shape: procs}
+ App --> LDAP[(Self-hosted LDAP/AD)]
+```
+
+- On every login, `App` performs an LDAP simple-bind with the user's supplied credentials, then
+ queries LDAP group membership to authorize the session.
+- `LDAP` is a single self-hosted instance (or a primary/replica pair) that also backs other
+ internal tooling beyond this app — it can't simply be decommissioned once this app cuts over.
+- Sessions are cookie-based and sticky to whichever app instance issued them.
+- There is no MFA today; LDAP only validates a password.
+
+# Intended State
+
+```mermaid
+graph LR
+ User --> App@{shape: procs}
+ App -- OIDC redirect --> IdP{{Cloud IdP - SSO}}
+ LDAP[(Self-hosted LDAP/AD)] -- directory sync / federation --> IdP
+```
+
+- `App` no longer talks to LDAP directly. Unauthenticated users are redirected to the IdP and
+ the app accepts a signed OIDC token back.
+- The cloud IdP is what users authenticate against going forward. LDAP remains in place as the
+ durable source of truth for identity and group data, kept in sync via a one-way directory
+ sync/federation agent, since other on-prem systems still depend on it directly.
+- Group membership claims in the OIDC token drive authorization, replacing the app's LDAP group
+ queries.
+- Existing LDAP-authenticated sessions must not all invalidate at the same instant during
+ cutover.
diff --git a/docs/migrations/svid/README.md b/docs/migrations/svid/README.md
new file mode 100644
index 0000000..bb92726
--- /dev/null
+++ b/docs/migrations/svid/README.md
@@ -0,0 +1,128 @@
+# Migration: CI/CD Kubernetes Auth — Static Kubeconfig → SPIFFE JWT-SVID
+
+This directory is a **mock interview solution artifact** for the scenario in
+[`objective.md`](./objective.md). It is a static IaC representation of a phased migration
+plan, plus Go validation scripts, meant to be *presented and defended* in a design review —
+not to be `pulumi up`'d against a real cluster.
+
+## Layout
+
+```text
+docs/migrations/svid/
+├── objective.md # the scenario (given)
+├── iac/ # Pulumi (Go) program, phase-gated by stack config
+│ ├── Pulumi.yaml
+│ ├── main.go # phase gate + resource wiring
+│ ├── authnconfig.go # Kubernetes AuthenticationConfiguration (structured auth config)
+│ └── legacy.go # pre-existing static-credential resources (imported, then retired)
+├── validate/ # Go CLI: pre/post checks run outside Pulumi, per phase
+│ ├── main.go
+│ ├── preflight.go # phase 0
+│ ├── phase1.go # post-trust-establishment
+│ ├── phase2.go # shadow/dual-run
+│ ├── phase3.go # cutover
+│ └── phase4.go # decommission
+└── workflow-examples/ # annotated before/after GitHub Actions YAML (context, not applied)
+ ├── deploy-before.yml
+ └── deploy-after.yml
+```
+
+## Load-bearing assumptions
+
+These are the assumptions that shape the design below. Everything not listed here is a
+naming/example detail (repo, cluster, trust domain) — swap freely without changing the plan.
+
+1. **Cloud-agnostic control plane.** We do not assume EKS/GKE/AKS specifically. The mechanism
+ modeled is Kubernetes' own **Structured Authentication Configuration**
+ (`apiserver.config.k8s.io/v1beta1 AuthenticationConfiguration`, beta since 1.30), which lets
+ the API server trust multiple JWT issuers with per-issuer claim validation — this is the part
+ that's actually portable across managed offerings. *Getting that file onto the control plane*
+ is CSP-specific (EKS: `associate-identity-provider-config` / cluster config update; GKE:
+ cluster update with the auth config field; AKS: equivalent). We abstract that last mile behind
+ a single `local.Command` call and call out in comments exactly where a real implementation
+ would branch by CSP.
+2. **Defakto has no public Pulumi provider**, and — importantly — **Pulumi has no business
+ touching the CI job's runtime SVID fetch at all**. The workload calls Defakto's
+ `RemoteWorkloadAPI` (gRPC) at execution time to exchange the GitHub OIDC token for a
+ JWT-SVID; that's a runtime/SDK concern living in the workflow YAML, not infrastructure. The
+ only thing IaC legitimately owns on the Defakto side is **control-plane configuration**:
+ registering GitHub Actions' OIDC issuer as a trusted upstream, scoped to a specific
+ repo/workflow, and the claims→SPIFFE-ID mapping. That's declarative SaaS state, similar to
+ managing an Auth0/Okta tenant via Terraform. We represent it with `pulumi-command`
+ (`local.Command` wrapping a hypothetical `defaktoctl` CLI) rather than a hand-rolled dynamic
+ provider, because Go's Pulumi SDK — unlike Node/Python — has no first-class in-process
+ dynamic-provider API; `pulumi-command` is the idiomatic Go-SDK stand-in for "declaratively
+ drive an API/CLI with no native provider." This is flagged in code, not silently papered over.
+3. **RBAC subject, not new permissions.** The migration changes *how* the pipeline authenticates,
+ not *what* it's allowed to do. The existing `ClusterRole` is referenced by name and left
+ untouched; only the binding's subject changes (service account → mapped SPIFFE-derived
+ username).
+4. **Narrow trust is enforced twice, redundantly**: once in the `AuthenticationConfiguration`'s
+ `claimValidationRules` (reject tokens whose `repository`/`workflow` claims don't match), and
+ again in RBAC (the new binding only grants the mapped username from *that* claim set — no
+ wildcard subjects). Belt-and-suspenders on purpose: trusting Defakto as an issuer must not
+ become an implicit grant to every other SPIFFE ID Defakto could ever issue.
+5. **Fail-closed over fail-open**, per the objective's stated priority (consistency over
+ availability). The cutover step never falls back to the static credential if SVID exchange
+ fails — a failed auth exchange fails the deploy job outright. This is enforced in the workflow
+ (no `continue-on-error`, no fallback branch) and asserted by the phase-3 validation script.
+6. **Legacy resources are assumed already-imported.** The static `ServiceAccount` and
+ `ClusterRoleBinding` predate this stack. For this exercise we assume they were brought under
+ management via `pulumi import` at phase 0 so their removal in phase 4 shows up as a normal
+ Pulumi diff. The legacy GitHub Actions secret's *value* is never represented in code (secrets
+ don't belong in IaC even conceptually) — it's referenced by name only, and phase 4 deletes it
+ by name via a one-shot command, not by declaring/destroying a `github.ActionsSecret` resource.
+7. **Exact field names/shape** of `AuthenticationConfiguration` and the `pulumi-command`
+ resource args are approximate to the real APIs as of writing — verify against the target
+ Kubernetes version's docs and the current `pulumi-command` provider before treating this as
+ copy-paste-able.
+
+## Phases
+
+| Phase | Name | Pulumi diff | Workflow change | Reversible? |
+|---|---|---|---|---|
+| 0 | Preflight | none | none | n/a |
+| 1 | Establish trust | + Defakto federation source, + `AuthenticationConfiguration` push, + new narrow `ClusterRoleBinding` | none (unused) | yes, trivially — nothing consumes the new path yet |
+| 2 | Shadow validation | none | a subset of workflows fetch an SVID and make a **read-only** call over the new path, real deploys unchanged | yes |
+| 3 | Cutover | legacy resources annotated `pending-decommission` | deploy workflows switch to SVID auth, fail-closed | yes — legacy path still intact, can revert workflow YAML |
+| 4 | Decommission | legacy `ServiceAccount` + `ClusterRoleBinding` removed from program (destroyed); legacy GitHub secret deleted | none further | no — this is the point of no return; gate it on a full cycle of green phase-3 deploys |
+
+Run any phase's plan with:
+
+```sh
+cd iac
+pulumi config set phase 1 # 0..4
+pulumi preview
+```
+
+## What I'd want to see from validation, per phase
+
+See `validate/*.go` for the actual (runnable, `kubectl`/`gh`-shelling) checks. It's a separate Go
+module, so run it from its own directory:
+
+```sh
+cd validate
+go run . preflight # preflight|phase1|phase2|phase3|phase3-gate|phase4
+```
+
+Summary of intent:
+
+- **Phase 0 (preflight)**: confirm current state matches assumptions before touching anything —
+ legacy SA/binding exist, no external OIDC issuer already trusted by the API server, and the
+ deploy workflow doesn't request `id-token: write` yet (the permission is free — it's granted at
+ cutover, so finding it already there means someone has started this migration elsewhere).
+- **Phase 1 (post-trust)**: the API server accepts a *correctly scoped* test SVID and rejects an
+ **out-of-scope** one (wrong repo/workflow claim) — the negative test matters as much as the
+ positive one, since it's the proof that trust didn't leak cluster-wide. Also confirm the legacy
+ path is completely unaffected (no regression).
+- **Phase 2 (shadow)**: end-to-end token exchange + a real (read-only) API server call succeeds
+ from an actual GitHub Actions run, not just a local simulation.
+- **Phase 3 (cutover, pre-deploy gate)**: SVID exchange + auth succeeds *before* the deploy step
+ runs — if it doesn't, the job must fail before attempting `kubectl apply`, never fall back.
+ `validate phase3-gate` is the fail-closed, non-aggregating entry point meant to run inline in
+ the workflow (see `workflow-examples/deploy-after.yml`); `validate phase3` aggregates both the
+ gate and the post-deploy checks for a standalone design-review run. Post-deploy: confirm the
+ deploy used the new path (absence of the legacy secret reference in the workflow definition).
+- **Phase 4 (decommission)**: legacy `ServiceAccount`/`ClusterRoleBinding` return `NotFound`;
+ legacy GitHub secret is gone from the repo; the phase-1 negative test (out-of-scope rejection)
+ still passes — decommissioning shouldn't have widened trust as a side effect.
diff --git a/docs/migrations/svid/iac/Pulumi.yaml b/docs/migrations/svid/iac/Pulumi.yaml
new file mode 100644
index 0000000..c682541
--- /dev/null
+++ b/docs/migrations/svid/iac/Pulumi.yaml
@@ -0,0 +1,41 @@
+name: svid-migration
+runtime: go
+description: >
+ Phased migration of the CI/CD deploy pipeline's Kubernetes auth from a static
+ long-lived kubeconfig to Defakto short-lived JWT-SVIDs, gated by the `phase`
+ config value (0-4). See ../README.md for the phase narrative and assumptions.
+
+config:
+ phase:
+ description: Migration phase to converge to (0=preflight, 1=establish trust, 2=shadow, 3=cutover, 4=decommission).
+ default: 0
+ githubOrg:
+ description: GitHub organization owning the deploy repo.
+ default: acme-platform
+ githubRepo:
+ description: Repository whose workflow is being migrated.
+ default: checkout-service
+ githubWorkflow:
+ description: Workflow file name that performs the deploy (used for file/run lookups, not for claim matching).
+ default: deploy.yml
+ githubWorkflowName:
+ description: The deploy workflow's `name:` value. GitHub's OIDC `workflow` claim carries the display name, not the file name, so this - not githubWorkflow - is what the trust conditions match on.
+ default: deploy
+ githubEnvironment:
+ description: GitHub Actions environment the deploy job runs under (narrows the OIDC subject claim).
+ default: production
+ k8sNamespace:
+ description: Namespace containing the legacy CI service account.
+ default: ci
+ existingClusterRoleName:
+ description: Pre-existing ClusterRole the pipeline is authorized against. Not managed by this stack - only its name is referenced.
+ default: ci-deployer
+ defaktoTrustDomain:
+ description: Defakto SPIFFE trust domain this pipeline's identities are issued under.
+ default: acme.defakto.id
+ legacyGithubSecretName:
+ description: Name (not value) of the GitHub Actions secret holding the static kubeconfig, deleted in phase 4.
+ default: KUBE_DEPLOY_KUBECONFIG
+ defaktoApiToken:
+ description: API token for Defakto's control-plane API, used only by the phase>=1 federation-source command.
+ secret: true
diff --git a/docs/migrations/svid/iac/authnconfig.go b/docs/migrations/svid/iac/authnconfig.go
new file mode 100644
index 0000000..927f6b5
--- /dev/null
+++ b/docs/migrations/svid/iac/authnconfig.go
@@ -0,0 +1,47 @@
+package main
+
+import "fmt"
+
+// authnConfigTemplate is Kubernetes' Structured Authentication Configuration
+// (apiserver.config.k8s.io/v1beta1) for trusting Defakto as an additional
+// issuer, scoped to exactly one repo/workflow/environment. This is the one
+// piece of the design that is genuinely cloud-agnostic: every managed
+// offering that supports additional JWT issuers converges on this file, even
+// though *delivering* it to the control plane is CSP-specific (see
+// declareAuthnConfigPush in main.go).
+//
+// Kept as literal YAML rather than marshalled Go structs on purpose: this
+// document is the artifact a reviewer needs to read and compare against the
+// upstream API reference, and there is no dynamic structure here - only six
+// interpolated strings. Field names are approximate to the upstream API as of
+// writing; verify against the target cluster's Kubernetes version before
+// treating this as copy-paste-able.
+const authnConfigTemplate = `apiVersion: apiserver.config.k8s.io/v1beta1
+kind: AuthenticationConfiguration
+jwt:
+ - issuer:
+ url: %[1]s
+ audiences: [%[2]s]
+ audienceMatchPolicy: MatchAny
+ claimMappings:
+ # Defakto's SPIFFE ID becomes the Kubernetes username, namespaced with a
+ # prefix so it can never collide with an existing
+ # "system:serviceaccount:..." subject.
+ username:
+ claim: sub
+ prefix: "defakto:"
+ # Redundant with the RBAC scoping in main.go on purpose - see README
+ # "Narrow trust is enforced twice." These rules are what stop "trust
+ # Defakto" from becoming "trust every SPIFFE ID Defakto could ever issue."
+ claimValidationRules:
+ - claim: repository
+ requiredValue: %[3]s/%[4]s
+ - claim: workflow
+ requiredValue: %[5]s
+ - claim: environment
+ requiredValue: %[6]s
+`
+
+func buildAuthenticationConfiguration(issuerURL, audience, org, repo, workflowName, environment string) string {
+ return fmt.Sprintf(authnConfigTemplate, issuerURL, audience, org, repo, workflowName, environment)
+}
diff --git a/docs/migrations/svid/iac/go.mod b/docs/migrations/svid/iac/go.mod
new file mode 100644
index 0000000..3ba75b6
--- /dev/null
+++ b/docs/migrations/svid/iac/go.mod
@@ -0,0 +1,93 @@
+module github.com/defakto/platform-engineer-interview/docs/migrations/svid/iac
+
+go 1.22
+
+require (
+ github.com/pulumi/pulumi-command/sdk v1.0.1
+ github.com/pulumi/pulumi-kubernetes/sdk/v4 v4.18.1
+ github.com/pulumi/pulumi/sdk/v3 v3.135.0
+)
+
+require (
+ dario.cat/mergo v1.0.0 // indirect
+ github.com/BurntSushi/toml v1.2.1 // indirect
+ github.com/Microsoft/go-winio v0.6.1 // indirect
+ github.com/ProtonMail/go-crypto v1.0.0 // indirect
+ github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da // indirect
+ github.com/agext/levenshtein v1.2.3 // indirect
+ github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect
+ github.com/atotto/clipboard v0.1.4 // indirect
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+ github.com/blang/semver v3.5.1+incompatible // indirect
+ github.com/charmbracelet/bubbles v0.16.1 // indirect
+ github.com/charmbracelet/bubbletea v0.25.0 // indirect
+ github.com/charmbracelet/lipgloss v0.7.1 // indirect
+ github.com/cheggaaa/pb v1.0.29 // indirect
+ github.com/cloudflare/circl v1.3.7 // indirect
+ github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
+ github.com/cyphar/filepath-securejoin v0.2.4 // indirect
+ github.com/djherbis/times v1.5.0 // indirect
+ github.com/emirpasic/gods v1.18.1 // indirect
+ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
+ github.com/go-git/go-billy/v5 v5.5.0 // indirect
+ github.com/go-git/go-git/v5 v5.12.0 // indirect
+ github.com/gogo/protobuf v1.3.2 // indirect
+ github.com/golang/glog v1.2.0 // indirect
+ github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 // indirect
+ github.com/hashicorp/errwrap v1.1.0 // indirect
+ github.com/hashicorp/go-multierror v1.1.1 // indirect
+ github.com/hashicorp/hcl/v2 v2.17.0 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
+ github.com/kevinburke/ssh_config v1.2.0 // indirect
+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-localereader v0.0.1 // indirect
+ github.com/mattn/go-runewidth v0.0.15 // indirect
+ github.com/mitchellh/go-ps v1.0.0 // indirect
+ github.com/mitchellh/go-wordwrap v1.0.1 // indirect
+ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
+ github.com/muesli/cancelreader v0.2.2 // indirect
+ github.com/muesli/reflow v0.3.0 // indirect
+ github.com/muesli/termenv v0.15.2 // indirect
+ github.com/opentracing/basictracer-go v1.1.0 // indirect
+ github.com/opentracing/opentracing-go v1.2.0 // indirect
+ github.com/pgavlin/fx v0.1.6 // indirect
+ github.com/pjbgf/sha1cd v0.3.0 // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/pkg/term v1.1.0 // indirect
+ github.com/pulumi/appdash v0.0.0-20231130102222-75f619a67231 // indirect
+ github.com/pulumi/esc v0.9.1 // indirect
+ github.com/rivo/uniseg v0.4.4 // indirect
+ github.com/rogpeppe/go-internal v1.12.0 // indirect
+ github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
+ github.com/santhosh-tekuri/jsonschema/v5 v5.0.0 // indirect
+ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
+ github.com/skeema/knownhosts v1.2.2 // indirect
+ github.com/spf13/cast v1.4.1 // indirect
+ github.com/spf13/cobra v1.8.0 // indirect
+ github.com/spf13/pflag v1.0.5 // indirect
+ github.com/texttheater/golang-levenshtein v1.0.1 // indirect
+ github.com/uber/jaeger-client-go v2.30.0+incompatible // indirect
+ github.com/uber/jaeger-lib v2.4.1+incompatible // indirect
+ github.com/xanzy/ssh-agent v0.3.3 // indirect
+ github.com/zclconf/go-cty v1.13.2 // indirect
+ go.uber.org/atomic v1.9.0 // indirect
+ golang.org/x/crypto v0.25.0 // indirect
+ golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 // indirect
+ golang.org/x/mod v0.18.0 // indirect
+ golang.org/x/net v0.27.0 // indirect
+ golang.org/x/sync v0.7.0 // indirect
+ golang.org/x/sys v0.22.0 // indirect
+ golang.org/x/term v0.22.0 // indirect
+ golang.org/x/text v0.16.0 // indirect
+ golang.org/x/tools v0.22.0 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20240311173647-c811ad7063a7 // indirect
+ google.golang.org/grpc v1.63.2 // indirect
+ google.golang.org/protobuf v1.33.0 // indirect
+ gopkg.in/warnings.v0 v0.1.2 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+ lukechampine.com/frand v1.4.2 // indirect
+)
diff --git a/docs/migrations/svid/iac/go.sum b/docs/migrations/svid/iac/go.sum
new file mode 100644
index 0000000..a831a3b
--- /dev/null
+++ b/docs/migrations/svid/iac/go.sum
@@ -0,0 +1,317 @@
+dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
+dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
+github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
+github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
+github.com/HdrHistogram/hdrhistogram-go v1.1.2 h1:5IcZpTvzydCQeHzK4Ef/D5rrSqwxob0t8PQPMybUNFM=
+github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo=
+github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
+github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
+github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
+github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78=
+github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
+github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da h1:KjTM2ks9d14ZYCvmHS9iAKVt9AyzRSqNU1qabPih5BY=
+github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da/go.mod h1:eHEWzANqSiWQsof+nXEI9bUVUyV6F53Fp89EuCh2EAA=
+github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo=
+github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
+github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw=
+github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo=
+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
+github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
+github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
+github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY=
+github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc=
+github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM=
+github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg=
+github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E=
+github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c=
+github.com/cheggaaa/pb v1.0.29 h1:FckUN5ngEk2LpvuG0fw1GEFx6LtyY2pWI/Z2QgCnEYo=
+github.com/cheggaaa/pb v1.0.29/go.mod h1:W40334L7FMC5JKWldsTWbdGjLo0RxUKK73K+TuPxX30=
+github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
+github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
+github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
+github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
+github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
+github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg=
+github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/djherbis/times v1.5.0 h1:79myA211VwPhFTqUk8xehWrsEO+zcIZj0zT8mXPVARU=
+github.com/djherbis/times v1.5.0/go.mod h1:5q7FDLvbNg1L/KaBmPcWlVR9NmoKo3+ucqUA3ijQhA0=
+github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
+github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
+github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
+github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
+github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
+github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
+github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
+github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
+github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
+github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU=
+github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow=
+github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
+github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
+github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys=
+github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY=
+github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
+github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
+github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/golang/glog v1.2.0 h1:uCdmnmatrKCgMBlM4rMuJZWOkPDqdbZPnrMXDY4gI68=
+github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 h1:MJG/KsmcqMwFAkh8mTnAwhyKoB+sTAnY4CACC110tbU=
+github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645/go.mod h1:6iZfnjpejD4L/4DwD7NryNaJyCQdzwWwH2MWhCA90Kw=
+github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
+github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
+github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
+github.com/hashicorp/hcl/v2 v2.17.0 h1:z1XvSUyXd1HP10U4lrLg5e0JMVz6CPaJvAgxM0KNZVY=
+github.com/hashicorp/hcl/v2 v2.17.0/go.mod h1:gJyW2PTShkJqQBKpAmPO3yxMxIuoXkOF2TpqXzrQyx4=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
+github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
+github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
+github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
+github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
+github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
+github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
+github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
+github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
+github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
+github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
+github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
+github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
+github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
+github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
+github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
+github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
+github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI=
+github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M=
+github.com/opentracing/basictracer-go v1.1.0 h1:Oa1fTSBvAl8pa3U+IJYqrKm0NALwH9OsgwOqDv4xJW0=
+github.com/opentracing/basictracer-go v1.1.0/go.mod h1:V2HZueSJEp879yv285Aap1BS69fQMD+MNP1mRs6mBQc=
+github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
+github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
+github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
+github.com/pgavlin/fx v0.1.6 h1:r9jEg69DhNoCd3Xh0+5mIbdbS3PqWrVWujkY76MFRTU=
+github.com/pgavlin/fx v0.1.6/go.mod h1:KWZJ6fqBBSh8GxHYqwYCf3rYE7Gp2p0N8tJp8xv9u9M=
+github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
+github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/term v1.1.0 h1:xIAAdCMh3QIAy+5FrE8Ad8XoDhEU4ufwbaSozViP9kk=
+github.com/pkg/term v1.1.0/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pulumi/appdash v0.0.0-20231130102222-75f619a67231 h1:vkHw5I/plNdTr435cARxCW6q9gc0S/Yxz7Mkd38pOb0=
+github.com/pulumi/appdash v0.0.0-20231130102222-75f619a67231/go.mod h1:murToZ2N9hNJzewjHBgfFdXhZKjY3z5cYC1VXk+lbFE=
+github.com/pulumi/esc v0.9.1 h1:HH5eEv8sgyxSpY5a8yePyqFXzA8cvBvapfH8457+mIs=
+github.com/pulumi/esc v0.9.1/go.mod h1:oEJ6bOsjYlQUpjf70GiX+CXn3VBmpwFDxUTlmtUN84c=
+github.com/pulumi/pulumi-command/sdk v1.0.1 h1:ZuBSFT57nxg/fs8yBymUhKLkjJ6qmyN3gNvlY/idiN0=
+github.com/pulumi/pulumi-command/sdk v1.0.1/go.mod h1:C7sfdFbUIoXKoIASfXUbP/U9xnwPfxvz8dBpFodohlA=
+github.com/pulumi/pulumi-kubernetes/sdk/v4 v4.18.1 h1:WIvq/l2ls8SVkcxG7kr8lE3Dq9rsmY9004mNSa9iUc4=
+github.com/pulumi/pulumi-kubernetes/sdk/v4 v4.18.1/go.mod h1:vUaV6NmzM//lS3WHB/QxkKr/CHehhsWw/wst3XGIn6I=
+github.com/pulumi/pulumi/sdk/v3 v3.135.0 h1:oBP7QsmZv6uUf3eJ9j6av0nrZpipV9IguNu6rVpHlFU=
+github.com/pulumi/pulumi/sdk/v3 v3.135.0/go.mod h1:J5kQEX8v87aeUhk6NdQXnjCo1DbiOnOiL3Sf2DuDda8=
+github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
+github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
+github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
+github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
+github.com/santhosh-tekuri/jsonschema/v5 v5.0.0 h1:TToq11gyfNlrMFZiYujSekIsPd9AmsA2Bj/iv+s4JHE=
+github.com/santhosh-tekuri/jsonschema/v5 v5.0.0/go.mod h1:FKdcjfQW6rpZSnxxUvEA5H/cDPdvJ/SZJQLWWXWGrZ0=
+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
+github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A=
+github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo=
+github.com/spf13/cast v1.4.1 h1:s0hze+J0196ZfEMTs80N7UlFt0BDuQ7Q+JDnHiMWKdA=
+github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
+github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
+github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/texttheater/golang-levenshtein v1.0.1 h1:+cRNoVrfiwufQPhoMzB6N0Yf/Mqajr6t1lOv8GyGE2U=
+github.com/texttheater/golang-levenshtein v1.0.1/go.mod h1:PYAKrbF5sAiq9wd+H82hs7gNaen0CplQ9uvm6+enD/8=
+github.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaOOb6ThwMmTEbhRwtKR97o=
+github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk=
+github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVKhn2Um6rjCsSsg=
+github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U=
+github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
+github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
+github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/zclconf/go-cty v1.13.2 h1:4GvrUxe/QUDYuJKAav4EYqdM47/kZa672LwmXFmEKT0=
+github.com/zclconf/go-cty v1.13.2/go.mod h1:YKQzy/7pZ7iq2jNFzy5go57xdxdWoLLpaEp4u238AE0=
+go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
+go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
+golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
+golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
+golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
+golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 h1:LoYXNGAShUG3m/ehNk4iFctuhGX/+R1ZpfJ4/ia80JM=
+golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
+golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
+golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200421231249-e086a090c8fd/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
+golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
+golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
+golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
+golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
+golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
+golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
+golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
+golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240311173647-c811ad7063a7 h1:8EeVk1VKMD+GD/neyEHGmz7pFblqPjHoi+PGQIlLx2s=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240311173647-c811ad7063a7/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
+google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM=
+google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA=
+google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
+google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
+gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+lukechampine.com/frand v1.4.2 h1:RzFIpOvkMXuPMBb9maa4ND4wjBn71E1Jpf8BzJHMaVw=
+lukechampine.com/frand v1.4.2/go.mod h1:4S/TM2ZgrKejMcKMbeLjISpJMO+/eZ1zu3vYX9dtj3s=
+pgregory.net/rapid v0.6.1 h1:4eyrDxyht86tT4Ztm+kvlyNBLIk071gR+ZQdhphc9dQ=
+pgregory.net/rapid v0.6.1/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
diff --git a/docs/migrations/svid/iac/legacy.go b/docs/migrations/svid/iac/legacy.go
new file mode 100644
index 0000000..8923d9b
--- /dev/null
+++ b/docs/migrations/svid/iac/legacy.go
@@ -0,0 +1,96 @@
+package main
+
+import (
+ "fmt"
+
+ "github.com/pulumi/pulumi-command/sdk/go/command/local"
+ corev1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/core/v1"
+ metav1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/meta/v1"
+ rbacv1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/rbac/v1"
+ "github.com/pulumi/pulumi/sdk/v3/go/pulumi"
+)
+
+const (
+ legacyServiceAccountName = "ci-deployer"
+ legacyClusterRoleBindingID = "ci-deployer-legacy"
+)
+
+// declareLegacyResources represents the pre-existing static-credential setup.
+// We assume these were brought under Pulumi management via `pulumi import`
+// at phase 0 (they predate this stack - nothing here *creates* new legacy
+// access). Modeling them explicitly is what makes their removal in phase 4
+// a normal, reviewable Pulumi diff instead of an out-of-band manual step.
+//
+// From phase 3 onward we only patch an annotation (signal of intent, no
+// permission change). At phase 4 the caller simply stops invoking this
+// function - the resources drop out of desired state and Pulumi destroys
+// them on the next `pulumi up`.
+func declareLegacyResources(ctx *pulumi.Context, phase int, namespace, clusterRoleName string) error {
+ annotations := pulumi.StringMap{}
+ if phase >= 3 {
+ // Phase 3: cutover has happened, deploys now use the SVID path.
+ // Nothing consumes this ServiceAccount's token anymore, but we hold
+ // off deleting it for one full deploy cycle so a revert of the
+ // workflow YAML alone is enough to roll back - no Pulumi apply
+ // needed to un-revert.
+ annotations["migration.defakto.io/status"] = pulumi.String("pending-decommission")
+ }
+
+ sa, err := corev1.NewServiceAccount(ctx, legacyServiceAccountName, &corev1.ServiceAccountArgs{
+ Metadata: &metav1.ObjectMetaArgs{
+ Name: pulumi.String(legacyServiceAccountName),
+ Namespace: pulumi.String(namespace),
+ Annotations: annotations,
+ },
+ })
+ if err != nil {
+ return fmt.Errorf("legacy service account: %w", err)
+ }
+
+ _, err = rbacv1.NewClusterRoleBinding(ctx, legacyClusterRoleBindingID, &rbacv1.ClusterRoleBindingArgs{
+ Metadata: &metav1.ObjectMetaArgs{
+ Name: pulumi.String(legacyClusterRoleBindingID),
+ Annotations: annotations,
+ },
+ RoleRef: &rbacv1.RoleRefArgs{
+ ApiGroup: pulumi.String("rbac.authorization.k8s.io"),
+ Kind: pulumi.String("ClusterRole"),
+ Name: pulumi.String(clusterRoleName),
+ },
+ Subjects: rbacv1.SubjectArray{
+ &rbacv1.SubjectArgs{
+ // Referencing sa's output is itself the dependency edge - no
+ // explicit DependsOn needed.
+ Kind: pulumi.String("ServiceAccount"),
+ Name: sa.Metadata.Name().Elem(),
+ Namespace: pulumi.String(namespace),
+ },
+ },
+ })
+ if err != nil {
+ return fmt.Errorf("legacy cluster role binding: %w", err)
+ }
+
+ return nil
+}
+
+// decommissionLegacySecretCommand deletes the GitHub Actions secret by name
+// only - its value was never, and must never be, represented in this
+// program. This is a one-shot imperative step (not a declarative resource
+// with a destroy lifecycle) because there is nothing to converge to: once
+// deleted, there's no "desired state" left to track.
+func decommissionLegacySecretCommand(ctx *pulumi.Context, org, repo, secretName string) error {
+ _, err := local.NewCommand(ctx, "decommission-legacy-secret", &local.CommandArgs{
+ // Idempotent: `gh secret delete` on an already-absent secret exits
+ // non-zero, so guard with a list+grep. Real implementation would
+ // use the GitHub REST API directly rather than shelling to `gh`.
+ Create: pulumi.String(fmt.Sprintf(
+ `gh secret list --repo %s/%s | grep -q '^%s' && gh secret delete %s --repo %s/%s || echo "%s already absent"`,
+ org, repo, secretName, secretName, org, repo, secretName,
+ )),
+ })
+ if err != nil {
+ return fmt.Errorf("decommission legacy secret: %w", err)
+ }
+ return nil
+}
diff --git a/docs/migrations/svid/iac/main.go b/docs/migrations/svid/iac/main.go
new file mode 100644
index 0000000..126c487
--- /dev/null
+++ b/docs/migrations/svid/iac/main.go
@@ -0,0 +1,190 @@
+// Package main is the phase-gated Pulumi program for the static-kubeconfig ->
+// JWT-SVID migration described in ../objective.md. Converge to a given phase
+// with:
+//
+// pulumi config set phase <0..4>
+// pulumi preview
+//
+// See ../README.md for the phase narrative, the load-bearing assumptions,
+// and why Defakto's control-plane config and the AuthenticationConfiguration
+// push are modeled via pulumi-command rather than a native provider.
+package main
+
+import (
+ "fmt"
+ "strconv"
+
+ "github.com/pulumi/pulumi-command/sdk/go/command/local"
+ metav1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/meta/v1"
+ rbacv1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/rbac/v1"
+ "github.com/pulumi/pulumi/sdk/v3/go/pulumi"
+ "github.com/pulumi/pulumi/sdk/v3/go/pulumi/config"
+)
+
+const svidClusterRoleBindingID = "ci-deployer-svid"
+
+func main() {
+ pulumi.Run(func(ctx *pulumi.Context) error {
+ cfg := config.New(ctx, "")
+
+ // Unset means phase 0 (preflight, declares nothing); anything else
+ // must be a phase this program actually models. Silently falling back
+ // to 0 on a typo would hide the mistake, and an out-of-range value
+ // would land in the phase>=4 decommission path.
+ phase := 0
+ if raw := cfg.Get("phase"); raw != "" {
+ v, err := strconv.Atoi(raw)
+ if err != nil {
+ return fmt.Errorf("phase %q is not an integer: set it to 0-4", raw)
+ }
+ phase = v
+ }
+ if phase < 0 || phase > 4 {
+ return fmt.Errorf("phase %d out of range: must be 0-4", phase)
+ }
+
+ org := cfg.Get("githubOrg")
+ repo := cfg.Get("githubRepo")
+ workflow := cfg.Get("githubWorkflow")
+ workflowName := cfg.Get("githubWorkflowName")
+ environment := cfg.Get("githubEnvironment")
+ namespace := cfg.Get("k8sNamespace")
+ clusterRoleName := cfg.Get("existingClusterRoleName")
+ trustDomain := cfg.Get("defaktoTrustDomain")
+ legacySecretName := cfg.Get("legacyGithubSecretName")
+
+ spiffeID := fmt.Sprintf("spiffe://%s/gha/%s/%s", trustDomain, repo, workflow)
+ issuerURL := fmt.Sprintf("https://issuer.defakto.id/%s", trustDomain)
+ k8sAudience := fmt.Sprintf("k8s.%s", trustDomain)
+ mappedUsername := "defakto:" + spiffeID
+
+ ctx.Export("spiffeId", pulumi.String(spiffeID))
+ ctx.Export("mappedK8sUsername", pulumi.String(mappedUsername))
+
+ // Phase 0 declares nothing - it is preflight only, see
+ // validate/preflight.go.
+ //
+ // Phase >= 1: establish trust. Additive and inert - nothing consumes
+ // this path yet, so it can be applied with zero risk to the existing
+ // pipeline.
+ if phase >= 1 {
+ if err := declareDefaktoFederationSource(ctx, cfg, trustDomain, org, repo, workflowName, environment, spiffeID); err != nil {
+ return err
+ }
+
+ if err := declareAuthnConfigPush(ctx, issuerURL, k8sAudience, org, repo, workflowName, environment); err != nil {
+ return err
+ }
+
+ if err := declareSVIDClusterRoleBinding(ctx, mappedUsername, clusterRoleName); err != nil {
+ return err
+ }
+ }
+
+ // Legacy static-credential resources exist through phase 3
+ // (annotated pending-decommission from phase 3 on) and are dropped
+ // entirely at phase 4, letting Pulumi's own diff destroy them.
+ if phase < 4 {
+ if err := declareLegacyResources(ctx, phase, namespace, clusterRoleName); err != nil {
+ return err
+ }
+ }
+
+ // Phase 4: point of no return. Delete the legacy secret by name;
+ // its value was never represented here.
+ if phase >= 4 {
+ if err := decommissionLegacySecretCommand(ctx, org, repo, legacySecretName); err != nil {
+ return err
+ }
+ }
+
+ return nil
+ })
+}
+
+// declareDefaktoFederationSource registers GitHub Actions' OIDC issuer as a
+// trusted upstream with Defakto, scoped to exactly one repo/workflow/
+// environment, and defines the claims -> SPIFFE-ID mapping. workflowName is
+// the workflow's `name:` (its display name), because that - not the file
+// name - is what GitHub puts in the token's `workflow` claim. This is
+// declarative SaaS configuration (akin to Terraform-managing an Auth0
+// tenant), not workload infrastructure - the CI job's runtime SVID fetch
+// never goes through Pulumi.
+//
+// Modeled with pulumi-command because Go's Pulumi SDK has no in-process
+// dynamic-provider API (unlike Node/Python's `pulumi.dynamic.Resource`).
+// `defakto-cli` below stands in for whatever Defakto's real control-plane
+// API/CLI is - swap the Create/Delete commands for real API calls when
+// adapting this for production.
+func declareDefaktoFederationSource(ctx *pulumi.Context, cfg *config.Config, trustDomain, org, repo, workflowName, environment, spiffeID string) error {
+ apiToken := cfg.RequireSecret("defaktoApiToken")
+
+ _, err := local.NewCommand(ctx, "defakto-federation-source", &local.CommandArgs{
+ Create: pulumi.Sprintf(
+ `defakto-cli federation-source apply --trust-domain %s `+
+ `--issuer https://token.actions.githubusercontent.com `+
+ `--audience defakto-svid `+
+ `--claim repository=%s/%s --claim workflow=%s --claim environment=%s `+
+ `--map-to %s`,
+ trustDomain, org, repo, workflowName, environment, spiffeID,
+ ),
+ Delete: pulumi.Sprintf(`defakto-cli federation-source delete --trust-domain %s --spiffe-id %s`, trustDomain, spiffeID),
+ Environment: pulumi.StringMap{
+ "DEFAKTO_API_TOKEN": apiToken,
+ },
+ })
+ if err != nil {
+ return fmt.Errorf("defakto federation source: %w", err)
+ }
+ return nil
+}
+
+// declareAuthnConfigPush renders the AuthenticationConfiguration (see
+// authnconfig.go) and pushes it to the control plane. The push mechanism
+// itself is CSP-specific (EKS: associate an identity provider config via a
+// cluster config update; GKE/AKS: analogous cluster update calls); this
+// wraps that behind a single command so the program stays cloud-agnostic.
+// In a real implementation, branch push.sh (or this Create command) on a
+// `targetCsp` config value and call the matching SDK
+// (aws.eks/google-native/azure-native) instead of shelling out.
+func declareAuthnConfigPush(ctx *pulumi.Context, issuerURL, audience, org, repo, workflowName, environment string) error {
+ // Stdin is an ordinary input: changing the rendered config re-runs the
+ // command on its own, so no explicit Triggers entry is needed.
+ _, err := local.NewCommand(ctx, "apply-authn-config", &local.CommandArgs{
+ // stub: ./scripts/push-authn-config.sh would branch per-CSP.
+ Create: pulumi.String(`./scripts/push-authn-config.sh apply`),
+ Delete: pulumi.String(`./scripts/push-authn-config.sh remove-issuer`),
+ Stdin: pulumi.String(buildAuthenticationConfiguration(issuerURL, audience, org, repo, workflowName, environment)),
+ })
+ if err != nil {
+ return fmt.Errorf("apply authn config: %w", err)
+ }
+ return nil
+}
+
+// declareSVIDClusterRoleBinding binds the narrowly-scoped, claim-mapped
+// username to the *existing* ClusterRole - the migration changes how the
+// pipeline authenticates, not what it's permitted to do. This binding is
+// additive; the legacy binding is untouched until phase 4.
+func declareSVIDClusterRoleBinding(ctx *pulumi.Context, mappedUsername, clusterRoleName string) error {
+ _, err := rbacv1.NewClusterRoleBinding(ctx, svidClusterRoleBindingID, &rbacv1.ClusterRoleBindingArgs{
+ Metadata: &metav1.ObjectMetaArgs{
+ Name: pulumi.String(svidClusterRoleBindingID),
+ },
+ RoleRef: &rbacv1.RoleRefArgs{
+ ApiGroup: pulumi.String("rbac.authorization.k8s.io"),
+ Kind: pulumi.String("ClusterRole"),
+ Name: pulumi.String(clusterRoleName),
+ },
+ Subjects: rbacv1.SubjectArray{
+ &rbacv1.SubjectArgs{
+ Kind: pulumi.String("User"),
+ Name: pulumi.String(mappedUsername),
+ },
+ },
+ })
+ if err != nil {
+ return fmt.Errorf("svid cluster role binding: %w", err)
+ }
+ return nil
+}
diff --git a/docs/migrations/svid/objective.md b/docs/migrations/svid/objective.md
new file mode 100644
index 0000000..bfafcd5
--- /dev/null
+++ b/docs/migrations/svid/objective.md
@@ -0,0 +1,61 @@
+# Migration Scenario: CI/CD Kubernetes Auth — Static Kubeconfig to SVID
+
+## Objective
+
+Migrate a CI/CD deploy pipeline's authentication to the Kubernetes API server away from a
+static, long-lived kubeconfig secret stored in CI, to short-lived SPIFFE JWT-SVIDs issued
+on-demand by Defakto's serverless workload identity platform.
+
+# Existing Infrastructure
+
+```mermaid
+graph LR
+ Dev[Developer] --> Trigger[Push / PR merge]
+ subgraph GitHubActions["CI/CD (GitHub Actions)"]
+ Trigger --> Job@{shape: procs}
+ Job --> KubeconfigSecret[(Static kubeconfig
long-lived SA token)]
+ end
+ Job --> kubeapi[[kube-apiserver]]
+```
+
+- The pipeline is GitHub Actions. Every workflow run already receives a short-lived,
+ repo/workflow-scoped OIDC ID token from GitHub for free (the `id-token: write` permission) —
+ nothing uses it today.
+- The credential in use is a long-lived Kubernetes ServiceAccount token (could also be a client
+ certificate) stored as a GitHub Actions secret, bound to a single ClusterRole via a
+ ClusterRoleBinding.
+- `kube-apiserver` is a single managed control plane (e.g. EKS/GKE/AKS). You don't manage the
+ control plane nodes, but you can change apiserver-level authentication config through the
+ CSP's supported mechanism.
+- Only a handful of workflows use this credential, but every deploy must apply cleanly or fail
+ outright — a partially-applied manifest from an ambiguous auth failure is worse than a
+ blocked deploy. We favor consistency over availability here.
+- No external OIDC/JWT issuer is currently trusted by the API server for this pipeline.
+
+# Intended State
+
+```mermaid
+graph LR
+ Dev[Developer] --> Trigger[Push / PR merge]
+ subgraph GitHubActions["CI/CD (GitHub Actions)"]
+ Trigger --> Job@{shape: procs}
+ Job --> GHOIDC[(GitHub-issued
OIDC ID token)]
+ end
+ subgraph Defakto["Defakto (serverless)"]
+ Issuer{{SVID / JWT Issuer}}
+ end
+ GHOIDC --> Issuer
+ Issuer -- short-lived JWT-SVID --> Job
+ Job --> kubeapi[[kube-apiserver]]
+ kubeapi -. validates via trusted issuer JWKS .-> Issuer
+```
+
+- `Issuer` is Defakto's hosted SVID issuer — "serverless" in that there is no node agent or
+ extra infrastructure to run or scale inside the cluster or the runner.
+- Each workflow run exchanges its GitHub-issued OIDC ID token for a short-lived JWT-SVID scoped
+ to that specific repo, workflow, and (ideally) environment/branch.
+- `kube-apiserver` trusts Defakto as an additional external JWT/OIDC issuer and maps SPIFFE ID
+ claims to the same RBAC subject the static kubeconfig used to satisfy — scoped narrowly enough
+ that trusting Defakto for this pipeline doesn't implicitly trust it for every other subject in
+ the cluster.
+- No long-lived secret material remains in CI for this pipeline.
diff --git a/docs/migrations/svid/validate/checks.go b/docs/migrations/svid/validate/checks.go
new file mode 100644
index 0000000..1fc994d
--- /dev/null
+++ b/docs/migrations/svid/validate/checks.go
@@ -0,0 +1,177 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+)
+
+// These must match the resource names declared in ../iac/legacy.go and
+// ../iac/main.go - duplicated here because this is a separate Go module
+// with no dependency on the Pulumi program (and no reason to introduce one
+// just to share three string constants).
+const (
+ legacyServiceAccountName = "ci-deployer"
+ legacyClusterRoleBindingID = "ci-deployer-legacy"
+ svidClusterRoleBindingName = "ci-deployer-svid"
+)
+
+// Config holds the same identifiers as the Pulumi stack config
+// (iac/Pulumi.yaml) - kept in sync by hand for this exercise. In a real
+// implementation this would read the same values from `pulumi stack output`
+// / `pulumi config` rather than duplicating them here.
+type Config struct {
+ GithubOrg string
+ GithubRepo string
+ GithubWorkflow string
+ GithubEnvironment string
+ K8sNamespace string
+ ClusterRoleName string
+ TrustDomain string
+ LegacySecretName string
+}
+
+func defaultConfig() Config {
+ return Config{
+ GithubOrg: "acme-platform",
+ GithubRepo: "checkout-service",
+ GithubWorkflow: "deploy.yml",
+ GithubEnvironment: "production",
+ K8sNamespace: "ci",
+ ClusterRoleName: "ci-deployer",
+ TrustDomain: "acme.defakto.id",
+ LegacySecretName: "KUBE_DEPLOY_KUBECONFIG",
+ }
+}
+
+// Check is a single named assertion. Run reports failure via a non-nil
+// error; the message should say what was expected vs. observed, since these
+// results are meant to be read live during a deploy or a design review, not
+// just fed to a test runner.
+type Check struct {
+ Name string
+ Run func() error
+}
+
+// RunChecks executes checks in order and reports all failures rather than
+// stopping at the first one - useful for a design review walkthrough where
+// you want the full picture, but note phase3.go's pre-deploy gate
+// deliberately does NOT use this: it must stop hard on the first failure.
+func RunChecks(checks []Check) bool {
+ allPassed := true
+ for _, c := range checks {
+ if err := c.Run(); err != nil {
+ fmt.Printf("[FAIL] %s: %v\n", c.Name, err)
+ allPassed = false
+ continue
+ }
+ fmt.Printf("[ OK ] %s\n", c.Name)
+ }
+ return allPassed
+}
+
+// sh runs a command and returns combined stdout+stderr, trimmed. It assumes
+// `kubectl`/`gh` are already authenticated against the correct
+// cluster/repo context - this script does not manage kubeconfig or gh auth
+// itself.
+func sh(name string, args ...string) (string, error) {
+ cmd := exec.Command(name, args...)
+ out, err := cmd.CombinedOutput()
+ return strings.TrimSpace(string(out)), err
+}
+
+// assertGone asserts a cluster resource no longer exists, distinguishing
+// "deleted" from "we couldn't tell" - a kubectl error that isn't NotFound
+// (RBAC denial, unreachable API server) must not read as success.
+func assertGone(kind, name string, extraArgs ...string) error {
+ out, err := sh("kubectl", append([]string{"get", kind, name}, extraArgs...)...)
+ if err == nil {
+ return fmt.Errorf("%s %s still present: %s", kind, name, out)
+ }
+ if !strings.Contains(out, "NotFound") {
+ return fmt.Errorf("unexpected error checking %s %s: %s", kind, name, out)
+ }
+ return nil
+}
+
+// fetchWorkflowContent reads the deploy workflow's YAML straight from GitHub
+// (raw, not the base64 contents payload), so checks assert against what will
+// actually run rather than whatever is checked out locally.
+func fetchWorkflowContent(cfg Config) (string, error) {
+ path := fmt.Sprintf(".github/workflows/%s", cfg.GithubWorkflow)
+ out, err := sh("gh", "api", fmt.Sprintf("repos/%s/%s/contents/%s", cfg.GithubOrg, cfg.GithubRepo, path),
+ "--jq", ".content", "-H", "Accept: application/vnd.github.raw+json")
+ if err != nil {
+ return "", fmt.Errorf("could not read workflow %s: %s", path, out)
+ }
+ return out, nil
+}
+
+// workflowDefinition is the sliver of a workflow this tool needs to reason
+// about: `permissions` can appear at the top level, per-job, or both.
+type workflowDefinition struct {
+ Permissions permissionsSpec `yaml:"permissions"`
+ Jobs map[string]struct {
+ Permissions permissionsSpec `yaml:"permissions"`
+ } `yaml:"jobs"`
+}
+
+// permissionsSpec handles both shapes GitHub accepts: the `write-all` /
+// `read-all` scalar shorthand, and the per-scope mapping.
+type permissionsSpec struct {
+ shorthand string
+ scopes map[string]string
+}
+
+func (p *permissionsSpec) UnmarshalYAML(value *yaml.Node) error {
+ switch value.Kind {
+ case yaml.ScalarNode:
+ return value.Decode(&p.shorthand)
+ case yaml.MappingNode:
+ return value.Decode(&p.scopes)
+ default:
+ return fmt.Errorf("unexpected permissions node at line %d", value.Line)
+ }
+}
+
+func (p permissionsSpec) grantsIDTokenWrite() bool {
+ return p.shorthand == "write-all" || p.scopes["id-token"] == "write"
+}
+
+// requestsIDTokenWrite reports whether the workflow asks for the GitHub OIDC
+// ID token. Without it there is nothing to exchange for an SVID. The YAML is
+// parsed rather than string-matched on purpose: a commented-out
+// `# id-token: write` (exactly what a half-finished cutover leaves behind)
+// must not satisfy this check. Unparseable YAML reports false - an
+// unreadable workflow is not evidence the permission is granted.
+func requestsIDTokenWrite(content string) bool {
+ var wf workflowDefinition
+ if err := yaml.Unmarshal([]byte(content), &wf); err != nil {
+ return false
+ }
+ if wf.Permissions.grantsIDTokenWrite() {
+ return true
+ }
+ for _, job := range wf.Jobs {
+ if job.Permissions.grantsIDTokenWrite() {
+ return true
+ }
+ }
+ return false
+}
+
+func fatalIfMissingTools(tools ...string) {
+ var missing []string
+ for _, t := range tools {
+ if _, err := exec.LookPath(t); err != nil {
+ missing = append(missing, t)
+ }
+ }
+ if len(missing) > 0 {
+ fmt.Fprintf(os.Stderr, "missing required tools on PATH: %s\n", strings.Join(missing, ", "))
+ os.Exit(2)
+ }
+}
diff --git a/docs/migrations/svid/validate/go.mod b/docs/migrations/svid/validate/go.mod
new file mode 100644
index 0000000..b33d8b9
--- /dev/null
+++ b/docs/migrations/svid/validate/go.mod
@@ -0,0 +1,5 @@
+module github.com/defakto/platform-engineer-interview/docs/migrations/svid/validate
+
+go 1.22
+
+require gopkg.in/yaml.v3 v3.0.1
diff --git a/docs/migrations/svid/validate/go.sum b/docs/migrations/svid/validate/go.sum
new file mode 100644
index 0000000..a62c313
--- /dev/null
+++ b/docs/migrations/svid/validate/go.sum
@@ -0,0 +1,4 @@
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/docs/migrations/svid/validate/main.go b/docs/migrations/svid/validate/main.go
new file mode 100644
index 0000000..4468fc2
--- /dev/null
+++ b/docs/migrations/svid/validate/main.go
@@ -0,0 +1,64 @@
+// Command validate runs the pre/post checks for one phase of the
+// static-kubeconfig -> JWT-SVID migration (../objective.md, ../README.md).
+//
+// Usage:
+//
+// go run . preflight # phase 0
+// go run . phase1 # post trust-establishment
+// go run . phase2 # shadow/dual-run
+// go run . phase3 # cutover: pre-deploy gate + post-deploy checks, aggregated (review use)
+// go run . phase3-gate # cutover: pre-deploy gate ONLY, fail-closed (inline CI use - see workflow-examples/deploy-after.yml)
+// go run . phase4 # decommission
+//
+// This assumes kubectl and gh are already authenticated against the target
+// cluster and repository - it does not manage credentials itself, and its
+// checks shell out rather than link client-go, so it stays runnable without
+// vendoring cluster-specific auth plugins for this exercise.
+package main
+
+import (
+ "fmt"
+ "os"
+)
+
+func main() {
+ if len(os.Args) != 2 {
+ fmt.Fprintln(os.Stderr, "usage: validate ")
+ os.Exit(2)
+ }
+
+ cfg := defaultConfig()
+
+ var ok bool
+ switch os.Args[1] {
+ case "preflight":
+ ok = RunPreflight(cfg)
+ case "phase1":
+ ok = RunPhase1(cfg)
+ case "phase2":
+ ok = RunPhase2(cfg)
+ case "phase3":
+ ok = RunPhase3(cfg)
+ case "phase3-gate":
+ // Deliberately not RunChecks: this must stop on the first failure
+ // and produce a nonzero exit with no aggregation, since it's meant
+ // to gate a real `kubectl apply` step. See PreDeployGate in
+ // phase3.go.
+ if err := PreDeployGate(cfg); err != nil {
+ fmt.Fprintf(os.Stderr, "pre-deploy gate failed, aborting: %v\n", err)
+ ok = false
+ } else {
+ fmt.Println("[ OK ] pre-deploy gate")
+ ok = true
+ }
+ case "phase4":
+ ok = RunPhase4(cfg)
+ default:
+ fmt.Fprintf(os.Stderr, "unknown phase %q\n", os.Args[1])
+ os.Exit(2)
+ }
+
+ if !ok {
+ os.Exit(1)
+ }
+}
diff --git a/docs/migrations/svid/validate/phase1.go b/docs/migrations/svid/validate/phase1.go
new file mode 100644
index 0000000..837f48d
--- /dev/null
+++ b/docs/migrations/svid/validate/phase1.go
@@ -0,0 +1,147 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+ "time"
+)
+
+// tokenReviewTimeout bounds the TokenReview call. This runs inline in the
+// deploy job's pre-deploy gate, where an API server that never answers must
+// fail the gate rather than hang the job until the runner's own timeout.
+const tokenReviewTimeout = 30 * time.Second
+
+func mappedUsername(cfg Config) string {
+ spiffeID := fmt.Sprintf("spiffe://%s/gha/%s/%s", cfg.TrustDomain, cfg.GithubRepo, cfg.GithubWorkflow)
+ return "defakto:" + spiffeID
+}
+
+// RunPhase1 confirms trust was established correctly and, critically, that
+// it's narrow: an out-of-scope token must be rejected. The negative test
+// matters as much as the positive one - a passing positive test alone would
+// also pass if trust had accidentally been granted cluster-wide.
+func RunPhase1(cfg Config) bool {
+ fatalIfMissingTools("kubectl")
+
+ checks := []Check{
+ {
+ Name: "new SVID ClusterRoleBinding exists and targets the existing ClusterRole",
+ Run: func() error {
+ out, err := sh("kubectl", "get", "clusterrolebinding", svidClusterRoleBindingName, "-o", "jsonpath={.roleRef.name}")
+ if err != nil {
+ return fmt.Errorf("clusterrolebinding %s not found: %s", svidClusterRoleBindingName, out)
+ }
+ if out != cfg.ClusterRoleName {
+ return fmt.Errorf("expected roleRef %q, got %q", cfg.ClusterRoleName, out)
+ }
+ return nil
+ },
+ },
+ {
+ Name: "new SVID ClusterRoleBinding subject matches the expected mapped username",
+ Run: func() error {
+ out, err := sh("kubectl", "get", "clusterrolebinding", svidClusterRoleBindingName, "-o", "jsonpath={.subjects[0].name}")
+ if err != nil {
+ return fmt.Errorf("could not read subject: %s", out)
+ }
+ want := mappedUsername(cfg)
+ if out != want {
+ return fmt.Errorf("expected subject %q, got %q - claimMappings.username prefix/claim may not match RBAC", want, out)
+ }
+ return nil
+ },
+ },
+ {
+ Name: "legacy ClusterRoleBinding is unaffected (no regression)",
+ Run: func() error {
+ out, err := sh("kubectl", "get", "clusterrolebinding", legacyClusterRoleBindingID, "-o", "jsonpath={.roleRef.name}")
+ if err != nil {
+ return fmt.Errorf("legacy binding missing or errored: %s", out)
+ }
+ if out != cfg.ClusterRoleName {
+ return fmt.Errorf("legacy binding roleRef changed unexpectedly: %q", out)
+ }
+ return nil
+ },
+ },
+ {
+ Name: "in-scope test SVID authenticates as the expected mapped username (positive test)",
+ Run: func() error {
+ token := os.Getenv("SVID_TEST_TOKEN_VALID")
+ if token == "" {
+ return fmt.Errorf("set SVID_TEST_TOKEN_VALID to a test SVID minted by Defakto for %s/%s:%s to run this check",
+ cfg.GithubOrg, cfg.GithubRepo, cfg.GithubWorkflow)
+ }
+ return tokenReview(token, true, mappedUsername(cfg))
+ },
+ },
+ {
+ Name: "out-of-scope test SVID is rejected (negative test - proves trust didn't leak cluster-wide)",
+ Run: func() error {
+ token := os.Getenv("SVID_TEST_TOKEN_OUT_OF_SCOPE")
+ if token == "" {
+ return fmt.Errorf("set SVID_TEST_TOKEN_OUT_OF_SCOPE to a test SVID minted for a *different* repo/workflow to run this check")
+ }
+ return tokenReview(token, false, "")
+ },
+ },
+ }
+
+ return RunChecks(checks)
+}
+
+// tokenReview presents a token to the API server's TokenReview API - the
+// only way to actually exercise the AuthenticationConfiguration wiring
+// end-to-end, as opposed to just checking RBAC objects exist. `kubectl
+// auth can-i --as=` does NOT exercise this: it assumes the identity
+// rather than authenticating a token, so it would pass even if the
+// AuthenticationConfiguration were misconfigured or absent.
+func tokenReview(token string, expectAuthenticated bool, expectedUsername string) error {
+ review := map[string]any{
+ "apiVersion": "authentication.k8s.io/v1",
+ "kind": "TokenReview",
+ "spec": map[string]any{"token": token},
+ }
+ body, err := json.Marshal(review)
+ if err != nil {
+ return err
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), tokenReviewTimeout)
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, "kubectl", "create", "--raw", "/apis/authentication.k8s.io/v1/tokenreviews", "-f", "-")
+ cmd.Stdin = strings.NewReader(string(body))
+ raw, err := cmd.CombinedOutput()
+ out := strings.TrimSpace(string(raw))
+ if err != nil {
+ if ctx.Err() != nil {
+ return fmt.Errorf("tokenreview request did not complete within %s (treated as a failure - never fall back to the legacy credential): %s", tokenReviewTimeout, out)
+ }
+ return fmt.Errorf("tokenreview request failed: %s", out)
+ }
+
+ var result struct {
+ Status struct {
+ Authenticated bool `json:"authenticated"`
+ User struct {
+ Username string `json:"username"`
+ } `json:"user"`
+ } `json:"status"`
+ }
+ if err := json.Unmarshal([]byte(out), &result); err != nil {
+ return fmt.Errorf("could not parse tokenreview response: %w (%s)", err, out)
+ }
+
+ if result.Status.Authenticated != expectAuthenticated {
+ return fmt.Errorf("expected authenticated=%v, got %v", expectAuthenticated, result.Status.Authenticated)
+ }
+ if expectAuthenticated && result.Status.User.Username != expectedUsername {
+ return fmt.Errorf("expected username %q, got %q", expectedUsername, result.Status.User.Username)
+ }
+ return nil
+}
diff --git a/docs/migrations/svid/validate/phase2.go b/docs/migrations/svid/validate/phase2.go
new file mode 100644
index 0000000..934efe5
--- /dev/null
+++ b/docs/migrations/svid/validate/phase2.go
@@ -0,0 +1,114 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "strings"
+ "time"
+)
+
+// legacyRunWindow is how far the legacy path's most recent run may sit from
+// the shadow run and still be evidence about the same window. A green deploy
+// from three weeks ago says nothing about whether the shadow run disturbed
+// the legacy path.
+const legacyRunWindow = 24 * time.Hour
+
+// RunPhase2 confirms the SVID path works end-to-end from an actual GitHub
+// Actions run (not just a local TokenReview simulation), while proving the
+// unmodified deploy path is still healthy in the same window. A shadow run
+// ID is required - this phase is about a specific real execution, not a
+// standing state check.
+func RunPhase2(cfg Config) bool {
+ fatalIfMissingTools("gh")
+
+ repo := fmt.Sprintf("%s/%s", cfg.GithubOrg, cfg.GithubRepo)
+
+ // Captured by the first check and read by the second, which has to know
+ // *when* the shadow ran to say anything meaningful about the legacy path.
+ // Safe because RunChecks executes in order.
+ var shadowCreatedAt time.Time
+
+ checks := []Check{
+ {
+ Name: "shadow workflow run (SVID auth, read-only call) completed successfully",
+ Run: func() error {
+ runID := os.Getenv("SHADOW_RUN_ID")
+ if runID == "" {
+ return fmt.Errorf("set SHADOW_RUN_ID to the gh run id of the shadow job to check")
+ }
+ // gh's own --jq does the extraction; no client-side JSON
+ // decoding needed for two scalar fields.
+ out, err := sh("gh", "run", "view", runID, "--repo", repo,
+ "--json", "conclusion,createdAt", "--jq", `[.conclusion, .createdAt] | @tsv`)
+ if err != nil {
+ return fmt.Errorf("could not view run %s: %s", runID, out)
+ }
+ conclusion, createdAt, ok := splitRunFields(out)
+ if !ok {
+ return fmt.Errorf("unexpected run view output for %s: %q", runID, out)
+ }
+ shadowCreatedAt, err = time.Parse(time.RFC3339, createdAt)
+ if err != nil {
+ return fmt.Errorf("could not parse createdAt %q for run %s: %w", createdAt, runID, err)
+ }
+ if conclusion != "success" {
+ return fmt.Errorf("shadow run %s concluded %q, expected success", runID, conclusion)
+ }
+ return nil
+ },
+ },
+ {
+ Name: "real deploy workflow (legacy auth path) is unaffected - most recent run in the shadow window still green",
+ Run: func() error {
+ if shadowCreatedAt.IsZero() {
+ return fmt.Errorf("no shadow run timestamp - the shadow run check above must pass before the legacy path can be compared against its window")
+ }
+ out, err := sh("gh", "run", "list", "--repo", repo,
+ "--workflow", cfg.GithubWorkflow, "--limit", "1",
+ "--json", "conclusion,createdAt", "--jq", `.[0] | [.conclusion, .createdAt] | @tsv`)
+ if err != nil {
+ return fmt.Errorf("could not list runs: %s", out)
+ }
+ if out == "" {
+ return fmt.Errorf("no runs found for %s", cfg.GithubWorkflow)
+ }
+ conclusion, createdAt, ok := splitRunFields(out)
+ if !ok {
+ return fmt.Errorf("unexpected run list output for %s: %q", cfg.GithubWorkflow, out)
+ }
+ legacyCreatedAt, err := time.Parse(time.RFC3339, createdAt)
+ if err != nil {
+ return fmt.Errorf("could not parse createdAt %q: %w", createdAt, err)
+ }
+ if skew := legacyCreatedAt.Sub(shadowCreatedAt); skew < -legacyRunWindow || skew > legacyRunWindow {
+ return fmt.Errorf("most recent %s run started %s from the shadow run, outside the +/-%s window - re-run the legacy deploy path so this check covers the shadow window",
+ cfg.GithubWorkflow, skew.Round(time.Minute), legacyRunWindow)
+ }
+ if conclusion != "success" {
+ return fmt.Errorf("most recent %s run concluded %q, expected success", cfg.GithubWorkflow, conclusion)
+ }
+ return nil
+ },
+ },
+ // Not automated here: confirming the shadow run's API server call
+ // actually authenticated as the mapped SPIFFE-derived username
+ // (rather than, say, silently no-op'ing). That requires querying
+ // the control plane's audit log for a request from that username
+ // in the run's time window - the query mechanism is CSP-specific
+ // (CloudWatch/Cloud Logging/Azure Monitor), so it's left as a
+ // documented manual step rather than faked here.
+ }
+
+ return RunChecks(checks)
+}
+
+// splitRunFields splits gh's tab-separated `[.a, .b] | @tsv` output. A run
+// with no conclusion yet (still in progress) yields an empty first field,
+// which the caller reports as a non-success conclusion.
+func splitRunFields(out string) (conclusion, createdAt string, ok bool) {
+ parts := strings.Split(out, "\t")
+ if len(parts) != 2 || parts[1] == "" {
+ return "", "", false
+ }
+ return parts[0], parts[1], true
+}
diff --git a/docs/migrations/svid/validate/phase3.go b/docs/migrations/svid/validate/phase3.go
new file mode 100644
index 0000000..6eb7db1
--- /dev/null
+++ b/docs/migrations/svid/validate/phase3.go
@@ -0,0 +1,89 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "strings"
+)
+
+// RunPhase3 covers the cutover phase. It reports both the pre-deploy gate
+// and the post-deploy checks together for design-review purposes, but note
+// the distinction: PreDeployGate is meant to be called directly from inside
+// the CI job (e.g. `go run . phase3-gate` from this directory) and must fail-closed on
+// the first error - no aggregation, no "3 of 4 checks passed, proceeding
+// anyway." This function's use of RunChecks (which aggregates) is only
+// appropriate for a standalone review run, never for the inline gate.
+func RunPhase3(cfg Config) bool {
+ fatalIfMissingTools("kubectl", "gh")
+
+ fmt.Println("--- pre-deploy gate (fail-closed; see PreDeployGate for the inline version) ---")
+ preOK := true
+ if err := PreDeployGate(cfg); err != nil {
+ fmt.Printf("[FAIL] pre-deploy gate: %v\n", err)
+ preOK = false
+ } else {
+ fmt.Println("[ OK ] pre-deploy gate")
+ }
+
+ fmt.Println("--- post-deploy checks ---")
+ // Fetched once: both workflow assertions below read the same definition.
+ workflow, workflowErr := fetchWorkflowContent(cfg)
+
+ postChecks := []Check{
+ {
+ Name: "deploy workflow no longer references the legacy secret",
+ Run: func() error {
+ if workflowErr != nil {
+ return workflowErr
+ }
+ if strings.Contains(workflow, cfg.LegacySecretName) {
+ return fmt.Errorf("workflow still references secrets.%s - cutover is not complete", cfg.LegacySecretName)
+ }
+ return nil
+ },
+ },
+ {
+ Name: "workflow requests id-token: write (required for the OIDC->SVID exchange)",
+ Run: func() error {
+ if workflowErr != nil {
+ return workflowErr
+ }
+ if !requestsIDTokenWrite(workflow) {
+ return fmt.Errorf("workflow does not request id-token: write - the SVID exchange step will fail")
+ }
+ return nil
+ },
+ },
+ {
+ Name: "legacy resources are annotated pending-decommission, not yet removed",
+ Run: func() error {
+ out, err := sh("kubectl", "get", "serviceaccount", legacyServiceAccountName, "-n", cfg.K8sNamespace,
+ "-o", "jsonpath={.metadata.annotations.migration\\.defakto\\.io/status}")
+ if err != nil {
+ return fmt.Errorf("could not read legacy service account: %s", out)
+ }
+ if out != "pending-decommission" {
+ return fmt.Errorf("expected annotation pending-decommission, got %q - confirm the stack was applied at phase>=3", out)
+ }
+ return nil
+ },
+ },
+ }
+ postOK := RunChecks(postChecks)
+
+ return preOK && postOK
+}
+
+// PreDeployGate is the fail-closed check meant to run as a step immediately
+// before `kubectl apply` in the deploy job itself. If the SVID exchange or
+// the resulting authentication fails, this returns an error and the caller
+// must abort the deploy - never fall back to the static credential. That
+// fallback is exactly the ambiguous, partially-applied failure mode the
+// objective calls out as worse than a blocked deploy.
+func PreDeployGate(cfg Config) error {
+ token := os.Getenv("CI_SVID_TOKEN")
+ if token == "" {
+ return fmt.Errorf("CI_SVID_TOKEN is not set - the workflow's SVID-exchange step must run before this gate and must itself fail the job if the exchange fails")
+ }
+ return tokenReview(token, true, mappedUsername(cfg))
+}
diff --git a/docs/migrations/svid/validate/phase4.go b/docs/migrations/svid/validate/phase4.go
new file mode 100644
index 0000000..51a1d11
--- /dev/null
+++ b/docs/migrations/svid/validate/phase4.go
@@ -0,0 +1,65 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "strings"
+)
+
+// RunPhase4 confirms decommission actually removed what it was supposed to
+// remove, and - just as importantly - that removing the old path didn't
+// change anything about the new path's trust boundary. Re-running the
+// phase-1 negative test here is deliberate: decommissioning is exactly the
+// kind of change that could accidentally coincide with someone loosening
+// the claim validation rules "since we don't need the old path anymore."
+func RunPhase4(cfg Config) bool {
+ fatalIfMissingTools("kubectl", "gh")
+
+ checks := []Check{
+ {
+ Name: "legacy ServiceAccount is gone",
+ Run: func() error {
+ return assertGone("serviceaccount", legacyServiceAccountName, "-n", cfg.K8sNamespace)
+ },
+ },
+ {
+ Name: "legacy ClusterRoleBinding is gone",
+ Run: func() error { return assertGone("clusterrolebinding", legacyClusterRoleBindingID) },
+ },
+ {
+ Name: "legacy GitHub Actions secret is gone",
+ Run: func() error {
+ out, err := sh("gh", "secret", "list", "--repo", fmt.Sprintf("%s/%s", cfg.GithubOrg, cfg.GithubRepo))
+ if err != nil {
+ return fmt.Errorf("could not list secrets: %s", out)
+ }
+ if strings.Contains(out, cfg.LegacySecretName) {
+ return fmt.Errorf("secret %s still present in repo secrets", cfg.LegacySecretName)
+ }
+ return nil
+ },
+ },
+ {
+ Name: "regression: out-of-scope SVID is still rejected after decommission",
+ Run: func() error {
+ token := os.Getenv("SVID_TEST_TOKEN_OUT_OF_SCOPE")
+ if token == "" {
+ return fmt.Errorf("set SVID_TEST_TOKEN_OUT_OF_SCOPE to re-run the phase-1 negative test")
+ }
+ return tokenReview(token, false, "")
+ },
+ },
+ {
+ Name: "regression: in-scope SVID still authenticates correctly after decommission",
+ Run: func() error {
+ token := os.Getenv("SVID_TEST_TOKEN_VALID")
+ if token == "" {
+ return fmt.Errorf("set SVID_TEST_TOKEN_VALID to re-run the phase-1 positive test")
+ }
+ return tokenReview(token, true, mappedUsername(cfg))
+ },
+ },
+ }
+
+ return RunChecks(checks)
+}
diff --git a/docs/migrations/svid/validate/preflight.go b/docs/migrations/svid/validate/preflight.go
new file mode 100644
index 0000000..16a37d2
--- /dev/null
+++ b/docs/migrations/svid/validate/preflight.go
@@ -0,0 +1,70 @@
+package main
+
+import "fmt"
+
+// RunPreflight (phase 0) confirms the assumptions this whole plan rests on,
+// before anything is touched. If any of these are wrong, every later phase's
+// blast-radius reasoning is wrong too.
+func RunPreflight(cfg Config) bool {
+ fatalIfMissingTools("kubectl", "gh")
+
+ checks := []Check{
+ {
+ Name: "legacy ServiceAccount exists",
+ Run: func() error {
+ out, err := sh("kubectl", "get", "serviceaccount", legacyServiceAccountName,
+ "-n", cfg.K8sNamespace, "-o", "name")
+ if err != nil {
+ return fmt.Errorf("expected pre-existing SA %s/%s, got: %s", cfg.K8sNamespace, legacyServiceAccountName, out)
+ }
+ return nil
+ },
+ },
+ {
+ Name: "legacy ClusterRoleBinding targets the legacy ServiceAccount and existing ClusterRole",
+ Run: func() error {
+ out, err := sh("kubectl", "get", "clusterrolebinding", legacyClusterRoleBindingID, "-o", "jsonpath={.roleRef.name}")
+ if err != nil {
+ return fmt.Errorf("clusterrolebinding %s not found: %s", legacyClusterRoleBindingID, out)
+ }
+ if out != cfg.ClusterRoleName {
+ return fmt.Errorf("expected roleRef %q, got %q - the plan assumes the role name is unchanged", cfg.ClusterRoleName, out)
+ }
+ return nil
+ },
+ },
+ {
+ // The OIDC ID token is free and always available to a workflow
+ // that asks for it, so phase 0 asserts the *absence* of the
+ // permission: it is added at cutover (phase 3), and finding it
+ // already granted means the workflow is not in the pre-migration
+ // state this plan's blast-radius reasoning assumes.
+ Name: "GitHub Actions workflow does not request id-token: write yet (added at cutover)",
+ Run: func() error {
+ content, err := fetchWorkflowContent(cfg)
+ if err != nil {
+ return err
+ }
+ if requestsIDTokenWrite(content) {
+ return fmt.Errorf("workflow %s already requests id-token: write - phase 0 expects the pre-migration workflow, so confirm nothing has already started the cutover", cfg.GithubWorkflow)
+ }
+ return nil
+ },
+ },
+ // Not automated here: confirming no external OIDC/JWT issuer is
+ // already trusted by the API server. Structured Authentication
+ // Configuration is a control-plane startup input, not a live
+ // Kubernetes API object - there is no `kubectl get` for it. In a
+ // real preflight this would come from the CSP's own API (e.g. `aws
+ // eks describe-cluster` and inspect the identity provider config,
+ // or the cluster's applied AuthenticationConfiguration if the CSP
+ // exposes one) or from the platform team's own change history.
+ // Flagged rather than faked with a check that always passes.
+ }
+
+ fmt.Println("--- preflight: not automated, confirm manually ---")
+ fmt.Println(" - no external OIDC/JWT issuer is currently trusted by the API server for this pipeline")
+ fmt.Println("--- preflight: automated checks ---")
+
+ return RunChecks(checks)
+}
diff --git a/docs/migrations/svid/workflow-examples/deploy-after.yml b/docs/migrations/svid/workflow-examples/deploy-after.yml
new file mode 100644
index 0000000..e7b4e2e
--- /dev/null
+++ b/docs/migrations/svid/workflow-examples/deploy-after.yml
@@ -0,0 +1,65 @@
+# AFTER: post phase-3 cutover. Not applied by anything in iac/ - shown for
+# context only. Diff against deploy-before.yml is the point: no long-lived
+# secret, and the SVID exchange failing must fail the job (no fallback).
+name: deploy
+on:
+ push:
+ branches: [main]
+
+permissions:
+ # This is the only "credential" GitHub grants the job for free - a
+ # short-lived, repo/workflow-scoped OIDC ID token. Nothing used it before
+ # this migration.
+ id-token: write
+ contents: read
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ environment: production
+ steps:
+ - uses: actions/checkout@v4
+
+ # Exchanges the GitHub OIDC ID token for a short-lived JWT-SVID scoped
+ # to this repo/workflow/environment. `defakto-cli` stands in for
+ # whatever Defakto's real GitHub Action/CLI is - see README.md
+ # assumption 2: this call is a runtime data-plane operation against
+ # Defakto's RemoteWorkloadAPI, not something Pulumi provisions.
+ #
+ # `set -e` (default for `run:` steps) means a failed exchange fails
+ # this step, which fails the job - by design, per the objective's
+ # "consistency over availability" priority. There is deliberately no
+ # `continue-on-error` and no fallback to a stored credential.
+ - name: Exchange GitHub OIDC token for a JWT-SVID
+ id: svid
+ run: |
+ token=$(defakto-cli fetch-svid \
+ --trust-domain acme.defakto.id \
+ --audience k8s.acme.defakto.id)
+ # Short-lived, but still a bearer credential: mask it before it
+ # reaches a step output, or any later `set -x`/error trace prints
+ # it into the run log verbatim.
+ echo "::add-mask::${token}"
+ echo "token=${token}" >> "$GITHUB_OUTPUT"
+
+ # Inline version of validate/phase3.go's PreDeployGate: fail hard,
+ # before touching the cluster, if the token doesn't actually
+ # authenticate. Belt-and-suspenders with the exchange step above
+ # failing on its own - this catches the case where the exchange
+ # "succeeded" but produced a token the API server won't accept
+ # (e.g. clock skew, JWKS not yet propagated after phase 1).
+ #
+ # `validate` is its own Go module, so the gate runs from that module's
+ # directory - `go run ./validate` from the repo root would not resolve.
+ - name: Pre-deploy auth gate
+ env:
+ CI_SVID_TOKEN: ${{ steps.svid.outputs.token }}
+ working-directory: docs/migrations/svid/validate
+ run: go run . phase3-gate
+
+ - name: Deploy
+ env:
+ KUBE_TOKEN: ${{ steps.svid.outputs.token }}
+ run: |
+ kubectl --token="$KUBE_TOKEN" --server=https://kube-apiserver.internal:6443 \
+ apply -f k8s/
diff --git a/docs/migrations/svid/workflow-examples/deploy-before.yml b/docs/migrations/svid/workflow-examples/deploy-before.yml
new file mode 100644
index 0000000..af78f1f
--- /dev/null
+++ b/docs/migrations/svid/workflow-examples/deploy-before.yml
@@ -0,0 +1,25 @@
+# BEFORE: current state (phase 0). Not applied by anything in iac/ - shown
+# for context only, so the diff to deploy-after.yml is legible in review.
+name: deploy
+on:
+ push:
+ branches: [main]
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ environment: production
+ steps:
+ - uses: actions/checkout@v4
+
+ # The long-lived credential this whole migration exists to remove.
+ # KUBECONFIG here embeds a static ServiceAccount token with no
+ # expiry tied to this workflow's lifecycle - it's valid until
+ # someone remembers to rotate it.
+ - name: Write kubeconfig
+ run: |
+ mkdir -p "$HOME/.kube"
+ echo "${{ secrets.KUBE_DEPLOY_KUBECONFIG }}" | base64 -d > "$HOME/.kube/config"
+
+ - name: Deploy
+ run: kubectl apply -f k8s/