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/