Compare commits

..

136 commits

Author SHA1 Message Date
eding 3dbaeab745 Merge pull request 'fix: avoid stale onboarding branches' (#64) from fix/stale-registration-branches into main
Reviewed-on: #64
2026-09-16 20:32:29 +02:00
eding 9908029629 fix: avoid stale onboarding branches 2026-09-16 20:26:43 +02:00
eding 0d7f40cc6d Merge pull request 'fix: consume protected platform values' (#63) from feat/platform-delivery-values into main
Reviewed-on: #63
2026-09-16 20:08:41 +02:00
eding c0ff0de8f7 fix: consume protected platform values 2026-09-16 19:57:39 +02:00
eding 04c7b67b5d Merge pull request 'feat: require protected platform branches' (#62) from feat/platform-app-branches into main
Reviewed-on: #62
2026-09-16 19:36:14 +02:00
eding 0f288f7730 feat: require protected platform branches 2026-09-16 19:33:45 +02:00
eding 8016a1063f Merge pull request 'feat: render central app delivery resources' (#61) from feat/central-app-delivery-resources into main
Reviewed-on: #61
2026-09-16 19:24:34 +02:00
eding fd1a88b1b3 feat: render central app delivery resources 2026-09-16 19:21:04 +02:00
eding 719abc3702 Merge pull request 'docs: define central delivery ownership' (#60) from docs/central-delivery-ownership into main
Reviewed-on: #60
2026-09-16 19:02:38 +02:00
eding 3bcac32072 docs: define central delivery ownership 2026-09-16 18:57:47 +02:00
eding 5701e511f8 Merge pull request 'fix: publish runtime secret access' (#59) from fix/publish-runtime-secret-access into main
Reviewed-on: #59
2026-09-16 12:24:39 +02:00
eding 7abb3ba1e4 fix: publish runtime secret access 2026-09-16 12:06:43 +02:00
eding b291f2dbe0 Merge pull request 'fix: reuse existing migration pull requests' (#58) from fix/reuse-existing-migration-pr into main
Reviewed-on: #58
2026-09-16 11:00:15 +02:00
eding 90c4c1088a fix: reuse existing migration pull requests 2026-09-16 09:16:45 +02:00
eding aff2ccc3fc Merge pull request 'feat: review app onboarding changes' (#57) from feat/reviewed-app-onboarding into main
Reviewed-on: #57
2026-09-16 09:06:19 +02:00
eding 97e7b59910 docs: explain legacy app registration migration 2026-09-16 08:56:28 +02:00
eding 799a51b485 feat: review app onboarding changes 2026-09-16 08:33:13 +02:00
eding c1305325f3 Merge pull request 'feat: select runtime delivery builds' (#56) from feat/runtime-delivery-selector into main
Reviewed-on: #56
2026-09-15 20:43:14 +02:00
eding 8684ed9427 Merge pull request 'feat: onboard canonical E2E sources' (#55) from feat/canonical-e2e-onboarding into main
Reviewed-on: #55
2026-09-15 20:32:43 +02:00
eding 11f9271fd0 fix: leave delivery PRs for review 2026-09-15 20:25:20 +02:00
eding 08851c6324 feat: select runtime delivery builds 2026-09-15 20:04:00 +02:00
eding 67518977eb feat: onboard canonical E2E sources 2026-09-15 01:10:05 +02:00
eding 7df7d03dbe Merge pull request 'fix: target canonical E2E fixtures' (#54) from fix/e2e-mutate-canonical-owner into main
Reviewed-on: #54
2026-09-15 00:50:38 +02:00
eding 025b828958 fix: target canonical E2E fixtures 2026-09-15 00:46:58 +02:00
eding a5e01da4e0 Merge pull request 'feat: refresh template revisions during reconcile' (#53) from feat/template-revision-refresh into main
Reviewed-on: #53
2026-09-15 00:27:27 +02:00
eding 11b8ee99a5 feat: refresh template revisions during reconcile 2026-09-15 00:25:40 +02:00
eding 04ba29f214 Merge pull request 'docs: define canonical E2E ownership' (#52) from docs/e2e-source-ownership into main
Reviewed-on: #52
2026-09-15 00:12:25 +02:00
eding 19ce9ae164 docs: define canonical E2E ownership 2026-09-15 00:02:23 +02:00
eding 8e546e1e50 Merge pull request 'feat: add scoped E2E fixture mutations' (#51) from feat/e2e-mutation into main
Reviewed-on: #51
2026-09-14 23:53:30 +02:00
eding 732270221d feat: add scoped E2E fixture mutations 2026-09-14 23:46:07 +02:00
eding a5f1cbb657 Merge pull request 'fix: register Forgejo webhooks without Secret reads' (#50) from fix/webhook-secret-sync into main
Reviewed-on: #50
2026-09-14 23:32:10 +02:00
eding 10b2362218 Merge pull request 'docs: require PR handoff' (#49) from docs/pr-handoff into main
Reviewed-on: #49
2026-09-14 23:31:50 +02:00
eding 523148a4be docs: standardize review handoff 2026-09-14 23:28:00 +02:00
eding 3ec1097fa3 fix: register Forgejo webhooks without Secret reads 2026-09-14 23:11:00 +02:00
eding dd1897d916 docs: require PR handoff 2026-09-14 23:07:25 +02:00
eding d4f6b74bef Merge pull request 'feat: render runtime secret GitOps' (#48) from feat/app-secret-gitops into main
Reviewed-on: #48
2026-09-14 23:04:28 +02:00
eding 9109b2b367 feat: render runtime secret GitOps 2026-09-14 23:02:09 +02:00
eding 2b545f662f feat: support secure Forgejo password files 2026-09-13 17:23:57 +02:00
eding beca1b5d36 docs: recover Forgejo registry package access 2026-09-13 17:15:35 +02:00
eding bc166e4973 fix: keep static builds off shared workspaces 2026-09-13 15:46:35 +02:00
eding a867c209c3 fix: preserve generated Tekton apps 2026-09-13 14:07:47 +02:00
eding 7b1de17e5d fix: retain app registrations across refresh 2026-09-13 13:55:02 +02:00
eding 66d42b6ca4 fix: preserve generated app registrations 2026-09-13 12:27:21 +02:00
eding 9f1c05a4d4 fix: bind runtime secret roles to environments 2026-09-13 12:18:12 +02:00
eding 217f03d4be fix: use valid E2E probe secret path 2026-09-13 11:49:41 +02:00
eding 1bca082c77 fix: parse provisioned identity tokens 2026-09-13 11:47:20 +02:00
eding 20381ebddf feat: automate restricted app-secret identities 2026-09-13 11:46:08 +02:00
eding d97bc041f9 fix: report local static build status 2026-09-13 11:05:02 +02:00
eding 45f16ab9bc feat: support per-app onboarding overrides 2026-09-13 10:40:55 +02:00
eding 7d1cf8ae6d fix: build static apps on local workspace 2026-09-13 09:27:42 +02:00
eding 4281e63750 feat: manage app OpenBao secrets 2026-09-13 09:27:42 +02:00
eding 7e4d50b776 Merge pull request 'fix: allow piped Forgejo credentials' (#47) from fix/delivery-status-token into main 2026-09-13 08:56:39 +02:00
eding 29c7180259 fix: allow piped Forgejo credentials 2026-09-13 08:56:13 +02:00
eding c9a8e38914 Merge pull request 'fix: provision Forgejo delivery status token' (#46) from fix/delivery-status-token into main 2026-09-13 01:20:42 +02:00
eding a62799d531 fix: provision Forgejo delivery status token 2026-09-13 01:19:52 +02:00
eding d6333d9775 Merge pull request 'feat: add portable delivery E2E runner' (#45) from feat/oci-e2e-runner into main
Reviewed-on: #45
2026-09-13 00:29:18 +02:00
eding e86732d219 feat: add portable delivery E2E runner 2026-09-13 00:19:23 +02:00
eding 7789c80f25 Merge pull request 'feat: report delivery status to developers' (#44) from feat/developer-delivery-status into main
Reviewed-on: #44
2026-09-13 00:04:32 +02:00
eding b26c2d3165 feat: report delivery status to developers 2026-09-12 23:59:20 +02:00
eding eba3df7bcd Merge pull request 'fix: bound OpenBao mutation commands' (#41) from fix/openbao-mutation-timeouts into main
Reviewed-on: #41
2026-09-12 23:34:40 +02:00
eding 5954a8e99b fix: bound OpenBao mutation commands 2026-09-12 19:52:36 +02:00
eding 31751d7eb7 Merge pull request 'fix: parse uninitialized OpenBao status' (#40) from fix/openbao-status-readiness into main
Reviewed-on: #40
2026-09-10 23:07:40 +02:00
eding 99eb19fba4 fix: parse uninitialized OpenBao status 2026-09-10 23:06:15 +02:00
eding 026b191c84 Merge pull request 'fix: bound OpenBao initialization tools' (#39) from fix/openbao-initialization-timeouts into main
Reviewed-on: #39
2026-09-09 22:46:42 +02:00
eding b7b9068f9c fix: bound OpenBao initialization tools 2026-09-09 22:44:57 +02:00
eding 0cf8fa7557 Merge pull request 'fix: merge all bootstrap migrations' (#38) from fix/bootstrap-migration-repositories into main
Reviewed-on: #38
2026-09-09 21:23:49 +02:00
eding 3ea55e34e0 fix: merge all bootstrap migrations 2026-09-09 21:21:27 +02:00
eding 32734dafff Merge pull request 'feat: purge cluster CSI storage on rebuild' (#37) from feat/rebuild-csi-storage into main
Reviewed-on: #37
2026-09-09 21:02:15 +02:00
eding def841532b feat: purge cluster CSI storage on rebuild 2026-09-09 20:59:03 +02:00
eding 00cb4ead63 Merge pull request 'fix: close OpenBao recovery probe input' (#36) from fix/openbao-recovery-probe into main
Reviewed-on: #36
2026-09-08 17:47:24 +02:00
eding 83ddc5f241 fix: close OpenBao recovery probe input 2026-09-08 17:45:09 +02:00
eding e8cef9b66e Merge pull request 'fix: bound OpenBao bootstrap commands' (#35) from fix/openbao-command-timeout into main
Reviewed-on: #35
2026-09-08 17:02:08 +02:00
eding f5015c67c0 fix: bound OpenBao bootstrap commands 2026-09-08 16:57:30 +02:00
eding b55602ce23 Merge pull request 'fix: report OpenBao auth setup stage' (#34) from fix/openbao-auth-diagnostics into main
Reviewed-on: #34
2026-09-08 16:46:13 +02:00
Maidn 888f4aa88b fix: report OpenBao auth setup stage 2026-09-08 16:42:30 +02:00
eding 461e4a02ab Merge pull request 'feat: scope application secret grants' (#33) from fix/fresh-delivery-contract into main
Reviewed-on: #33
2026-09-08 15:34:16 +02:00
Maidn ac215f40ab feat: scope application secret grants 2026-09-08 15:24:10 +02:00
eding 8cf30c0429 Merge pull request 'fix: support fresh delivery promotion' (#32) from fix/fresh-delivery-contract into main
Reviewed-on: #32
2026-09-06 21:17:55 +02:00
eding 7038197757 fix: support fresh delivery promotion 2026-09-06 21:16:48 +02:00
eding d3f204c873 Merge pull request 'fix: generate Talconfig during reconcile' (#31) from fix/reconcile-talconfig into main
Reviewed-on: #31
2026-09-06 20:39:37 +02:00
eding 044bd865c0 fix: generate Talconfig during reconcile 2026-09-06 20:37:05 +02:00
eding c431156498 Merge pull request 'feat: add platform app onboarding' (#30) from feat/platform-app-onboarding into main
Reviewed-on: #30
2026-09-06 20:26:06 +02:00
eding 112fc5376b feat: add platform app onboarding 2026-09-06 20:22:20 +02:00
eding 43695c7b29 Merge pull request 'fix: recover externally removed Talos VMs' (#29) from fix/rebuild-absent-vm into main
Reviewed-on: #29
2026-09-06 10:55:17 +02:00
eding 50989db2fe fix: replace delivery contract on import branch 2026-09-06 10:02:51 +02:00
eding 8e4672010b fix: publish app from external source checkout 2026-09-06 09:59:14 +02:00
eding 214b00720a fix: defer webhook refresh until Tekton exists 2026-09-06 09:51:05 +02:00
eding 62540c69a2 fix: detect absent Proxmox VM response 2026-09-06 09:38:46 +02:00
eding 54a2ae2838 fix: recover externally removed Talos VMs 2026-09-06 09:35:05 +02:00
eding 055b658567 Merge pull request 'docs: record cluster removal requirements' (#28) from docs/uninstall-command-gap into main
Reviewed-on: #28
2026-09-06 09:31:33 +02:00
eding eb840a5468 Merge remote-tracking branch 'origin/main' into docs/uninstall-command-gap 2026-09-06 00:24:52 +02:00
eding d0df184af4 docs: record cluster removal requirements 2026-09-06 00:24:52 +02:00
eding 769028c587 Merge pull request 'fix: complete fresh bootstrap setup' (#27) from fix/fresh-bootstrap-autocommit into main
Reviewed-on: #27
2026-09-05 23:55:03 +02:00
eding 0b8147dd4f fix: complete fresh bootstrap setup 2026-09-05 23:45:59 +02:00
eding c80b860fcb Merge pull request 'docs: contain credential rotation guidance' (#26) from docs/credential-containment into main
Reviewed-on: #26
2026-09-05 21:17:19 +02:00
eding f09cdf4ee9 Merge pull request 'feat: scaffold fresh organization bootstrap' (#25) from feat/fresh-organization-bootstrap into main
Reviewed-on: #25
2026-09-05 21:16:03 +02:00
eding 9d24194637 Merge pull request 'fix: restrict OpenBao unseal secret' (#24) from fix/openbao-sops-recovery into main
Reviewed-on: #24
2026-09-05 21:14:30 +02:00
eding fe3ce96511 fix: run fresh bootstrap lifecycle 2026-09-05 20:53:07 +02:00
eding 651c2938d5 feat: scaffold fresh organization bootstrap 2026-09-05 20:28:29 +02:00
eding c659bd3c1f docs: contain credential rotation guidance 2026-09-05 20:28:24 +02:00
eding 0bef3b5f03 fix: restrict OpenBao unseal secret 2026-09-05 20:28:18 +02:00
eding 2478f08e5c Merge pull request 'fix: reconcile delivery after reset' (#23) from fix/delivery-e2e-recovery into main
Reviewed-on: #23
2026-09-05 14:22:24 +02:00
eding ee517273b2 fix: reconcile delivery after reset 2026-09-05 13:23:53 +02:00
eding 4ec361ec1d Merge pull request 'fix: read Cilium version from template' (#22) from fix/cilium-version-template-path into main
Reviewed-on: #22
2026-08-31 19:00:52 +02:00
eding 1777cfce20 fix: read Cilium version from template 2026-08-31 18:57:48 +02:00
eding 5e98336cdf Merge pull request 'fix: read Cilium version from workspace' (#21) from fix/cilium-version-workspace-path into main
Reviewed-on: #21
2026-08-31 18:52:49 +02:00
eding 3f903afd13 fix: read Cilium version from workspace 2026-08-31 18:49:27 +02:00
eding 8e8f80e06f Merge pull request 'feat: regenerate app delivery source' (#20) from feat/regenerate-app-delivery-source into main
Reviewed-on: #20
2026-08-31 18:44:26 +02:00
eding d48547229a feat: regenerate app delivery source 2026-08-31 18:40:56 +02:00
eding 2d3840653a fix: read Cilium version from HelmRelease 2026-08-31 18:34:20 +02:00
eding e5bfb3b2b7 Merge pull request 'fix: retain Hubble during Cilium reconcile' (#19) from fix/retain-hubble-reconcile into main
Reviewed-on: #19
2026-08-31 18:27:17 +02:00
eding 8dd95824c0 fix: retain Hubble during Cilium reconcile 2026-08-31 18:15:53 +02:00
eding 8b26e1422b Merge pull request 'fix: handle ANSI Terraform empty state' (#18) from fix/terraform-no-state-ansi into main
Reviewed-on: #18
2026-08-23 20:57:47 +02:00
eding 1ae08491b6 fix: keep VM changes rebuild-only 2026-08-23 20:22:09 +02:00
eding 634f415716 fix: import retained Talos VMs on reconcile 2026-08-23 20:14:21 +02:00
eding 476259bc18 fix: handle ANSI Terraform empty state 2026-08-23 19:56:10 +02:00
eding e8bcebf65f Merge pull request 'fix: accept empty Terraform state imports' (#17) from fix/empty-terraform-state-import into main
Reviewed-on: #17
2026-08-23 19:44:53 +02:00
eding 2852569aa3 fix: accept empty Terraform state imports 2026-08-23 19:43:06 +02:00
eding 8b4fef00a8 Merge pull request 'fix: require complete delivery for reconcile' (#16) from fix/delivery-opt-in-reconcile into main
Reviewed-on: #16
2026-08-23 19:23:29 +02:00
eding 433305e0aa fix: require complete delivery for reconcile 2026-08-23 19:20:56 +02:00
eding ce4194e5b2 Merge pull request 'fix: tolerate partial delivery config' (#15) from fix/partial-delivery-reconcile into main
Reviewed-on: #15
2026-08-23 19:06:39 +02:00
eding 5c7bbf6e21 fix: tolerate partial delivery config 2026-08-23 19:01:18 +02:00
eding e0bc2a34b0 Merge pull request 'fix: allow platform-only reconcile' (#14) from fix/platform-reconcile-delivery-validation into main
Reviewed-on: #14
2026-08-22 14:31:42 +02:00
eding dbd370b20d fix: allow platform-only reconcile 2026-08-22 14:29:47 +02:00
eding ba79613064 Merge pull request 'feat: harden bootstrap and generic delivery' (#13) from feat/recovery-delivery-hardening into main
Reviewed-on: #13
2026-08-22 14:03:56 +02:00
eding 7b64f8fbdb feat: harden bootstrap and generic delivery 2026-08-22 13:58:28 +02:00
eding 6437318f91 Merge pull request 'fix: normalize Cloudflare tunnel properties' (#12) from fix/normalize-cloudflare-properties into main
Reviewed-on: #12
2026-08-04 22:23:51 +02:00
eding 58478b50ae fix: normalize Cloudflare tunnel properties 2026-08-04 22:06:37 +02:00
eding 92ae57121d Merge pull request 'feat: import Cloudflare tunnel credentials' (#11) from fix/import-cloudflare-tunnel-credentials into main
Reviewed-on: #11
2026-08-04 20:55:34 +02:00
eding a08eb8616d fix: import Cloudflare Secret credentials 2026-08-04 20:48:18 +02:00
eding f17097ecae feat: import Cloudflare tunnel credentials 2026-08-04 00:51:03 +02:00
eding de8e8209cf Merge pull request 'feat: manage Cloudflare tunnels' (#10) from feat/managed-cloudflare-tunnel into main
Reviewed-on: #10
2026-08-04 00:18:52 +02:00
eding bfd1618403 feat: manage Cloudflare tunnels 2026-08-04 00:04:29 +02:00
eding eebd36cca1 Merge pull request 'fix: refresh Cloudflare tunnel template' (#9) from fix/refresh-cloudflare-template into main
Reviewed-on: #9
2026-08-03 00:00:08 +02:00
eding 1df14ef62e fix: refresh Cloudflare tunnel template 2026-08-02 23:53:58 +02:00
eding f21635a0a6 Merge pull request 'fix: refresh generated template components' (#8) from fix/template-refresh-reconcile into main
Reviewed-on: #8
2026-08-02 21:39:01 +02:00
56 changed files with 9650 additions and 417 deletions

16
.dockerignore Normal file
View file

@ -0,0 +1,16 @@
.git
.age
.recovery
maidn-bootstrap*.yaml
maidn-workspace
*.sops.yaml
terraform.tfvars
*.tfvars
*.tfstate*
kubeconfig
*.kubeconfig
*.kube
.kube
clusterconfig
*.key
*.pem

52
AGENTS.md Normal file
View file

@ -0,0 +1,52 @@
# MaidnCLI Review And Delivery Contract
## Branches And Pull Requests
- Keep each safe, reviewable change on one scoped branch.
- Do not leave completed work only in a local worktree or a pushed branch.
- Open a Forgejo pull request for every completed branch unless the user explicitly authorizes a direct merge.
- Verify the pull request exists, has the intended `head` and `base`, and return its URL.
- Do not claim a pull request is open until it is verified through the Forgejo API or UI.
- Update an existing pull request when follow-up work belongs to its scope; open another only for an independent change.
- Merge only when the user explicitly authorizes the named pull request or branch.
## E2E Ownership
- Canonical E2E fixture source repositories are `Maidn/maidn-e2e-*`.
- The testing suite, onboarding, and mutation E2E commands must target `Maidn` fixture sources.
- `test-org-2` is disposable execution state only. It may host temporary delivery branches and resources, but it is never a fixture source or test-suite owner.
## Delivery Ownership
- Application repositories are build inputs only; do not add or update active
`.tekton/` or `.maidn/` delivery resources in them.
- The cluster repository owns Pipelines, Tasks, triggers, and runtime secret
access. The manifests repository owns image tags and promotion state.
- Flux chart sources must use only the protected `maidn/platform-<app>` branch,
never an application `main` or `maidn/delivery-*` branch.
## Required Checks
- Before each commit: inspect `git status --short`, `git diff --check`, and `git log --oneline -10`.
- Before review: run the applicable focused and repository checks, then record the commands and results.
- Never commit generated workspaces, `.password`, SOPS material, kubeconfigs, Terraform state, recovery material, or token files.
## Review Handoff Format
Use this exact format whenever user review or merge is required:
```text
Review required
PR: <URL>
Branch: <name>
Purpose: <one sentence>
Checks: <command> - PASS|FAIL|BLOCKED
Risk: <one sentence, or none>
Merge: <merge action the reviewer should take>
```
## API Failure
- Retry with the target repository owner, not a disposable-cluster owner.
- Report the HTTP status and non-sensitive response shape only.
- A compare URL is a fallback only after PR creation has genuinely failed; it is not a substitute for an opened PR.

15
Dockerfile Normal file
View file

@ -0,0 +1,15 @@
FROM golang:1.24.0-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/cicd-tool .
FROM alpine:3.22
ARG KUBECTL_VERSION=v1.33.4
RUN apk add --no-cache ca-certificates curl \
&& curl -fsSL "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" -o /usr/local/bin/kubectl \
&& chmod 0755 /usr/local/bin/kubectl
COPY --from=build /out/cicd-tool /usr/local/bin/cicd-tool
USER 65532:65532
ENTRYPOINT ["cicd-tool"]

View file

@ -1,2 +1,11 @@
edingrech
dckr_pat_bfKKDH4g3qUxchs9UMLxFx2oTiU
# Credential onboarding
Do not put credential values in this file, Git, generated configuration, command arguments, terminal history, or logs.
1. Obtain written authorization for `<credential-purpose>`, `<consumer-inventory>`, `<approved-scope>`, and `<rotation-window>`.
2. Have the authorized operator enter `<credential-value>` only through the approved secure prompt or standard input boundary.
3. Store it only in the configured SOPS-encrypted operational-secrets file, then reseed and verify OpenBao before changing consumers.
4. Keep the previous credential active only for the approved overlap window; revoke it only after every consumer check succeeds.
5. Record `<credential-identifier>`, `<timestamp>`, `<operator>`, and `<status>` without recording any credential value.
See [docs/secrets.md](docs/secrets.md) and [docs/runbooks/credential-rotation.md](docs/runbooks/credential-rotation.md).

View file

@ -1,23 +1,16 @@
## Commands
go mod init github.com/Pingu-Studio/MaidnCLI
go get -u github.com/spf13/cobra@latest
go get golang.org/x/term
go mod tidy
go get gopkg.in/yaml.v3
in powershell run
```powershell
go install github.com/go-delve/delve/cmd/dlv@latest
dlv version
```
## Commands
- `cicd-tool repo init --org <org> --flux-repo <repo>` creates the manifests and Flux repos
- `cicd-tool repo init` is a legacy GitHub workflow and is not used for new Forgejo/GitOps platform onboarding.
- `cicd-tool bootstrap` runs a shorter Forgejo-first wizard, asks for a Forgejo PAT, asks where local repos should be cloned, discovers Proxmox nodes/storage/networks, retries without losing entered answers when discovery fails, shows the latest Talos version, derives the standardized Talos factory URL, schematic, and required extensions automatically from the chosen version, writes `terraform.tfvars`, stages Talos images on Proxmox, and can execute Terraform, Talos bootstrap, and Flux bootstrap
- `cicd-tool bootstrap --config maidn-bootstrap.yaml` skips the wizard and uses the saved config
- `cicd-tool bootstrap init --config <private-config> --organization <new-org> --create-organization` locks an isolated workspace, initializes Forgejo repositories, then runs the non-destructive bootstrap reconcile lifecycle. Use `--mode=rebuild --yes` only for an authorized rebuild.
- `cicd-tool app onboard --config <private-config> --from <app-checkout>` is being migrated to central delivery ownership. Do not use the source-owned implementation for new applications; see `docs/architecture/delivery-ownership.md`.
- `cicd-tool e2e` runs bounded, read-only Flux, ExternalSecret, PipelineRun, preview, and promotion-PR checks with JSON output. See `docs/e2e.md`.
See `docs/operations.md` for the authorized operating and verification runbook.
See `docs/architecture/delivery-ownership.md` for the developer and platform
ownership boundary.
App authors: see `docs/delivery-feedback.md` for preview feedback and the scoped Forgejo token contract.
## Forgejo setup

295
cmd/app_secret.go Normal file
View file

@ -0,0 +1,295 @@
package cmd
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
"strings"
"github.com/Pingu-Studio/MaidnCLI/internal/bootstrap"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/Pingu-Studio/MaidnCLI/internal/openbao"
"github.com/spf13/cobra"
)
var appSecretConfigPath, appSecretFile, appSecretTokenFile, appSecretIdentity, appSecretGrantEnvironment string
var appSecretShared, appSecretDeleteYes, appSecretGenerate bool
var appSecretGrantShared, appSecretGrantSecrets []string
var loadAppSecretConfig = config.Load
var saveAppSecretConfig = config.Save
var storeAppSecret = openbao.StoreManagedSecret
var listAppSecrets = openbao.ListManagedSecrets
var appSecretStatus = openbao.ManagedSecretStatus
var deleteAppSecret = openbao.DeleteManagedSecret
var appSecretCmd = &cobra.Command{
Use: "secret",
Short: "Manage application and shared OpenBao secret values.",
}
var appSecretSetCmd = &cobra.Command{
Use: "set <app-or-group> <secret>",
Short: "Store a value from stdin or --file.",
Args: cobra.ExactArgs(2),
RunE: runAppSecretSet,
}
var appSecretListCmd = &cobra.Command{
Use: "list <app-or-group>",
Short: "List secret names without values.",
Args: cobra.ExactArgs(1),
RunE: runAppSecretList,
}
var appSecretDeleteCmd = &cobra.Command{
Use: "delete <app-or-group> <secret>",
Short: "Permanently delete a secret after explicit confirmation.",
Args: cobra.ExactArgs(2),
RunE: runAppSecretDelete,
}
var appSecretGrantCmd = &cobra.Command{
Use: "grant <app> <build|publish|runtime>",
Short: "Add a declarative SecretGrant to the private bootstrap config.",
Args: cobra.ExactArgs(2),
RunE: runAppSecretGrant,
}
var appSecretStatusCmd = &cobra.Command{
Use: "status <app-or-group> <secret>",
Short: "Report whether a secret exists without reading its value.",
Args: cobra.ExactArgs(2),
RunE: runAppSecretStatus,
}
func init() {
appCmd.AddCommand(appSecretCmd)
appSecretCmd.AddCommand(appSecretSetCmd, appSecretListCmd, appSecretDeleteCmd, appSecretGrantCmd, appSecretStatusCmd)
appSecretCmd.PersistentFlags().StringVar(&appSecretConfigPath, "config", "", "Path to private bootstrap config YAML")
_ = appSecretCmd.MarkPersistentFlagRequired("config")
appSecretCmd.PersistentFlags().StringVar(&appSecretTokenFile, "token-file", "", "Path to restricted OpenBao token file for secret CRUD")
appSecretCmd.PersistentFlags().StringVar(&appSecretIdentity, "identity", "", "Encrypted operational identity: admin or e2e:<app>")
appSecretSetCmd.Flags().StringVar(&appSecretFile, "file", "", "Read the secret value from this file instead of stdin")
appSecretSetCmd.Flags().BoolVar(&appSecretGenerate, "generate", false, "Generate a random secret value without printing it")
for _, command := range []*cobra.Command{appSecretSetCmd, appSecretListCmd, appSecretDeleteCmd, appSecretStatusCmd} {
command.Flags().BoolVar(&appSecretShared, "shared", false, "Use shared/<group>/<secret> instead of apps/<app>/<secret>")
}
appSecretDeleteCmd.Flags().BoolVar(&appSecretDeleteYes, "yes", false, "Confirm permanent deletion")
appSecretGrantCmd.Flags().StringVar(&appSecretGrantEnvironment, "environment", "", "Runtime environment: staging or production")
appSecretGrantCmd.Flags().StringSliceVar(&appSecretGrantSecrets, "secret", nil, "Application secret name granted to this consumer (repeat for each)")
appSecretGrantCmd.Flags().StringSliceVar(&appSecretGrantShared, "shared", nil, "Shared secret group allowed by this grant")
}
func runAppSecretSet(cmd *cobra.Command, args []string) error {
path, kubeconfig, err := appSecretTarget(args)
if err != nil {
return err
}
value, err := readAppSecretValue(cmd)
if err != nil {
return err
}
tokenFile, cleanup, err := appSecretTokenPath()
if err != nil {
return err
}
defer cleanup()
if err := storeAppSecret(kubeconfig, tokenFile, path, value); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "stored %s\n", path)
return nil
}
func runAppSecretList(cmd *cobra.Command, args []string) error {
kubeconfig, err := appSecretKubeconfigFromConfig()
if err != nil {
return err
}
tokenFile, cleanup, err := appSecretTokenPath()
if err != nil {
return err
}
defer cleanup()
values, err := listAppSecrets(kubeconfig, tokenFile, appSecretShared, args[0])
if err != nil {
return err
}
for _, value := range values {
fmt.Fprintln(cmd.OutOrStdout(), value)
}
return nil
}
func runAppSecretDelete(cmd *cobra.Command, args []string) error {
if !appSecretDeleteYes {
return errors.New("delete requires --yes")
}
path, kubeconfig, err := appSecretTarget(args)
if err != nil {
return err
}
tokenFile, cleanup, err := appSecretTokenPath()
if err != nil {
return err
}
defer cleanup()
if err := deleteAppSecret(kubeconfig, tokenFile, path); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "deleted %s\n", path)
return nil
}
func runAppSecretGrant(cmd *cobra.Command, args []string) error {
cfg, err := loadRequiredAppSecretConfig()
if err != nil {
return err
}
grant := config.SecretGrant{Application: args[0], Consumer: args[1], Environment: appSecretGrantEnvironment, Secrets: appSecretGrantSecrets, Shared: appSecretGrantShared}
if err := config.ValidateSecretGrants([]config.SecretGrant{grant}); err != nil {
return err
}
for _, existing := range cfg.SecretGrants {
if existing.Application == grant.Application && existing.Consumer == grant.Consumer && existing.Environment == grant.Environment {
if reflect.DeepEqual(existing, grant) {
fmt.Fprintln(cmd.OutOrStdout(), "grant already declared")
return nil
}
return errors.New("secret grant already exists with a different definition")
}
}
cfg.SecretGrants = append(cfg.SecretGrants, grant)
if err := config.ValidateSecretGrants(cfg.SecretGrants); err != nil {
return err
}
if err := saveAppSecretConfig(appSecretConfigPath, cfg); err != nil {
return fmt.Errorf("save declarative secret grant: %w", err)
}
fmt.Fprintln(cmd.OutOrStdout(), "grant declared; reconcile OpenBao through the reviewed bootstrap workflow")
return nil
}
func runAppSecretStatus(cmd *cobra.Command, args []string) error {
path, kubeconfig, err := appSecretTarget(args)
if err != nil {
return err
}
tokenFile, cleanup, err := appSecretTokenPath()
if err != nil {
return err
}
defer cleanup()
present, err := appSecretStatus(kubeconfig, tokenFile, path)
if err != nil {
return err
}
if present {
fmt.Fprintf(cmd.OutOrStdout(), "%s: present\n", path)
} else {
fmt.Fprintf(cmd.OutOrStdout(), "%s: absent\n", path)
}
return nil
}
func appSecretTarget(args []string) (string, string, error) {
if len(args) != 2 {
return "", "", errors.New("secret target requires an application or shared group and a secret name")
}
path, err := openbao.ManagedSecretPath(appSecretShared, args[0], args[1])
if err != nil {
return "", "", err
}
kubeconfig, err := appSecretKubeconfigFromConfig()
return path, kubeconfig, err
}
func loadRequiredAppSecretConfig() (config.Config, error) {
if appSecretConfigPath == "" {
return config.Config{}, errors.New("--config is required")
}
return loadAppSecretConfig(appSecretConfigPath)
}
func appSecretKubeconfigFromConfig() (string, error) {
cfg, err := loadRequiredAppSecretConfig()
if err != nil {
return "", err
}
return filepath.Join(cfg.Git.CloneParent, cfg.Talos.RepoDirName, cfg.Talos.GeneratedDir, "kubeconfig"), nil
}
func readAppSecretValue(cmd *cobra.Command) ([]byte, error) {
if appSecretGenerate {
if appSecretFile != "" {
return nil, errors.New("--generate and --file cannot be used together")
}
value := make([]byte, 32)
if _, err := rand.Read(value); err != nil {
return nil, err
}
return []byte(base64.RawURLEncoding.EncodeToString(value)), nil
}
if appSecretFile != "" {
value, err := os.ReadFile(appSecretFile)
if err != nil {
return nil, fmt.Errorf("read secret file: %w", err)
}
return value, nil
}
value, err := io.ReadAll(cmd.InOrStdin())
if err != nil {
return nil, fmt.Errorf("read secret stdin: %w", err)
}
return value, nil
}
func appSecretTokenPath() (string, func(), error) {
if appSecretTokenFile != "" {
return appSecretTokenFile, func() {}, nil
}
if appSecretIdentity == "" {
return "", nil, errors.New("--token-file or --identity is required")
}
cfg, err := loadRequiredAppSecretConfig()
if err != nil {
return "", nil, err
}
path := ""
if appSecretIdentity == "admin" {
path = "cicd/app-secret-admin"
} else if strings.HasPrefix(appSecretIdentity, "e2e:") {
path = "cicd/e2e-" + strings.TrimPrefix(appSecretIdentity, "e2e:")
} else {
return "", nil, errors.New("--identity must be admin or e2e:<app>")
}
secrets, err := bootstrap.ReadOperationalSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath)
if err != nil {
return "", nil, errors.New("read encrypted app-secret identity")
}
token := secrets[path]["token"]
if token == "" {
return "", nil, errors.New("configured app-secret identity is absent")
}
file, err := os.CreateTemp("", "maidn-openbao-token-*")
if err != nil {
return "", nil, err
}
if _, err := file.WriteString(token + "\n"); err != nil {
file.Close()
os.Remove(file.Name())
return "", nil, err
}
if err := file.Close(); err != nil {
os.Remove(file.Name())
return "", nil, err
}
return file.Name(), func() { _ = os.Remove(file.Name()) }, nil
}

87
cmd/app_secret_test.go Normal file
View file

@ -0,0 +1,87 @@
package cmd
import (
"bytes"
"errors"
"path/filepath"
"strings"
"testing"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/spf13/cobra"
)
func TestAppSecretSetReadsValueFromStdinWithoutOutput(t *testing.T) {
originalLoad, originalStore := loadAppSecretConfig, storeAppSecret
originalConfig, originalFile, originalToken, originalShared := appSecretConfigPath, appSecretFile, appSecretTokenFile, appSecretShared
t.Cleanup(func() {
loadAppSecretConfig, storeAppSecret = originalLoad, originalStore
appSecretConfigPath, appSecretFile, appSecretTokenFile, appSecretShared = originalConfig, originalFile, originalToken, originalShared
})
appSecretConfigPath, appSecretFile, appSecretTokenFile, appSecretShared = "private.yaml", "", "restricted-token", false
loadAppSecretConfig = func(string) (config.Config, error) {
return config.Config{Git: config.GitConfig{CloneParent: "checkouts"}, Talos: config.TalosConfig{RepoDirName: "talos", GeneratedDir: "generated"}, SOPS: config.SOPSConfig{RecoveryIdentityPath: "must-not-pass", RecoveryBundlePath: "must-not-pass"}}, nil
}
const value = "do-not-print"
storeAppSecret = func(kubeconfig, tokenPath, path string, got []byte) error {
if kubeconfig != filepath.Join("checkouts", "talos", "generated", "kubeconfig") || tokenPath != "restricted-token" || path != "apps/orders-api/publish" || string(got) != value {
t.Fatal("set did not pass only kubeconfig, token path, target, and stdin value")
}
return nil
}
output := new(bytes.Buffer)
command := &cobra.Command{}
command.SetIn(strings.NewReader(value))
command.SetOut(output)
if err := runAppSecretSet(command, []string{"orders-api", "publish"}); err != nil || strings.Contains(output.String(), value) {
t.Fatal("set leaked its value or failed")
}
}
func TestAppSecretDeleteRequiresExplicitConfirmation(t *testing.T) {
originalYes := appSecretDeleteYes
t.Cleanup(func() { appSecretDeleteYes = originalYes })
appSecretDeleteYes = false
if err := runAppSecretDelete(&cobra.Command{}, []string{"orders-api", "publish"}); err == nil || !strings.Contains(err.Error(), "--yes") {
t.Fatalf("delete confirmation error = %v", err)
}
}
func TestAppSecretGenerateDoesNotReadOrPrintValue(t *testing.T) {
originalGenerate, originalFile := appSecretGenerate, appSecretFile
t.Cleanup(func() { appSecretGenerate, appSecretFile = originalGenerate, originalFile })
appSecretGenerate, appSecretFile = true, ""
value, err := readAppSecretValue(&cobra.Command{})
if err != nil || len(value) < 40 || strings.Contains(string(value), "\n") {
t.Fatal("generated app secret is not a bounded opaque value")
}
}
func TestAppSecretGrantOnlySavesNewDeclarativeDefinition(t *testing.T) {
originalLoad, originalSave := loadAppSecretConfig, saveAppSecretConfig
originalConfig, originalEnvironment, originalSecrets, originalShared := appSecretConfigPath, appSecretGrantEnvironment, appSecretGrantSecrets, appSecretGrantShared
t.Cleanup(func() {
loadAppSecretConfig, saveAppSecretConfig = originalLoad, originalSave
appSecretConfigPath, appSecretGrantEnvironment, appSecretGrantSecrets, appSecretGrantShared = originalConfig, originalEnvironment, originalSecrets, originalShared
})
appSecretConfigPath, appSecretGrantEnvironment, appSecretGrantSecrets, appSecretGrantShared = "private.yaml", "production", []string{"database"}, []string{"rabbitmq"}
loadAppSecretConfig = func(string) (config.Config, error) { return config.Config{}, nil }
saved := false
saveAppSecretConfig = func(path string, cfg config.Config) error {
saved = path == "private.yaml" && len(cfg.SecretGrants) == 1 && cfg.SecretGrants[0].Application == "orders-api" && cfg.SecretGrants[0].Consumer == "runtime" && cfg.SecretGrants[0].Environment == "production" && len(cfg.SecretGrants[0].Secrets) == 1 && cfg.SecretGrants[0].Secrets[0] == "database" && len(cfg.SecretGrants[0].Shared) == 1 && cfg.SecretGrants[0].Shared[0] == "rabbitmq"
return nil
}
command := &cobra.Command{}
command.SetOut(new(bytes.Buffer))
if err := runAppSecretGrant(command, []string{"orders-api", "runtime"}); err != nil || !saved {
t.Fatalf("runAppSecretGrant() = %v, saved = %t", err, saved)
}
loadAppSecretConfig = func(string) (config.Config, error) {
return config.Config{SecretGrants: []config.SecretGrant{{Application: "orders-api", Consumer: "runtime", Environment: "production", Secrets: []string{"database"}, Shared: []string{"other"}}}}, nil
}
saveAppSecretConfig = func(string, config.Config) error { return errors.New("must not save ambiguous grant") }
if err := runAppSecretGrant(command, []string{"orders-api", "runtime"}); err == nil || !strings.Contains(err.Error(), "different definition") {
t.Fatalf("ambiguous grant error = %v", err)
}
}

View file

@ -1,8 +1,10 @@
package cmd
import (
"errors"
"fmt"
"path/filepath"
"os"
"strings"
"github.com/Pingu-Studio/MaidnCLI/internal/bootstrap"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
@ -14,6 +16,7 @@ import (
var bootstrapConfigPath string
var bootstrapOutputPath string
var bootstrapWorkspaceDir string
var bootstrapMode string
var bootstrapYes bool
var bootstrapPromptDemocraticCSI bool
@ -21,14 +24,23 @@ var bootstrapPromptOperationalSecrets bool
var bootstrapInitializeOpenBaoRecovery bool
var bootstrapInitializeOpenBao bool
var bootstrapCreateForgejoRegistryToken bool
var bootstrapForgejoPasswordFile string
var bootstrapCreateForgejoDeliveryStatusToken bool
var bootstrapProvisionAppSecretIdentities bool
var bootstrapE2EApp string
var bootstrapRegisterWebhook bool
var bootstrapRotateWebhookAuthorization bool
var bootstrapPublishAppFrom string
var bootstrapMergeBootstrapPR bool
var bootstrapManageNetworkBridges bool
var bootstrapEnableDelivery bool
var bootstrapDestroyDemocraticCSIStorage bool
var bootstrapRefreshTemplateRevisions bool
var upsertOperationalSecret = bootstrap.UpsertOperationalSecret
var readOperationalSecrets = bootstrap.ReadOperationalSecrets
var initializeOpenBao = bootstrap.InitializeOpenBao
var createForgejoDeliveryStatusToken = forgejo.CreateDeliveryStatusToken
var promptForgejoDeliveryStatusToken = ui.PromptForgejoDeliveryStatusToken
var bootstrapCmd = &cobra.Command{
Use: "bootstrap",
Short: "Bootstrap Talos and Flux from config or an interactive wizard.",
@ -39,6 +51,7 @@ func init() {
rootCmd.AddCommand(bootstrapCmd)
bootstrapCmd.Flags().StringVar(&bootstrapConfigPath, "config", "", "Path to bootstrap config YAML")
bootstrapCmd.Flags().StringVar(&bootstrapOutputPath, "out", "maidn-bootstrap.yaml", "Path to save generated config")
bootstrapCmd.Flags().StringVar(&bootstrapWorkspaceDir, "workspace-dir", "", "Override workspace directory for this bootstrap run")
bootstrapCmd.Flags().StringVar(&bootstrapMode, "mode", string(bootstrap.Reconcile), "Lifecycle mode: reconcile or rebuild")
bootstrapCmd.Flags().BoolVar(&bootstrapYes, "yes", false, "Confirm destructive rebuild")
bootstrapCmd.Flags().BoolVar(&bootstrapPromptDemocraticCSI, "prompt-democratic-csi", false, "Prompt for and save Democratic CSI settings in --config")
@ -46,19 +59,31 @@ func init() {
bootstrapCmd.Flags().BoolVar(&bootstrapInitializeOpenBaoRecovery, "initialize-openbao-recovery", false, "Create and save a separate OpenBao recovery age identity for --config")
bootstrapCmd.Flags().BoolVar(&bootstrapInitializeOpenBao, "initialize-openbao", false, "Initialize OpenBao and seed encrypted operational secrets for --config")
bootstrapCmd.Flags().BoolVar(&bootstrapCreateForgejoRegistryToken, "create-forgejo-registry-token", false, "Create a least-privilege Forgejo package registry token and seed it through OpenBao")
bootstrapCmd.Flags().StringVar(&bootstrapForgejoPasswordFile, "forgejo-password-file", "", "Read the Forgejo password from this local file when creating a registry token")
bootstrapCmd.Flags().BoolVar(&bootstrapCreateForgejoDeliveryStatusToken, "create-forgejo-delivery-status-token", false, "Create or reuse the Forgejo delivery-status token and seed it through OpenBao")
bootstrapCmd.Flags().BoolVar(&bootstrapProvisionAppSecretIdentities, "provision-app-secret-identities", false, "Create restricted app-secret and E2E OpenBao identities")
bootstrapCmd.Flags().StringVar(&bootstrapE2EApp, "e2e-app", "", "Fixture app granted an E2E probe identity")
bootstrapCmd.Flags().BoolVar(&bootstrapRegisterWebhook, "register-webhook", false, "Seed OpenBao secrets and register the Forgejo webhook")
bootstrapCmd.Flags().BoolVar(&bootstrapRotateWebhookAuthorization, "rotate-webhook-authorization", false, "Replace the Forgejo webhook authorization and reconcile it through OpenBao")
bootstrapCmd.Flags().StringVar(&bootstrapPublishAppFrom, "publish-app-from", "", "Push this app checkout's current branch and create a Forgejo delivery PR")
bootstrapCmd.Flags().BoolVar(&bootstrapMergeBootstrapPR, "merge-bootstrap-pr", false, "Merge the generated Flux repository migration PR before bootstrapping")
bootstrapCmd.Flags().BoolVar(&bootstrapManageNetworkBridges, "manage-network-bridges", false, "Persist Terraform management for existing Talos network bridges")
bootstrapCmd.Flags().BoolVar(&bootstrapEnableDelivery, "enable-delivery", false, "Resolve delivery defaults and reconcile the configured app delivery source")
bootstrapCmd.Flags().BoolVar(&bootstrapDestroyDemocraticCSIStorage, "destroy-democratic-csi-storage", false, "Delete only TrueNAS datasets under this cluster's configured Democratic CSI parent during rebuild")
bootstrapCmd.Flags().BoolVar(&bootstrapRefreshTemplateRevisions, "refresh-template-revisions", false, "Refresh existing template revision locks from configured branch heads during reconcile")
}
func runBootstrap(cmd *cobra.Command, args []string) error {
if err := validateTemplateRevisionRefresh(bootstrapRefreshTemplateRevisions, bootstrap.Mode(bootstrapMode), cmd != nil && cmd.Flags().Changed("mode")); err != nil {
return err
}
if bootstrapRefreshTemplateRevisions && (bootstrapProvisionAppSecretIdentities || bootstrapCreateForgejoRegistryToken || bootstrapCreateForgejoDeliveryStatusToken || bootstrapRotateWebhookAuthorization || bootstrapInitializeOpenBao || bootstrapMergeBootstrapPR) {
return errors.New("--refresh-template-revisions is available only for the bootstrap lifecycle")
}
var cfg config.Config
var err error
if bootstrapCreateForgejoRegistryToken {
if bootstrapConfigPath == "" {
return fmt.Errorf("--create-forgejo-registry-token requires --config")
if bootstrapProvisionAppSecretIdentities {
if bootstrapConfigPath == "" || bootstrapE2EApp == "" {
return errors.New("--provision-app-secret-identities requires --config and --e2e-app")
}
cfg, err = config.Load(bootstrapConfigPath)
if err != nil {
@ -67,8 +92,42 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil {
return err
}
return bootstrap.ProvisionAppSecretIdentities(cfg, bootstrapE2EApp)
}
if bootstrapCreateForgejoRegistryToken {
if bootstrapConfigPath == "" {
return fmt.Errorf("--create-forgejo-registry-token requires --config")
}
cfg, err = config.Load(bootstrapConfigPath)
if err != nil {
return err
}
cfg, err = config.ResolveDelivery(cfg)
if err != nil {
return err
}
if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil {
return err
}
return createForgejoRegistryToken(cfg)
}
if bootstrapCreateForgejoDeliveryStatusToken {
if bootstrapConfigPath == "" {
return fmt.Errorf("--create-forgejo-delivery-status-token requires --config")
}
cfg, err = config.Load(bootstrapConfigPath)
if err != nil {
return err
}
cfg, err = config.ResolveDelivery(cfg)
if err != nil {
return err
}
if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil {
return err
}
return createOrReuseForgejoDeliveryStatusToken(cfg)
}
if bootstrapRotateWebhookAuthorization {
if bootstrapConfigPath == "" {
return fmt.Errorf("--rotate-webhook-authorization requires --config")
@ -77,6 +136,10 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
cfg, err = config.ResolveDelivery(cfg)
if err != nil {
return err
}
authorization, err := bootstrap.NewWebhookAuthorization()
if err != nil {
return fmt.Errorf("generate Forgejo webhook authorization: %w", err)
@ -84,7 +147,7 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
if err := bootstrap.UpsertOperationalSecret(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, "cicd/forgejo-webhook", "authorization", authorization); err != nil {
return fmt.Errorf("save Forgejo webhook authorization: %w", err)
}
return bootstrap.Runner{Config: cfg, RegisterWebhook: true}.Run()
return bootstrap.Runner{Config: cfg, RegisterWebhook: true, RefreshWebhookSecret: true}.Run()
}
if bootstrapInitializeOpenBao {
if bootstrapConfigPath == "" {
@ -111,61 +174,18 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
return err
}
manager := forgejo.NewRepoManager(cfg.Git.BaseURL, cfg.Git.Token, cfg.Git.Owner, cfg.Git.Username, "", "", cfg.Flux.Branch, "")
if err := manager.MergePullRequest(cfg.Flux.RepoName, "maidn/bootstrap-"+cfg.ClusterID); err != nil {
return err
}
}
if bootstrapPublishAppFrom != "" {
if bootstrapConfigPath == "" {
return fmt.Errorf("--publish-app-from requires --config")
}
cfg, err = config.Load(bootstrapConfigPath)
for _, repository := range []string{cfg.Flux.ManifestsRepo, cfg.Flux.RepoName} {
open, err := manager.HasOpenPullRequest(repository, "maidn/bootstrap-"+cfg.ClusterID)
if err != nil {
return err
}
if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil {
return err
}
branch, err := forgejo.CurrentBranch(bootstrapPublishAppFrom)
if err != nil {
return err
}
owner, repo, err := forgejo.RepositoryFromURL(cfg.Delivery.AppRepoURL)
if err != nil {
return err
}
manager := forgejo.NewRepoManager(cfg.Git.BaseURL, cfg.Git.Token, owner, cfg.Git.Username, "", "", cfg.Delivery.AppRepoRef, "")
created, err := manager.EnsureRepository(repo, "Application source for Maidn CI/CD delivery")
if err != nil {
return err
}
hasDeliveryBranch, err := manager.HasRemoteBranch(cfg.Delivery.AppRepoURL, cfg.Delivery.AppRepoRef)
if err != nil {
return err
}
if created || !hasDeliveryBranch {
if err := manager.PushRef(bootstrapPublishAppFrom, cfg.Delivery.AppRepoURL, "HEAD", cfg.Delivery.AppRepoRef); err != nil {
if open {
if err := manager.MergePullRequest(repository, "maidn/bootstrap-"+cfg.ClusterID); err != nil {
return err
}
}
if err := manager.PushBranch(bootstrapPublishAppFrom, cfg.Delivery.AppRepoURL, branch); err != nil {
return err
}
return manager.CreatePullRequest(repo, "feat: migrate delivery to Tekton", branch, cfg.Delivery.AppRepoRef)
}
if bootstrapInitializeOpenBao {
if bootstrapConfigPath == "" {
return fmt.Errorf("--initialize-openbao requires --config")
}
cfg, err = config.Load(bootstrapConfigPath)
if err != nil {
return err
}
generatedDir := filepath.Join(cfg.Git.CloneParent, cfg.Talos.RepoDirName, cfg.Talos.GeneratedDir)
_, err = openbao.Initialize(filepath.Join(generatedDir, "kubeconfig"), cfg.SOPS.RecoveryRecipient, cfg.SOPS.RecoveryIdentityPath, cfg.SOPS.RecoveryBundlePath, cfg.SOPS.AgeKeyPath, cfg.SOPS.OperationalSecretsPath)
return err
}
if bootstrapConfigPath != "" {
if bootstrapPromptDemocraticCSI || bootstrapPromptOperationalSecrets || bootstrapInitializeOpenBaoRecovery || bootstrapManageNetworkBridges {
cfg, err = config.LoadRaw(bootstrapConfigPath)
@ -178,6 +198,9 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
}
cfg, err = config.Resolve(cfg)
}
if err == nil && bootstrapPromptOperationalSecrets {
cfg, err = config.ResolveDelivery(cfg)
}
if err == nil && bootstrapPromptOperationalSecrets {
var secrets map[string]map[string]string
secrets, err = ui.PromptOperationalSecrets(cfg)
@ -218,16 +241,30 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
if bootstrapWorkspaceDir != "" {
cfg.WorkspaceDir = bootstrapWorkspaceDir
}
if bootstrapRegisterWebhook {
cfg, err = config.ResolveDelivery(cfg)
if err != nil {
return err
}
if err := seedForgejoOperationalCredentials(cfg); err != nil {
return err
}
}
runner := bootstrap.Runner{Config: cfg, Mode: bootstrap.Mode(bootstrapMode), ConfirmRebuild: bootstrapYes, RegisterWebhook: bootstrapRegisterWebhook}
runner := bootstrap.Runner{Config: cfg, Mode: bootstrap.Mode(bootstrapMode), ConfirmRebuild: bootstrapYes, RegisterWebhook: bootstrapRegisterWebhook, EnableDelivery: bootstrapEnableDelivery, DestroyDemocraticCSIStorage: bootstrapDestroyDemocraticCSIStorage, RefreshTemplateRevisions: bootstrapRefreshTemplateRevisions}
return runner.Run()
}
func validateTemplateRevisionRefresh(refresh bool, mode bootstrap.Mode, modeExplicit bool) error {
if refresh && (!modeExplicit || mode != bootstrap.Reconcile) {
return errors.New("--refresh-template-revisions requires --mode=reconcile")
}
return nil
}
func seedForgejoOperationalCredentials(cfg config.Config) error {
if err := upsertOperationalSecret(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, "cicd/forgejo", "username", cfg.Git.Username); err != nil {
return fmt.Errorf("save Forgejo username for webhook registration: %w", err)
@ -242,7 +279,7 @@ func createForgejoRegistryToken(cfg config.Config) error {
if _, err := bootstrap.ReadOperationalSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath); err != nil {
return err
}
password, otp, name, err := ui.PromptForgejoRegistryToken()
password, otp, name, err := forgejoRegistryTokenCredentials()
if err != nil {
return err
}
@ -262,3 +299,59 @@ func createForgejoRegistryToken(cfg config.Config) error {
}
return nil
}
func forgejoRegistryTokenCredentials() (password, otp, name string, err error) {
if bootstrapForgejoPasswordFile == "" {
return ui.PromptForgejoRegistryToken()
}
data, err := os.ReadFile(bootstrapForgejoPasswordFile)
if err != nil {
return "", "", "", fmt.Errorf("read Forgejo password file: %w", err)
}
password = strings.TrimSpace(string(data))
if password == "" {
return "", "", "", errors.New("Forgejo password file is empty")
}
return password, "", "maidn-registry", nil
}
func createOrReuseForgejoDeliveryStatusToken(cfg config.Config) error {
secrets, err := readOperationalSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath)
if err != nil {
return err
}
values, found := secrets["cicd/forgejo-delivery-status"]
var token string
if found {
if len(values) != 1 || values["token"] == "" {
return errors.New("operational SOPS secrets has ambiguous cicd/forgejo-delivery-status state; refusing to create another token")
}
token = values["token"]
} else {
password, otp, err := promptForgejoDeliveryStatusToken()
if err != nil {
return err
}
token, err = createForgejoDeliveryStatusToken(cfg.Git.BaseURL, cfg.Git.Username, password, otp)
if err != nil {
return fmt.Errorf("create Forgejo delivery-status token: %w", redactCredentialError(err, password, otp))
}
if err := upsertOperationalSecret(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, "cicd/forgejo-delivery-status", "token", token); err != nil {
return errors.New("Forgejo delivery-status token was created but could not be saved; revoke the new token in Forgejo and retry")
}
}
if err := initializeOpenBao(cfg); err != nil {
return fmt.Errorf("Forgejo delivery-status token is in encrypted operational secrets but OpenBao seeding failed; rerun bootstrap with --config and --create-forgejo-delivery-status-token: %w", redactCredentialError(err, token))
}
return nil
}
func redactCredentialError(err error, sensitive ...string) error {
message := err.Error()
for _, value := range sensitive {
if value != "" {
message = strings.ReplaceAll(message, value, "[REDACTED]")
}
}
return errors.New(message)
}

View file

@ -5,9 +5,31 @@ import (
"strings"
"testing"
"github.com/Pingu-Studio/MaidnCLI/internal/bootstrap"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
)
func TestValidateTemplateRevisionRefreshRequiresExplicitReconcileMode(t *testing.T) {
tests := []struct {
name string
mode bootstrap.Mode
modeExplicit bool
wantErr bool
}{
{name: "reconcile", mode: bootstrap.Reconcile, modeExplicit: true},
{name: "rebuild", mode: bootstrap.Rebuild, modeExplicit: true, wantErr: true},
{name: "missing mode", mode: bootstrap.Reconcile, wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := validateTemplateRevisionRefresh(true, test.mode, test.modeExplicit)
if (err != nil) != test.wantErr {
t.Fatalf("validateTemplateRevisionRefresh() error = %v, wantErr %t", err, test.wantErr)
}
})
}
}
func TestCreateForgejoRegistryTokenRequiresConfig(t *testing.T) {
originalConfigPath, originalCreate := bootstrapConfigPath, bootstrapCreateForgejoRegistryToken
defer func() {
@ -62,3 +84,72 @@ func TestSeedForgejoOperationalCredentialsHidesTokenOnUpsertFailure(t *testing.T
t.Fatal("credential upsert failure was not clear and token-safe")
}
}
func TestCreateOrReuseForgejoDeliveryStatusTokenCreatesSeedsAndRedacts(t *testing.T) {
originalRead, originalPrompt, originalCreate, originalUpsert, originalInitialize := readOperationalSecrets, promptForgejoDeliveryStatusToken, createForgejoDeliveryStatusToken, upsertOperationalSecret, initializeOpenBao
t.Cleanup(func() {
readOperationalSecrets, promptForgejoDeliveryStatusToken, createForgejoDeliveryStatusToken, upsertOperationalSecret, initializeOpenBao = originalRead, originalPrompt, originalCreate, originalUpsert, originalInitialize
})
cfg := config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test", Username: "delivery-bot"}, SOPS: config.SOPSConfig{OperationalSecretsPath: "secrets.sops.yaml", AgeKeyPath: "age-key.txt"}}
readOperationalSecrets = func(_, _ string) (map[string]map[string]string, error) { return map[string]map[string]string{}, nil }
promptForgejoDeliveryStatusToken = func() (string, string, error) { return "password", "otp", nil }
createForgejoDeliveryStatusToken = func(baseURL, username, password, otp string) (string, error) {
if baseURL != cfg.Git.BaseURL || username != cfg.Git.Username || password != "password" || otp != "otp" {
t.Fatal("delivery token creation crossed an unexpected credential boundary")
}
return "delivery-token", nil
}
seeded := false
upsertOperationalSecret = func(path, agePath, secretPath, key, value string) error {
seeded = path == cfg.SOPS.OperationalSecretsPath && agePath == cfg.SOPS.AgeKeyPath && secretPath == "cicd/forgejo-delivery-status" && key == "token" && value == "delivery-token"
return nil
}
initializeOpenBao = func(config.Config) error { return errors.New("OpenBao rejected delivery-token") }
err := createOrReuseForgejoDeliveryStatusToken(cfg)
if !seeded || err == nil || strings.Contains(err.Error(), "delivery-token") {
t.Fatalf("delivery token create/seed failure leaked or skipped a credential: seeded=%t err=%v", seeded, err)
}
}
func TestCreateOrReuseForgejoDeliveryStatusTokenReusesAndReseeds(t *testing.T) {
originalRead, originalPrompt, originalCreate, originalUpsert, originalInitialize := readOperationalSecrets, promptForgejoDeliveryStatusToken, createForgejoDeliveryStatusToken, upsertOperationalSecret, initializeOpenBao
t.Cleanup(func() {
readOperationalSecrets, promptForgejoDeliveryStatusToken, createForgejoDeliveryStatusToken, upsertOperationalSecret, initializeOpenBao = originalRead, originalPrompt, originalCreate, originalUpsert, originalInitialize
})
cfg := config.Config{SOPS: config.SOPSConfig{OperationalSecretsPath: "secrets.sops.yaml", AgeKeyPath: "age-key.txt"}}
readOperationalSecrets = func(_, _ string) (map[string]map[string]string, error) {
return map[string]map[string]string{"cicd/forgejo-delivery-status": {"token": "existing-token"}}, nil
}
promptForgejoDeliveryStatusToken = func() (string, string, error) { t.Fatal("existing delivery token must not prompt"); return "", "", nil }
createForgejoDeliveryStatusToken = func(string, string, string, string) (string, error) {
t.Fatal("existing delivery token must not be recreated")
return "", nil
}
upsertOperationalSecret = func(string, string, string, string, string) error {
t.Fatal("existing delivery token must not be rewritten")
return nil
}
seeded := false
initializeOpenBao = func(received config.Config) error {
seeded = received.SOPS.OperationalSecretsPath == cfg.SOPS.OperationalSecretsPath
return nil
}
if err := createOrReuseForgejoDeliveryStatusToken(cfg); err != nil || !seeded {
t.Fatalf("delivery token reuse did not reseed OpenBao: seeded=%t err=%v", seeded, err)
}
}
func TestCreateOrReuseForgejoDeliveryStatusTokenRejectsAmbiguousState(t *testing.T) {
originalRead, originalPrompt := readOperationalSecrets, promptForgejoDeliveryStatusToken
t.Cleanup(func() { readOperationalSecrets, promptForgejoDeliveryStatusToken = originalRead, originalPrompt })
readOperationalSecrets = func(_, _ string) (map[string]map[string]string, error) {
return map[string]map[string]string{"cicd/forgejo-delivery-status": {"token": "", "legacy": "value"}}, nil
}
promptForgejoDeliveryStatusToken = func() (string, string, error) {
t.Fatal("ambiguous state must not create a replacement token")
return "", "", nil
}
if err := createOrReuseForgejoDeliveryStatusToken(config.Config{}); err == nil || !strings.Contains(err.Error(), "ambiguous") {
t.Fatalf("ambiguous delivery token state = %v", err)
}
}

197
cmd/cloudflare_tunnel.go Normal file
View file

@ -0,0 +1,197 @@
package cmd
import (
"context"
"errors"
"fmt"
"strings"
"github.com/Pingu-Studio/MaidnCLI/internal/bootstrap"
"github.com/Pingu-Studio/MaidnCLI/internal/cloudflare"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/spf13/cobra"
)
var cloudflareTunnelConfigPath string
var cloudflareTunnelHostname string
var cloudflareTunnelService string
var cloudflareTunnelZoneID string
var cloudflareTunnelCredentialsFile string
var loadCloudflareTunnelConfig = config.Load
var readCloudflareTunnelSecrets = bootstrap.ReadOperationalSecrets
var writeCloudflareTunnelSecrets = bootstrap.WriteOperationalSecrets
var newCloudflareRouteClient = func(token string) cloudflare.API { return cloudflare.NewClient(token) }
var initializeCloudflareTunnelOpenBao = bootstrap.InitializeOpenBao
var readCloudflareTunnelCredentials = cloudflare.ReadCredentialsFile
var cloudflareTunnelCmd = &cobra.Command{
Use: "cloudflare-tunnel",
Short: "Manage CLI-created Cloudflare Tunnel routes.",
}
var cloudflareTunnelRouteCmd = &cobra.Command{Use: "route"}
var cloudflareTunnelRouteAddCmd = &cobra.Command{
Use: "add",
Short: "Add a proxied public route to the managed tunnel.",
RunE: func(cmd *cobra.Command, _ []string) error {
return runCloudflareTunnelRoute(cmd, true)
},
}
var cloudflareTunnelRouteRemoveCmd = &cobra.Command{
Use: "remove",
Short: "Remove a proxied public route from the managed tunnel.",
RunE: func(cmd *cobra.Command, _ []string) error {
return runCloudflareTunnelRoute(cmd, false)
},
}
var cloudflareTunnelImportCmd = &cobra.Command{
Use: "import",
Short: "Import local Cloudflare Tunnel credentials into encrypted operational state.",
RunE: func(_ *cobra.Command, _ []string) error {
return runCloudflareTunnelImport()
},
}
func init() {
rootCmd.AddCommand(cloudflareTunnelCmd)
cloudflareTunnelCmd.AddCommand(cloudflareTunnelRouteCmd, cloudflareTunnelImportCmd)
cloudflareTunnelRouteCmd.AddCommand(cloudflareTunnelRouteAddCmd, cloudflareTunnelRouteRemoveCmd)
cloudflareTunnelCmd.PersistentFlags().StringVar(&cloudflareTunnelConfigPath, "config", "", "Path to bootstrap config YAML")
for _, command := range []*cobra.Command{cloudflareTunnelRouteAddCmd, cloudflareTunnelRouteRemoveCmd} {
command.Flags().StringVar(&cloudflareTunnelHostname, "hostname", "", "Public DNS hostname")
command.Flags().StringVar(&cloudflareTunnelService, "service", "", "Upstream http or https service URL")
command.Flags().StringVar(&cloudflareTunnelZoneID, "zone-id", "", "Cloudflare zone ID")
_ = command.MarkFlagRequired("hostname")
_ = command.MarkFlagRequired("service")
_ = command.MarkFlagRequired("zone-id")
}
cloudflareTunnelImportCmd.Flags().StringVar(&cloudflareTunnelCredentialsFile, "credentials-file", "", "Path to local Cloudflare Tunnel credentials JSON")
_ = cloudflareTunnelImportCmd.MarkFlagRequired("credentials-file")
}
func runCloudflareTunnelImport() error {
if cloudflareTunnelConfigPath == "" {
return errors.New("--config is required")
}
if cloudflareTunnelCredentialsFile == "" {
return errors.New("--credentials-file is required")
}
cfg, err := loadCloudflareTunnelConfig(cloudflareTunnelConfigPath)
if err != nil {
return err
}
secrets, err := readCloudflareTunnelSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath)
if err != nil {
return fmt.Errorf("read encrypted Cloudflare operational state: %w", err)
}
state := secrets["platform/cloudflare-tunnel"]
_, present, stateErr := cloudflare.ParseStoredTunnel(state)
if present {
return errors.New("Cloudflare tunnel state is already managed; refusing to overwrite it")
}
if stateErr != nil && !cloudflare.IsLegacyRunTokenState(state) {
return stateErr
}
credentials, err := readCloudflareTunnelCredentials(cloudflareTunnelCredentialsFile)
if err != nil {
return err
}
values, err := (cloudflare.StoredTunnel{Credentials: credentials, Config: cloudflare.NewConfig(credentials.TunnelID)}).Values()
if err != nil {
return err
}
secrets["platform/cloudflare-tunnel"] = values
if err := writeCloudflareTunnelSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, secrets); err != nil {
return errors.New("save encrypted Cloudflare tunnel credentials")
}
if err := initializeCloudflareTunnelOpenBao(cfg); err != nil {
return errors.New("Cloudflare tunnel credentials were saved but OpenBao could not be seeded; rerun bootstrap --config <config> --initialize-openbao")
}
return nil
}
func runCloudflareTunnelRoute(cmd *cobra.Command, add bool) error {
if cloudflareTunnelConfigPath == "" {
return errors.New("--config is required")
}
if strings.TrimSpace(cloudflareTunnelZoneID) == "" {
return errors.New("--zone-id is required")
}
route, err := cloudflare.NewRoute(cloudflareTunnelHostname, cloudflareTunnelService)
if err != nil {
return err
}
cfg, err := loadCloudflareTunnelConfig(cloudflareTunnelConfigPath)
if err != nil {
return err
}
secrets, err := readCloudflareTunnelSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath)
if err != nil {
return fmt.Errorf("read encrypted Cloudflare operational state: %w", err)
}
token := strings.TrimSpace(secrets["platform/cloudflare"]["api-token"])
if token == "" {
return errors.New("encrypted operational secrets requires platform/cloudflare.api-token")
}
stored, present, err := cloudflare.ParseStoredTunnel(secrets["platform/cloudflare-tunnel"])
if err != nil {
return err
}
if !present {
return errors.New("Cloudflare tunnel credentials and config are not generated; run cloudflare-tunnel import with a local credentials file")
}
ctx := context.Background()
if cmd != nil && cmd.Context() != nil {
ctx = cmd.Context()
}
client := newCloudflareRouteClient(token)
if add {
changed, err := stored.Config.AddRoute(route)
if err != nil {
return err
}
if err := client.EnsureCNAME(ctx, strings.TrimSpace(cloudflareTunnelZoneID), route.Hostname, stored.Credentials.TunnelID); err != nil {
return fmt.Errorf("ensure Cloudflare route DNS record: %w", err)
}
if !changed {
return nil
}
values, err := stored.Values()
if err != nil {
return err
}
secrets["platform/cloudflare-tunnel"] = values
if err := writeCloudflareTunnelSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, secrets); err != nil {
return errors.New("save encrypted Cloudflare tunnel route")
}
if err := initializeCloudflareTunnelOpenBao(cfg); err != nil {
return errors.New("Cloudflare tunnel route was saved but OpenBao could not be seeded; rerun bootstrap --config <config> --initialize-openbao")
}
return nil
}
changed, err := stored.Config.RemoveRoute(route)
if err != nil {
return err
}
if changed {
values, err := stored.Values()
if err != nil {
return err
}
secrets["platform/cloudflare-tunnel"] = values
if err := writeCloudflareTunnelSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, secrets); err != nil {
return errors.New("save encrypted Cloudflare tunnel route")
}
if err := initializeCloudflareTunnelOpenBao(cfg); err != nil {
return errors.New("Cloudflare tunnel route was saved but OpenBao could not be seeded; rerun bootstrap --config <config> --initialize-openbao")
}
}
if err := client.DeleteCNAME(ctx, strings.TrimSpace(cloudflareTunnelZoneID), route.Hostname, stored.Credentials.TunnelID); err != nil {
return fmt.Errorf("delete Cloudflare route DNS record: %w", err)
}
return nil
}

View file

@ -0,0 +1,290 @@
package cmd
import (
"context"
"errors"
"strings"
"testing"
"github.com/Pingu-Studio/MaidnCLI/internal/cloudflare"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
)
type fakeCloudflareRouteAPI struct {
ensured bool
deleted bool
ensureErr error
deleteCalls int
}
func (f *fakeCloudflareRouteAPI) EnsureCNAME(_ context.Context, zoneID, hostname, tunnelID string) error {
if zoneID != "zone-id" || hostname != "app.example.test" || tunnelID != "tunnel-id" {
return &routeTestError{}
}
f.ensured = true
return f.ensureErr
}
func (f *fakeCloudflareRouteAPI) DeleteCNAME(_ context.Context, zoneID, hostname, tunnelID string) error {
if zoneID != "zone-id" || hostname != "app.example.test" || tunnelID != "tunnel-id" {
return &routeTestError{}
}
f.deleted = true
f.deleteCalls++
return nil
}
type routeTestError struct{}
func (*routeTestError) Error() string { return "unexpected route request" }
func TestCloudflareTunnelRouteCommandsUpdateEncryptedStateAndDNS(t *testing.T) {
originalLoad := loadCloudflareTunnelConfig
originalRead := readCloudflareTunnelSecrets
originalWrite := writeCloudflareTunnelSecrets
originalClient := newCloudflareRouteClient
originalInitialize := initializeCloudflareTunnelOpenBao
originalPath, originalHostname, originalService, originalZoneID := cloudflareTunnelConfigPath, cloudflareTunnelHostname, cloudflareTunnelService, cloudflareTunnelZoneID
t.Cleanup(func() {
loadCloudflareTunnelConfig = originalLoad
readCloudflareTunnelSecrets = originalRead
writeCloudflareTunnelSecrets = originalWrite
newCloudflareRouteClient = originalClient
initializeCloudflareTunnelOpenBao = originalInitialize
cloudflareTunnelConfigPath, cloudflareTunnelHostname, cloudflareTunnelService, cloudflareTunnelZoneID = originalPath, originalHostname, originalService, originalZoneID
})
values, err := (cloudflare.StoredTunnel{
Credentials: cloudflare.Credentials{AccountTag: "account-id", TunnelSecret: "test-tunnel-secret", TunnelID: "tunnel-id"},
Config: cloudflare.NewConfig("tunnel-id"),
}).Values()
if err != nil {
t.Fatal(err)
}
state := map[string]map[string]string{
"platform/cloudflare": {"api-token": "test-api-token"},
"platform/cloudflare-tunnel": values,
}
client := &fakeCloudflareRouteAPI{}
loadCloudflareTunnelConfig = func(string) (config.Config, error) {
return config.Config{SOPS: config.SOPSConfig{OperationalSecretsPath: "secrets", AgeKeyPath: "age"}}, nil
}
readCloudflareTunnelSecrets = func(string, string) (map[string]map[string]string, error) { return state, nil }
writes := 0
writeCloudflareTunnelSecrets = func(_ string, _ string, secrets map[string]map[string]string) error {
if writes == 0 && !client.ensured {
t.Fatal("route add saved config before ensuring the CNAME")
}
if writes == 1 && client.deleted {
t.Fatal("route remove deleted the CNAME before saving config")
}
state = secrets
writes++
return nil
}
newCloudflareRouteClient = func(token string) cloudflare.API {
if token != "test-api-token" {
t.Fatal("Cloudflare route command leaked the token beyond the API boundary")
}
return client
}
seeds := 0
var seedErr error
initializeCloudflareTunnelOpenBao = func(config.Config) error {
stored, present, err := cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"])
wantIngress := 2
if seeds%2 == 1 {
wantIngress = 1
}
if err != nil || !present || len(stored.Config.Ingress) != wantIngress {
t.Fatal("OpenBao was not seeded with the saved tunnel config")
}
seeds++
return seedErr
}
cloudflareTunnelConfigPath = "bootstrap.yaml"
cloudflareTunnelHostname = "app.example.test"
cloudflareTunnelService = "http://service.default.svc:8080"
cloudflareTunnelZoneID = "zone-id"
if err := runCloudflareTunnelRoute(nil, true); err != nil {
t.Fatal(err)
}
stored, present, err := cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"])
if err != nil || !present || len(stored.Config.Ingress) != 2 || !client.ensured || seeds != 1 {
t.Fatal("route add did not save config and ensure the CNAME")
}
if err := runCloudflareTunnelRoute(nil, false); err != nil {
t.Fatal(err)
}
stored, present, err = cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"])
if err != nil || !present || len(stored.Config.Ingress) != 1 || !client.deleted || seeds != 2 {
t.Fatal("route remove did not save config and delete the CNAME")
}
client.ensureErr = errors.New("unavailable")
if err := runCloudflareTunnelRoute(nil, true); err == nil {
t.Fatal("route add accepted a CNAME failure")
}
stored, present, err = cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"])
if err != nil || !present || len(stored.Config.Ingress) != 1 || writes != 2 || seeds != 2 {
t.Fatal("route add saved ingress config after a CNAME failure")
}
client.ensureErr = nil
seedErr = errors.New("unavailable")
if err := runCloudflareTunnelRoute(nil, true); err == nil || strings.Contains(err.Error(), "unavailable") {
t.Fatal("route add did not return a safe OpenBao seeding error")
}
stored, present, err = cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"])
if err != nil || !present || len(stored.Config.Ingress) != 2 || writes != 3 || seeds != 3 {
t.Fatal("route add did not retain DNS-safe state before OpenBao seeding failed")
}
deletes := client.deleteCalls
if err := runCloudflareTunnelRoute(nil, false); err == nil || strings.Contains(err.Error(), "unavailable") {
t.Fatal("route remove did not return a safe OpenBao seeding error")
}
stored, present, err = cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"])
if err != nil || !present || len(stored.Config.Ingress) != 1 || writes != 4 || seeds != 4 || client.deleteCalls != deletes {
t.Fatal("route remove deleted DNS before OpenBao seeding completed")
}
}
func TestCloudflareTunnelRouteErrorsDoNotRevealToken(t *testing.T) {
originalLoad := loadCloudflareTunnelConfig
originalRead := readCloudflareTunnelSecrets
originalPath, originalHostname, originalService, originalZoneID := cloudflareTunnelConfigPath, cloudflareTunnelHostname, cloudflareTunnelService, cloudflareTunnelZoneID
t.Cleanup(func() {
loadCloudflareTunnelConfig = originalLoad
readCloudflareTunnelSecrets = originalRead
cloudflareTunnelConfigPath, cloudflareTunnelHostname, cloudflareTunnelService, cloudflareTunnelZoneID = originalPath, originalHostname, originalService, originalZoneID
})
loadCloudflareTunnelConfig = func(string) (config.Config, error) { return config.Config{}, nil }
readCloudflareTunnelSecrets = func(string, string) (map[string]map[string]string, error) {
return map[string]map[string]string{"platform/cloudflare": {"api-token": "test-api-token"}}, nil
}
cloudflareTunnelConfigPath = "bootstrap.yaml"
cloudflareTunnelHostname = "app.example.test"
cloudflareTunnelService = "http://service.default.svc:8080"
cloudflareTunnelZoneID = "zone-id"
err := runCloudflareTunnelRoute(nil, true)
if err == nil || strings.Contains(err.Error(), "test-api-token") {
t.Fatal("route command error revealed the Cloudflare token")
}
}
func TestCloudflareTunnelImportAcceptsAbsentOrLegacyState(t *testing.T) {
originalLoad := loadCloudflareTunnelConfig
originalRead := readCloudflareTunnelSecrets
originalWrite := writeCloudflareTunnelSecrets
originalInitialize := initializeCloudflareTunnelOpenBao
originalCredentials := readCloudflareTunnelCredentials
originalPath, originalCredentialsFile := cloudflareTunnelConfigPath, cloudflareTunnelCredentialsFile
t.Cleanup(func() {
loadCloudflareTunnelConfig = originalLoad
readCloudflareTunnelSecrets = originalRead
writeCloudflareTunnelSecrets = originalWrite
initializeCloudflareTunnelOpenBao = originalInitialize
readCloudflareTunnelCredentials = originalCredentials
cloudflareTunnelConfigPath, cloudflareTunnelCredentialsFile = originalPath, originalCredentialsFile
})
loadCloudflareTunnelConfig = func(string) (config.Config, error) {
return config.Config{SOPS: config.SOPSConfig{OperationalSecretsPath: "secrets", AgeKeyPath: "age"}}, nil
}
cloudflareTunnelConfigPath = "bootstrap.yaml"
cloudflareTunnelCredentialsFile = "local-credentials.json"
for _, state := range []map[string]map[string]string{
{},
{"platform/cloudflare-tunnel": {"token": "legacy-run-token"}},
} {
readCloudflareTunnelSecrets = func(string, string) (map[string]map[string]string, error) { return state, nil }
readCloudflareTunnelCredentials = func(path string) (cloudflare.Credentials, error) {
if path != cloudflareTunnelCredentialsFile {
t.Fatal("credentials were not read from the explicit file path")
}
return cloudflare.Credentials{AccountTag: "account", TunnelSecret: "test-tunnel-secret", TunnelID: "tunnel"}, nil
}
written := false
writeCloudflareTunnelSecrets = func(_, _ string, values map[string]map[string]string) error {
state = values
written = true
return nil
}
initializeCloudflareTunnelOpenBao = func(config.Config) error {
stored, present, err := cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"])
if !written || err != nil || !present || len(stored.Config.Ingress) != 1 {
t.Fatal("OpenBao was not seeded after encrypted tunnel state was saved")
}
return nil
}
if err := runCloudflareTunnelImport(); err != nil {
t.Fatal(err)
}
if _, present, err := cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"]); err != nil || !present || len(state["platform/cloudflare-tunnel"]) != 2 {
t.Fatal("credentials file was not imported as managed tunnel state")
}
}
}
func TestCloudflareTunnelImportRefusesManagedStateAndReturnsSafeSeedFailure(t *testing.T) {
originalLoad := loadCloudflareTunnelConfig
originalRead := readCloudflareTunnelSecrets
originalWrite := writeCloudflareTunnelSecrets
originalInitialize := initializeCloudflareTunnelOpenBao
originalCredentials := readCloudflareTunnelCredentials
originalPath, originalCredentialsFile := cloudflareTunnelConfigPath, cloudflareTunnelCredentialsFile
t.Cleanup(func() {
loadCloudflareTunnelConfig = originalLoad
readCloudflareTunnelSecrets = originalRead
writeCloudflareTunnelSecrets = originalWrite
initializeCloudflareTunnelOpenBao = originalInitialize
readCloudflareTunnelCredentials = originalCredentials
cloudflareTunnelConfigPath, cloudflareTunnelCredentialsFile = originalPath, originalCredentialsFile
})
loadCloudflareTunnelConfig = func(string) (config.Config, error) {
return config.Config{SOPS: config.SOPSConfig{OperationalSecretsPath: "secrets", AgeKeyPath: "age"}}, nil
}
cloudflareTunnelConfigPath = "bootstrap.yaml"
cloudflareTunnelCredentialsFile = "local-credentials.json"
managed, err := (cloudflare.StoredTunnel{Credentials: cloudflare.Credentials{AccountTag: "account", TunnelSecret: "test-tunnel-secret", TunnelID: "tunnel"}, Config: cloudflare.NewConfig("tunnel")}).Values()
if err != nil {
t.Fatal(err)
}
state := map[string]map[string]string{"platform/cloudflare-tunnel": managed}
readCloudflareTunnelSecrets = func(string, string) (map[string]map[string]string, error) { return state, nil }
readCloudflareTunnelCredentials = func(string) (cloudflare.Credentials, error) {
t.Fatal("managed state import read the credential file")
return cloudflare.Credentials{}, nil
}
writeCloudflareTunnelSecrets = func(string, string, map[string]map[string]string) error {
t.Fatal("managed state import overwrote encrypted state")
return nil
}
initializeCloudflareTunnelOpenBao = func(config.Config) error {
t.Fatal("managed state import seeded OpenBao")
return nil
}
if err := runCloudflareTunnelImport(); err == nil || !strings.Contains(err.Error(), "refusing to overwrite") {
t.Fatal("managed state import was not refused")
}
state = map[string]map[string]string{}
readCloudflareTunnelCredentials = func(string) (cloudflare.Credentials, error) {
return cloudflare.Credentials{AccountTag: "account", TunnelSecret: "test-tunnel-secret", TunnelID: "tunnel"}, nil
}
written := false
writeCloudflareTunnelSecrets = func(_, _ string, values map[string]map[string]string) error {
written = true
state = values
return nil
}
initializeCloudflareTunnelOpenBao = func(config.Config) error {
if !written {
t.Fatal("OpenBao seeding ran before encrypted state was saved")
}
return errors.New("unavailable")
}
err = runCloudflareTunnelImport()
if err == nil || strings.Contains(err.Error(), "unavailable") {
t.Fatal("import did not return a safe OpenBao seeding error")
}
if _, present, parseErr := cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"]); parseErr != nil || !present {
t.Fatal("import did not retain encrypted state after OpenBao seeding failed")
}
}

77
cmd/e2e.go Normal file
View file

@ -0,0 +1,77 @@
package cmd
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/Pingu-Studio/MaidnCLI/internal/e2e"
"github.com/spf13/cobra"
)
var (
e2eKubeconfig, e2eContext, e2eExternalSecret, e2ePipelineRun string
e2ePreviewURL, e2ePreviewSentinel string
e2ePromotionPullsURL, e2ePromotionOwner, e2ePromotionHead string
e2ePromotionTokenEnv, e2ePromotionTokenFile string
e2eFluxKustomizations []string
e2eTimeout, e2eInterval time.Duration
e2eRunner = e2e.DefaultRunner
errE2EChecks = errors.New("e2e checks failed")
)
var e2eCmd = &cobra.Command{
Use: "e2e",
Short: "Run bounded, read-only delivery checks and emit JSON.",
RunE: runE2E,
}
func init() {
rootCmd.AddCommand(e2eCmd)
e2eCmd.Flags().StringVar(&e2eKubeconfig, "kubeconfig", "", "Path to a read-only kubeconfig")
e2eCmd.Flags().StringVar(&e2eContext, "context", "", "Kubernetes context name")
e2eCmd.Flags().StringSliceVar(&e2eFluxKustomizations, "flux-kustomization", nil, "Flux Kustomization namespace/name (repeatable)")
e2eCmd.Flags().StringVar(&e2eExternalSecret, "external-secret", "", "ExternalSecret namespace/name")
e2eCmd.Flags().StringVar(&e2ePipelineRun, "pipelinerun", "", "PipelineRun namespace/name")
e2eCmd.Flags().StringVar(&e2ePreviewURL, "preview-url", "", "Credential-free preview HTTP(S) URL")
e2eCmd.Flags().StringVar(&e2ePreviewSentinel, "preview-sentinel", "", "Non-secret text expected in the preview response")
e2eCmd.Flags().StringVar(&e2ePromotionPullsURL, "promotion-pulls-url", "", "Credential-free Forgejo pulls API URL without query parameters")
e2eCmd.Flags().StringVar(&e2ePromotionOwner, "promotion-owner", "", "Forgejo owner for the promotion branch")
e2eCmd.Flags().StringVar(&e2ePromotionHead, "promotion-head", "", "Expected promotion branch name")
e2eCmd.Flags().StringVar(&e2ePromotionTokenEnv, "promotion-token-env", "", "Environment variable containing the Forgejo token")
e2eCmd.Flags().StringVar(&e2ePromotionTokenFile, "promotion-token-file", "", "Path to a file containing the Forgejo token")
e2eCmd.Flags().DurationVar(&e2eTimeout, "timeout", 2*time.Minute, "Maximum wait for each check (up to 10m)")
e2eCmd.Flags().DurationVar(&e2eInterval, "interval", 2*time.Second, "Polling interval")
for _, name := range []string{"kubeconfig", "flux-kustomization", "external-secret", "pipelinerun", "preview-url", "preview-sentinel", "promotion-pulls-url", "promotion-owner", "promotion-head"} {
_ = e2eCmd.MarkFlagRequired(name)
}
}
func runE2E(cmd *cobra.Command, _ []string) error {
token, err := e2e.ReadToken(e2ePromotionTokenEnv, e2ePromotionTokenFile)
if err != nil {
return err
}
ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}
result, err := e2eRunner().Run(ctx, e2e.Options{
Kubeconfig: e2eKubeconfig, Context: e2eContext, FluxKustomizations: e2eFluxKustomizations,
ExternalSecret: e2eExternalSecret, PipelineRun: e2ePipelineRun,
PreviewURL: e2ePreviewURL, PreviewSentinel: e2ePreviewSentinel,
PromotionPullsURL: e2ePromotionPullsURL, PromotionOwner: e2ePromotionOwner, PromotionHead: e2ePromotionHead,
PromotionToken: token, Timeout: e2eTimeout, Interval: e2eInterval,
})
if err != nil {
return err
}
if err := json.NewEncoder(cmd.OutOrStdout()).Encode(result); err != nil {
return err
}
if !result.Passed {
return errE2EChecks
}
return nil
}

56
cmd/e2e_mutate.go Normal file
View file

@ -0,0 +1,56 @@
package cmd
import (
"context"
"github.com/Pingu-Studio/MaidnCLI/internal/e2emutate"
"github.com/spf13/cobra"
)
var (
e2eMutateForgejoURL, e2eMutateOwner, e2eMutateRepo, e2eMutateBranch, e2eMutateSHA string
e2eMutateTokenEnv, e2eMutateTokenFile string
e2eMutateOpenPR bool
e2eMutator = e2emutate.DefaultMutator
)
var e2eMutateCmd = &cobra.Command{
Use: "e2e-mutate",
Short: "Update a Forgejo E2E fixture branch and optionally open its PR.",
RunE: runE2EMutate,
}
func init() {
rootCmd.AddCommand(e2eMutateCmd)
e2eMutateCmd.Flags().StringVar(&e2eMutateForgejoURL, "forgejo-url", "", "Credential-free Forgejo base URL")
e2eMutateCmd.Flags().StringVar(&e2eMutateOwner, "owner", "", "Fixture Forgejo owner (must be Maidn)")
e2eMutateCmd.Flags().StringVar(&e2eMutateRepo, "repo", "", "Fixture Forgejo repository (must start maidn-e2e-)")
e2eMutateCmd.Flags().StringVar(&e2eMutateBranch, "branch", "", "Fixture Forgejo branch (must start maidn-e2e-)")
e2eMutateCmd.Flags().StringVar(&e2eMutateSHA, "sha", "", "Full Git object ID for the fixture branch")
e2eMutateCmd.Flags().StringVar(&e2eMutateTokenEnv, "token-env", "", "Environment variable containing the Forgejo token")
e2eMutateCmd.Flags().StringVar(&e2eMutateTokenFile, "token-file", "", "Path to a file containing the Forgejo token")
e2eMutateCmd.Flags().BoolVar(&e2eMutateOpenPR, "open-pr", false, "Open one pull request from the fixture branch to main")
for _, name := range []string{"forgejo-url", "owner", "repo", "branch", "sha"} {
_ = e2eMutateCmd.MarkFlagRequired(name)
}
}
func runE2EMutate(cmd *cobra.Command, _ []string) error {
token, err := e2emutate.ReadToken(e2eMutateTokenEnv, e2eMutateTokenFile)
if err != nil {
return err
}
ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}
return e2eMutator().Run(ctx, e2emutate.Options{
ForgejoURL: e2eMutateForgejoURL,
Owner: e2eMutateOwner,
Repo: e2eMutateRepo,
Branch: e2eMutateBranch,
SHA: e2eMutateSHA,
Token: token,
OpenPR: e2eMutateOpenPR,
})
}

46
cmd/e2e_mutate_test.go Normal file
View file

@ -0,0 +1,46 @@
package cmd
import (
"io"
"net/http"
"strings"
"testing"
"github.com/Pingu-Studio/MaidnCLI/internal/e2emutate"
"github.com/spf13/cobra"
)
type commandMutationHTTP struct {
calls []*http.Request
}
func (f *commandMutationHTTP) Do(request *http.Request) (*http.Response, error) {
f.calls = append(f.calls, request)
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil
}
func TestE2EMutateCommandWiringUsesOnlyTokenReferences(t *testing.T) {
originalMutator := e2eMutator
originalURL, originalOwner, originalRepo, originalBranch, originalSHA := e2eMutateForgejoURL, e2eMutateOwner, e2eMutateRepo, e2eMutateBranch, e2eMutateSHA
originalEnv, originalFile, originalOpenPR := e2eMutateTokenEnv, e2eMutateTokenFile, e2eMutateOpenPR
t.Cleanup(func() {
e2eMutator = originalMutator
e2eMutateForgejoURL, e2eMutateOwner, e2eMutateRepo, e2eMutateBranch, e2eMutateSHA = originalURL, originalOwner, originalRepo, originalBranch, originalSHA
e2eMutateTokenEnv, e2eMutateTokenFile, e2eMutateOpenPR = originalEnv, originalFile, originalOpenPR
})
command, _, err := rootCmd.Find([]string{"e2e-mutate"})
if err != nil || command != e2eMutateCmd || command.Flags().Lookup("token") != nil {
t.Fatalf("e2e-mutate command or token flags are not wired safely: %v", err)
}
fake := &commandMutationHTTP{}
e2eMutator = func() e2emutate.Mutator { return e2emutate.Mutator{HTTP: fake} }
e2eMutateForgejoURL, e2eMutateOwner = "https://git.example.test", "Maidn"
e2eMutateRepo, e2eMutateBranch = "maidn-e2e-repo", "maidn-e2e-branch"
e2eMutateSHA = "0123456789abcdef0123456789abcdef01234567"
e2eMutateTokenEnv, e2eMutateTokenFile, e2eMutateOpenPR = "E2E_MUTATE_TEST_TOKEN", "", false
t.Setenv(e2eMutateTokenEnv, "test-token")
if err := runE2EMutate(&cobra.Command{}, nil); err != nil || len(fake.calls) != 1 || fake.calls[0].Method != http.MethodPatch {
t.Fatalf("runE2EMutate() = %v, calls = %#v", err, fake.calls)
}
}

107
cmd/fresh.go Normal file
View file

@ -0,0 +1,107 @@
package cmd
import (
"fmt"
"github.com/Pingu-Studio/MaidnCLI/internal/bootstrap"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/spf13/cobra"
)
var freshConfigPath, freshOrganization, onboardConfigPath, onboardFrom string
var onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy, onboardBuildOutputDirectory, onboardBuildConfiguration string
var freshCreateOrganization, freshEnableDelivery, freshYes bool
var freshMode string
var loadFreshConfig = config.Load
var loadAppOnboardConfig = config.LoadRaw
var runFreshOrganization = bootstrap.RunFreshOrganization
var resolveAppOnboarding = config.ResolveAppOnboarding
var onboardApp = bootstrap.OnboardApp
var bootstrapInitCmd = &cobra.Command{
Use: "init",
Short: "Create and lock a fresh Forgejo organization bootstrap workspace.",
RunE: runBootstrapInit,
}
var appCmd = &cobra.Command{
Use: "app",
Short: "Manage application delivery scaffolding.",
}
var appOnboardCmd = &cobra.Command{
Use: "onboard",
Short: "Validate an application checkout and add its centrally-owned delivery contract.",
RunE: runAppOnboard,
}
func init() {
bootstrapCmd.AddCommand(bootstrapInitCmd)
bootstrapInitCmd.Flags().StringVar(&freshConfigPath, "config", "", "Path to private bootstrap config YAML")
bootstrapInitCmd.Flags().StringVar(&freshOrganization, "organization", "", "New Forgejo organization name")
bootstrapInitCmd.Flags().BoolVar(&freshCreateOrganization, "create-organization", false, "Create the Forgejo organization when absent")
bootstrapInitCmd.Flags().BoolVar(&freshEnableDelivery, "enable-delivery", false, "Deprecated: init always initializes the shared delivery platform")
bootstrapInitCmd.Flags().StringVar(&freshMode, "mode", string(bootstrap.Reconcile), "Lifecycle mode: reconcile or rebuild")
bootstrapInitCmd.Flags().BoolVar(&freshYes, "yes", false, "Confirm destructive rebuild")
_ = bootstrapInitCmd.MarkFlagRequired("config")
_ = bootstrapInitCmd.MarkFlagRequired("organization")
rootCmd.AddCommand(appCmd)
appCmd.AddCommand(appOnboardCmd)
appOnboardCmd.Flags().StringVar(&onboardConfigPath, "config", "", "Path to private bootstrap config YAML")
appOnboardCmd.Flags().StringVar(&onboardFrom, "from", "", "Clean application checkout to validate")
appOnboardCmd.Flags().StringVar(&onboardAppName, "app-name", "", "Application name override")
appOnboardCmd.Flags().StringVar(&onboardAppRepoURL, "app-repo-url", "", "Application repository URL override")
appOnboardCmd.Flags().StringVar(&onboardImageRepository, "image-repository", "", "OCI image repository override")
appOnboardCmd.Flags().StringVar(&onboardBuildStrategy, "build-strategy", "", "Build strategy override: static or runtime")
appOnboardCmd.Flags().StringVar(&onboardBuildOutputDirectory, "build-output-directory", "", "Static build output directory override")
appOnboardCmd.Flags().StringVar(&onboardBuildConfiguration, "build-configuration", "", "Static build configuration override")
_ = appOnboardCmd.MarkFlagRequired("config")
_ = appOnboardCmd.MarkFlagRequired("from")
}
func runBootstrapInit(cmd *cobra.Command, _ []string) error {
cfg, err := loadFreshConfig(freshConfigPath)
if err != nil {
return err
}
plan, err := runFreshOrganization(cfg, bootstrap.FreshOrganizationOptions{Organization: freshOrganization, CreateOrganization: freshCreateOrganization, EnableDelivery: freshEnableDelivery, Mode: bootstrap.Mode(freshMode), ConfirmRebuild: freshYes})
if err != nil {
return err
}
for _, phase := range plan.Phases {
fmt.Fprintf(cmd.OutOrStdout(), "[PLAN] %s\n", phase)
}
return nil
}
func runAppOnboard(_ *cobra.Command, _ []string) error {
cfg, err := loadAppOnboardConfig(onboardConfigPath)
if err != nil {
return err
}
if onboardAppName != "" {
cfg.Delivery.AppName = onboardAppName
}
if onboardAppRepoURL != "" {
cfg.Delivery.AppRepoURL = onboardAppRepoURL
}
if onboardImageRepository != "" {
cfg.Delivery.ImageRepository = onboardImageRepository
}
if onboardBuildStrategy != "" {
cfg.Delivery.BuildStrategy = onboardBuildStrategy
}
if onboardBuildOutputDirectory != "" {
cfg.Delivery.BuildOutputDirectory = onboardBuildOutputDirectory
}
if onboardBuildConfiguration != "" {
cfg.Delivery.BuildConfiguration = onboardBuildConfiguration
}
cfg, err = resolveAppOnboarding(cfg)
if err != nil {
return err
}
return onboardApp(cfg, onboardFrom)
}

112
cmd/fresh_test.go Normal file
View file

@ -0,0 +1,112 @@
package cmd
import (
"errors"
"io"
"os"
"path/filepath"
"testing"
"github.com/Pingu-Studio/MaidnCLI/internal/bootstrap"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)
func TestBootstrapInitAppliesFluxDefaultsBeforeFreshValidation(t *testing.T) {
originalRun := runFreshOrganization
originalConfigPath, originalOrganization := freshConfigPath, freshOrganization
originalCreate, originalDelivery, originalMode, originalYes := freshCreateOrganization, freshEnableDelivery, freshMode, freshYes
t.Cleanup(func() {
runFreshOrganization = originalRun
freshConfigPath, freshOrganization = originalConfigPath, originalOrganization
freshCreateOrganization, freshEnableDelivery, freshMode, freshYes = originalCreate, originalDelivery, originalMode, originalYes
})
workspace := t.TempDir()
cfg := config.Config{
ClusterID: "test-cluster",
WorkspaceDir: workspace,
Git: config.GitConfig{Provider: "forgejo", BaseURL: "https://git.example.test", Username: "bot", Token: "test-token", Owner: "new-org", CloneParent: filepath.Join(workspace, "checkouts")},
Flux: config.FluxConfig{RepoName: "cluster", ClusterDomain: "example.test"},
Talos: config.TalosConfig{
Proxmox: config.TalosProxmoxConfig{APIURL: "https://proxmox.example.test:8006", APITokenID: "id", APITokenSecret: "test-secret"},
Cluster: config.TalosClusterConfig{Name: "test-cluster", Domain: "example.test"},
Image: config.TalosImageConfig{TalosVersion: "v1.13.6", SchematicID: "abcdefghijkl"},
Nodes: []config.TalosNode{{Name: "cp-01", VMID: 100, Role: "controlplane", Networks: []config.TalosNetwork{{IP: "192.168.45.3", CIDR: "192.168.45.0/28", Gateway: "192.168.45.1", VLANID: 45}, {IP: "192.168.45.18", CIDR: "192.168.45.16/28", VLANID: 451}}}},
},
Cilium: config.CiliumConfig{LoadBalancerStart: "192.168.45.19", LoadBalancerEnd: "192.168.45.30"},
DemocraticCSI: config.DemocraticCSIConfig{TrueNASAPIKey: "test-key", TrueNASHost: "truenas.example.test", TargetPortal: "truenas.example.test:3260", ShareHost: "truenas.example.test", DatasetParentNFS: "pool/kubernetes/nfs/v", DatasetSnapshotsNFS: "pool/kubernetes/nfs/s", AllowedNetworks: "192.168.45.0/24", NameSuffix: "-test"},
}
data, err := yaml.Marshal(cfg)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}
runFreshOrganization = func(got config.Config, options bootstrap.FreshOrganizationOptions) (bootstrap.FreshOrganizationPlan, error) {
if got.Flux.Branch != "main" || got.Flux.ClusterPath != "./clusters/maidn-cd-0" || got.Flux.ManifestsRepo != "cicd-deployment-manifests" || got.Flux.TektonCatalogRepo != "tekton-pipelines" {
t.Fatalf("fresh init Flux defaults = %#v", got.Flux)
}
_, plan, err := bootstrap.PlanFreshOrganization(got, options)
return plan, err
}
freshConfigPath, freshOrganization = path, "new-org"
freshCreateOrganization, freshEnableDelivery, freshMode, freshYes = true, false, string(bootstrap.Reconcile), false
command := &cobra.Command{}
command.SetOut(io.Discard)
if err := runBootstrapInit(command, nil); err != nil {
t.Fatal(err)
}
}
func TestAppOnboardValidatesConfigBeforeExternalWork(t *testing.T) {
originalConfig, originalResolve, originalOnboard := loadAppOnboardConfig, resolveAppOnboarding, onboardApp
originalConfigPath, originalFrom := onboardConfigPath, onboardFrom
originalName, originalRepo, originalImage, originalBuildStrategy, originalBuildOutput, originalBuildConfiguration := onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy, onboardBuildOutputDirectory, onboardBuildConfiguration
t.Cleanup(func() {
loadAppOnboardConfig, resolveAppOnboarding, onboardApp = originalConfig, originalResolve, originalOnboard
onboardConfigPath, onboardFrom = originalConfigPath, originalFrom
onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy, onboardBuildOutputDirectory, onboardBuildConfiguration = originalName, originalRepo, originalImage, originalBuildStrategy, originalBuildOutput, originalBuildConfiguration
})
loadAppOnboardConfig = func(string) (config.Config, error) { return config.Config{}, nil }
resolveAppOnboarding = func(config.Config) (config.Config, error) { return config.Config{}, errors.New("incomplete delivery") }
onboardApp = func(config.Config, string) error {
t.Fatal("onboarding reached external work before validating config")
return nil
}
onboardConfigPath, onboardFrom = "private.yaml", "app-checkout"
onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy, onboardBuildOutputDirectory, onboardBuildConfiguration = "", "", "", "", "", ""
if err := runAppOnboard(nil, nil); err == nil {
t.Fatal("onboarding accepted invalid configuration")
}
}
func TestAppOnboardPassesOnlyValidatedConfigAndCheckout(t *testing.T) {
originalConfig, originalResolve, originalOnboard := loadAppOnboardConfig, resolveAppOnboarding, onboardApp
originalConfigPath, originalFrom := onboardConfigPath, onboardFrom
originalName, originalRepo, originalImage, originalBuildStrategy, originalBuildOutput, originalBuildConfiguration := onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy, onboardBuildOutputDirectory, onboardBuildConfiguration
t.Cleanup(func() {
loadAppOnboardConfig, resolveAppOnboarding, onboardApp = originalConfig, originalResolve, originalOnboard
onboardConfigPath, onboardFrom = originalConfigPath, originalFrom
onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy, onboardBuildOutputDirectory, onboardBuildConfiguration = originalName, originalRepo, originalImage, originalBuildStrategy, originalBuildOutput, originalBuildConfiguration
})
cfg := config.Config{Delivery: config.DeliveryConfig{AppRepoURL: "https://git.example.test/new-org/app.git", AppRepoRef: "main"}}
loadAppOnboardConfig = func(string) (config.Config, error) { return cfg, nil }
resolveAppOnboarding = func(got config.Config) (config.Config, error) { return got, nil }
calls := 0
onboardApp = func(got config.Config, checkout string) error {
if checkout != "app-checkout" || got.Delivery.AppName != "fixture" || got.Delivery.AppRepoURL != "https://git.example.test/new-org/fixture.git" || got.Delivery.ImageRepository != "registry.example.test/new-org/fixture" || got.Delivery.BuildStrategy != "runtime" || got.Delivery.BuildOutputDirectory != "dist/fixture" || got.Delivery.BuildConfiguration != "ci" {
t.Fatal("onboarding used the wrong checkout or config")
}
calls++
return nil
}
onboardConfigPath, onboardFrom = "private.yaml", "app-checkout"
onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy, onboardBuildOutputDirectory, onboardBuildConfiguration = "fixture", "https://git.example.test/new-org/fixture.git", "registry.example.test/new-org/fixture", "runtime", "dist/fixture", "ci"
if err := runAppOnboard(nil, nil); err != nil || calls != 1 {
t.Fatalf("runAppOnboard() = %v, calls = %d", err, calls)
}
}

View file

@ -1,6 +1,7 @@
package cmd
import (
"errors"
"fmt"
"os"
@ -10,12 +11,15 @@ import (
var rootCmd = &cobra.Command{
Use: "cicd-tool",
Short: "A CLI tool to manage CI/CD setup for applications.",
SilenceErrors: true,
}
// Execute adds all child commands to the root command and sets flags appropriately.
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
if !errors.Is(err, errE2EChecks) {
fmt.Fprintln(os.Stderr, err)
}
os.Exit(1)
}
}

View file

@ -0,0 +1,107 @@
# Delivery Ownership
## Status
This is the approved target architecture. Central onboarding is available for
new applications; existing source-owned registrations remain migration work.
## Trust Boundary
Application repositories are build inputs, not GitOps inputs. A developer can
change application code on `main`, but cannot change an active Pipeline, Task,
runtime secret reference, Helm chart, environment value, or promotion policy.
| Concern | Owner | Location |
| --- | --- | --- |
| Application code, tests, Dockerfile | Developers | `Maidn/<app>` `main` |
| Approved charts and environment values | Platform | `Maidn/<app>` `maidn/platform-<app>` |
| Pipeline, Tasks, triggers, runtime access | Platform | Cluster repository |
| Image tags, preview ownership, promotion PRs | Platform | Deployment manifests repository |
| Secret values and policies | Platform | Private config, SOPS operational state, OpenBao |
The protected platform branch may be readable by developers, but only platform
operators and approved automation may push or merge into it. Flux must track
only that branch for chart content. Flux must never track an application `main`
branch or `maidn/delivery-*` branch.
Onboarding requires an existing `maidn/platform-<app>` branch and verifies its
no-direct-push protection before it opens the central registration PR. It never
seeds a platform branch from developer-controlled `main`.
## Resource Flow
```mermaid
flowchart LR
App[Application main branch\ncode only]
Platform[Protected platform branch\nchart and values]
Cluster[Cluster repository\nPipeline, Tasks, secret access]
Manifests[Manifests repository\nimage tags and promotion]
Flux[Flux]
Workload[Preview, staging, production]
App -->|exact source SHA| Cluster
Cluster --> Manifests
Platform --> Flux
Manifests --> Flux
Flux --> Workload
```
The central Pipeline clones the application repository at the event SHA only to
build an image. It obtains chart content from the protected platform branch and
writes only image-tag and promotion state to the manifests repository.
## Prohibited Application Content
Application repositories must not contain active delivery control-plane
resources:
- `.tekton/` Pipeline or Task resources
- `.maidn/` Flux, RBAC, SecretStore, or ExternalSecret resources
- Flux Kustomizations or GitRepositories
- Kubernetes Secret values, SOPS identities, OpenBao tokens, or kubeconfigs
An application `main` branch may retain a chart as a developer proposal, but it
has no delivery effect. A platform operator explicitly reviews and copies an
approved chart and environment values to `maidn/platform-<app>`.
## Approval Sequence
1. A platform operator creates or updates the protected platform branch through
a reviewed platform PR.
2. A platform operator reviews and merges the central cluster registration that
renders the Pipeline, Tasks, protected chart source, and runtime references.
3. Flux applies only the central cluster resources and protected chart branch.
4. Developers use Forgejo pull requests and pushes to trigger builds; they do
not edit deployment control-plane resources.
5. Production promotion remains a reviewed PR in the manifests repository.
## Migration Rules
Existing source-owned registrations are migrated one application at a time.
First establish the protected platform package, then merge the central cluster
registration, then verify Flux and delivery. Remove the legacy source
GitRepository, Kustomizations, and delivery branch only after the replacement
is Ready.
The canonical fixtures are migration pilots. `Maidn/maidn-e2e-secret` remains
disabled until its central replacement is reviewed. Angular and web legacy
registrations must follow the same migration path.
## Command Status
`cicd-tool bootstrap`, `cicd-tool app`, and `cicd-tool e2e` are the current
Forgejo and GitOps command families. `cicd-tool repo init` and `cicd-tool vault
create-*` are legacy GitHub/direct-apply paths and are not part of new platform
onboarding. They will be deprecated or removed after the central delivery
migration.
## Acceptance
The architecture is accepted only after a canonical fixture proves all of the
following with a real Forgejo event:
- pull request preview build, route, and delivery feedback
- main-to-staging deployment at an immutable image SHA
- reviewed production promotion PR
- runtime secret and shared database projection without preview credentials
- closed-preview cleanup and orphan-cleaner recovery

23
docs/delivery-feedback.md Normal file
View file

@ -0,0 +1,23 @@
# Delivery feedback
The central cluster registration owns the Tekton tasks that update one marked
Forgejo pull-request comment. Application repositories do not carry active
delivery Tasks. The comment contains only the verified preview URL, a redacted
task-status summary, and the PipelineRun name. Set the optional
`delivery.tektonDashboardUrl` to a credential-free HTTPS Tekton Dashboard origin
to add a PipelineRun link.
Before enabling delivery feedback, create the separate Forgejo token with
`bootstrap --config <private-bootstrap-config> --create-forgejo-delivery-status-token`.
It creates or reuses `maidn-delivery-status` at
`cicd/forgejo-delivery-status.token`, with only `write:issue` and
`write:repository` for pull-request comments and commit statuses. It does not
reuse the Git clone/push token. The generated task never prints the token or
Forgejo API responses.
Preview and staging feedback waits up to ten minutes for the app Deployment
and HTTPRoute, then performs a bounded HTTPS check. A production event reports
the manifest-repository promotion PR; it does not claim a production deploy.
The protected `maidn/platform-<app>` chart must name both resources after
`delivery.appName`; the HTTPRoute's first hostname must be the public HTTPS
preview/staging URL.

38
docs/e2e.md Normal file
View file

@ -0,0 +1,38 @@
# OCI E2E runner
`cicd-tool e2e` is a read-only verifier: it uses `kubectl get` and HTTP GET
only. It never applies resources, reconciles Flux, or calls bootstrap/rebuild.
It emits one JSON result and exits non-zero when a check fails.
Supply explicit resource identifiers and credential-free URLs. The runner waits
independently (bounded by `--timeout`, maximum ten minutes) for Flux
Kustomizations and an ExternalSecret `Ready=True`, a terminal PipelineRun,
the preview response sentinel, and exactly one open Forgejo promotion PR for
the supplied branch. It reads the Forgejo token only from `--promotion-token-env`
or `--promotion-token-file`; do not pass tokens or credential-bearing URLs.
```sh
cicd-tool e2e \
--kubeconfig /run/secrets/kubeconfig \
--flux-kustomization flux-system/tekton \
--external-secret tekton-pipelines/forgejo-webhook \
--pipelinerun tekton-pipelines/<run-name> \
--preview-url https://<preview-host>/ \
--preview-sentinel <non-secret-sentinel> \
--promotion-pulls-url https://<forgejo>/api/v1/repos/<owner>/<manifests>/pulls \
--promotion-owner <owner> \
--promotion-head maidn/promotion-<app>-<sha> \
--promotion-token-env FORGEJO_TOKEN
```
Build the portable OCI runner with `docker build -t maidn-e2e-runner .`.
Mount the kubeconfig and optional token file read-only; ensure they are readable
by the image's non-root user. The build context excludes known secret-bearing
bootstrap inputs.
## Fixture Boundary
Use `cicd-tool e2e-mutate` only with canonical `Maidn/maidn-e2e-*` fixtures.
The test runner must prove preview, staging, production promotion, runtime
secret/database access, preview cleanup, and orphan cleanup against central
delivery resources. It must not mutate `test-org-2` as a fixture source.

View file

@ -23,6 +23,86 @@ new template revisions intentionally, use a new empty `workspaceDir` (and a
fresh `cloneParent` when it is configured separately) and keep the prior
secret-bearing workspace intact for recovery.
## Standard Delivery Workflow
Open and merge reviewed platform-source PRs before reconciling. When a merged
template source must replace an existing revision lock, run the refresh by
itself. It creates migration PRs; review and merge them, then rerun normal
reconciliation:
```powershell
go run . bootstrap --config <private-bootstrap-config> --mode=reconcile --refresh-template-revisions
go run . bootstrap --config <private-bootstrap-config> --mode=reconcile
```
Initialize the shared delivery platform before onboarding an application. This
creates the Gateway, Tekton, and Tekton Triggers platform resources using
`tekton.<cluster-domain>` and the configured Forgejo owner:
```powershell
go run . bootstrap init --config <private-bootstrap-config> --organization <owner> --create-organization
```
The source-owned onboarding implementation is retired. Central onboarding
creates a reviewed cluster registration only; it never writes `.tekton` or
`.maidn` resources to an application repository. Before merging that
registration, a platform operator must create the corresponding
`maidn/platform-<app>` package through a reviewed platform PR; onboarding
verifies its existence and enforces its protection. See [Delivery
Ownership](architecture/delivery-ownership.md) for the approved architecture
and migration rules.
The onboarding command uses a clean checkout on `delivery.appRepoRef`.
The app repository URL must be the canonical source owner, such as
`Maidn/<app>.git`; `test-org-2` is execution state only. Per-app static build
values remain command-line overrides rather than private defaults:
```powershell
go run . app onboard --config <private-bootstrap-config> --from <clean-checkout> `
--app-name <app> --app-repo-url https://<forgejo>/Maidn/<app>.git `
--image-repository <registry>/<owner>/<app> --build-strategy static `
--build-output-directory <output-directory> --build-configuration <configuration>
```
For a runtime build, set `--build-strategy runtime`; static-only output options
remain harmless. Central onboarding creates the reviewed cluster-registration
change after the platform package exists. The cluster repository owns
`base/tekton/apps/<app>.yaml`; the application repository remains a build input
only. The generic EventListener dispatches by Forgejo repository name.
Existing source-owned registrations are migrated in separate reviewed cluster
repository PRs. Never overwrite an unmanaged registration.
After Flux reports the app Kustomization Ready, use Forgejo's hook test endpoint
against a non-`main` ref and inspect the resulting PipelineRun. The command and
read-only checks are in [Webhook Smoke Test](#webhook-smoke-test).
### Shared Services And Add-ons
Environment databases are platform-owned shared services. Staging workloads use
the CNPG-generated `staging-postgres-app` Secret and production workloads use
`production-postgres-app`; applications must not declare their own CNPG Cluster
by default. Shared credentials are appropriate only for the shared environment
database. Use a dedicated service only when isolation, lifecycle, or storage
requirements demand it.
The cluster repository owns reviewed dedicated resources and runtime secret
access. Application repositories must not carry active `.maidn` resources.
Runtime references never contain credential values. Preview namespaces do not
receive staging or production runtime credentials; preview-safe configuration is
defined in the protected platform chart branch.
Declare runtime secret access in the private configuration and run normal
bootstrap reconciliation to create its policy and role. Set values only with
`app secret set` using stdin, `--file`, or `--generate`; provision the scoped
E2E identity with `bootstrap --provision-app-secret-identities --e2e-app <app>`
only when a probe needs it. See [secret-grants.md](secret-grants.md).
`delivery.productionBranch` is the application production branch. A production
delivery PR targets the configured generic
`<owner>/<manifests-repo>` repository semantics (for example,
`test-org-2/<manifests-repo>`), never through a source checkout's former owner.
## Rebuild
Use only when an authorized recovery requires recreating the Talos VM:
@ -34,6 +114,22 @@ go run . bootstrap --config <private-bootstrap-config> --mode=rebuild --yes
The rebuild replaces Terraform-managed Talos VMs only. It does not manage or
delete TrueNAS datasets or unrelated infrastructure.
## Planned Cluster Removal
There is currently no `maidn cluster remove` or `maidn uninstall` command.
Do not substitute direct Terraform or Kubernetes deletion for a supported
workflow. A future remove command must require the named-cluster confirmation,
limit its Terraform scope to Maidn Talos VMs, and leave external storage and
shared infrastructure under their respective operator procedures.
The command must handle partial removal and retry safely: verify each VM's
actual Proxmox state, accept already-absent Maidn VMs, report API timeouts as
incomplete rather than successful removal, and leave a sanitized removal
record in the isolated workspace. It must not delete shared network bridges,
TrueNAS datasets, SOPS identities, or unrelated infrastructure. Until that
command exists, an operator must use the authorized recovery procedure and
resolve Proxmox API failures before retrying bootstrap.
## OpenBao And Webhooks
After a rebuild or an OpenBao restart, refresh Kubernetes auth and reseed the
@ -102,18 +198,38 @@ provider Secret directly.
## Webhook TLS
The public Gateway terminates HTTPS with a cert-manager certificate. Its
Cloudflare DNS-01 token is used only to issue the `nid3.com` certificate;
Pi-hole remains the ExternalDNS provider. Check certificate readiness with:
The public Gateway terminates HTTPS with a cert-manager certificate. The
Cloudflare API token issues the `nid3.com` certificate and manages explicit
Tunnel CNAME records. Pi-hole remains the ExternalDNS provider. Check
certificate readiness with:
```powershell
kubectl -n cert-manager get externalsecret cloudflare-api-token
kubectl -n gateway-system get certificate webhook-tls
```
Enter the Pi-hole values, Cloudflare DNS-01 token, and Tunnel token through
`--prompt-operational-secrets`, then run `--initialize-openbao`. Do not put the
Cloudflare token in the cluster repository.
Enter the Pi-hole values and Cloudflare API token through
`--prompt-operational-secrets`, then import the user-approved local credentials
file before bootstrap:
```powershell
go run . cloudflare-tunnel import --config <private-bootstrap-config> --credentials-file <local-credentials-json>
```
Do not put the Cloudflare token or tunnel credentials in the cluster repository.
## Cloudflare Tunnel Routes
After importing the credentials, manage one explicit proxied CNAME and ingress
rule at a time:
```powershell
go run . cloudflare-tunnel route add --config <private-bootstrap-config> --hostname <public-hostname> --service <http-or-https-upstream-url> --zone-id <cloudflare-zone-id>
go run . cloudflare-tunnel route remove --config <private-bootstrap-config> --hostname <public-hostname> --service <http-or-https-upstream-url> --zone-id <cloudflare-zone-id>
```
Both commands require all three route values, preserve the terminal 404 rule,
and refuse an existing CNAME that does not point to the managed tunnel.
## Internal Platform UIs

View file

@ -0,0 +1,58 @@
# Credential rotation runbook
## Authorization and inputs
Before any live action, obtain approval for the exact `<credential-identifier>`, `<credential-purpose>`, `<approved-scope>`, `<consumer-inventory>`, `<overlap-window>`, `<maintenance-window>`, and `<rollback-owner>`. The authorized operator must have access to the approved configuration, SOPS identity, recovery identity and encrypted recovery bundle, OpenBao recovery recipient, and the required Forgejo, Flux, webhook, Tekton, registry, and cluster permissions. Credential values must be entered only through an approved secure prompt or standard input boundary; never provide them to an agent or place them in an argument, file, log, or evidence record.
## Consumer inventory template
| Consumer | Logical secret path | Credential identifier | Owner | Validation | Status |
| --- | --- | --- | --- | --- | --- |
| `<consumer>` | `<encrypted-logical-path>` | `<credential-identifier>` | `<owner>` | `<sanitized-check>` | `<pending>` |
Include Forgejo API access, Flux source authentication, webhook authorization, Tekton pipeline consumers, and registry pull/push consumers when applicable. Stop for an unlisted or ambiguous consumer; do not guess its credential source.
## Ordered rotation
1. Confirm the old credential remains valid for the approved overlap window and capture the sanitized baseline status.
2. Create the replacement with the approved minimum scope. Do not revoke or replace the old credential yet.
3. Put the replacement only in `sops.operationalSecretsPath`; encrypt it with `sops.ageKeyPath`. Keep the recovery identity and encrypted recovery bundle in their configured local paths. Do not write plaintext configuration or generated files.
4. Reseed OpenBao from the encrypted operational-secrets file. Verify OpenBao is initialized, unsealed, and ready, then verify External Secrets has refreshed the intended target Secret without reading or printing its data.
5. Validate each inventoried consumer: Forgejo authenticated operation, Flux source authentication and reconciliation readiness, webhook delivery authorization, Tekton trigger and PipelineRun behavior, and registry pull/push behavior. Record only identifiers, timestamps, and pass/fail status.
6. If every validation passes during the overlap window, revoke the old credential through its owning system and repeat the affected consumer checks.
## Forgejo registry package-write recovery
Use this procedure when a Tekton image push fails with `401 Unauthorized: reqPackageAccess` for the target organization.
1. Obtain approval for package write access to the named Forgejo organization. Do not reuse a repository-status token or a user-wide credential without this approval.
2. From the configured MaidnCLI checkout, run the prompted command below. Enter the Forgejo password and optional OTP only at its secure prompts.
```powershell
cicd-tool bootstrap --config <bootstrap-config> --create-forgejo-registry-token
```
3. The command creates or rotates the registry credential, saves its Docker configuration only in encrypted operational secrets, seeds OpenBao, and refreshes `tekton-pipelines/forgejo-registry-credentials`. Never copy the generated token or Docker configuration into a shell command, manifest, or report.
4. Confirm the ExternalSecret is ready without reading Secret data, then retry one disposable PipelineRun targeting the approved organization. Record only the image repository, PipelineRun name, and pass/fail result.
5. If the retry still returns `reqPackageAccess`, stop. Confirm the token owner has package write permission for the target organization and create a replacement through the same prompted command. Do not broaden application, Forgejo status, or webhook credentials as a workaround.
## Rollback
If OpenBao reseed, readiness, External Secrets refresh, or any consumer validation fails, stop before revocation. Restore the previously encrypted operational-secret version, reseed OpenBao, verify readiness and all affected consumers, and keep the old credential active. Escalate if the previous encrypted version or recovery material is unavailable; do not reconstruct values from logs or configuration.
## Sanitized evidence template
| Field | Record |
| --- | --- |
| Rotation ID | `<rotation-id>` |
| Credential identifier | `<credential-identifier>` |
| Scope approval | `<approval-reference>` |
| Operator | `<operator-id>` |
| Started / completed | `<timestamp>` / `<timestamp>` |
| OpenBao initialized / unsealed / ready | `<status>` / `<status>` / `<status>` |
| External Secrets target refresh | `<target-identifier>: <status>` |
| Forgejo / Flux / webhook / Tekton / registry | `<status>` / `<status>` / `<status>` / `<status>` / `<status>` |
| Old credential revocation | `<not-attempted|completed|rolled-back>` |
| Follow-up | `<sanitized-reference>` |
Never include values, encoded values, headers, token fragments, private keys, recovery shares, or decrypted manifest content in the evidence.

150
docs/secret-grants.md Normal file
View file

@ -0,0 +1,150 @@
# Application secret grants
Maidn stores secret values in OpenBao. Git contains only references and access
policy. A repository does not get OpenBao access: one named workload identity
gets one reviewed grant.
## Grant classes
| Consumer | OpenBao path | Kubernetes namespace | Intended use |
| --- | --- | --- | --- |
| `build` | declared `apps/<app>/<secret>` entries | `tekton-pipelines` | Read-only dependency credentials |
| `publish` | declared `apps/<app>/<secret>` entries | `tekton-pipelines` | One app's artifact repository credential |
| `runtime` | declared `apps/<app>/<secret>` entries | `staging` or `production` | Service runtime credentials |
| shared | `shared/<name>/*` | Granted consumer only | Deliberately shared broker, database, or API credentials |
`build` code is repository-controlled. Anything granted to it is readable by a
pull request author. Do not grant deployment, production, Git write, or
administrator credentials to a build.
## Bootstrap configuration
Declare access in the private bootstrap configuration. This declaration has no
secret values and is reviewed with the platform configuration:
```yaml
secretGrants:
- application: orders-api
consumer: publish
secrets:
- registry
shared:
- internal-npm
- application: orders-api
consumer: runtime
environment: staging
secrets:
- database-staging
shared:
- rabbitmq
- application: orders-api
consumer: runtime
environment: production
secrets:
- database-production
shared:
- rabbitmq
```
MaidnCLI validates application, consumer, environment, application-secret, and
shared-grant names.
It creates one OpenBao policy and Kubernetes-auth role for every declaration.
The role names are deterministic:
```text
maidn-<app>-build
maidn-<app>-publish
maidn-<app>-runtime-<environment>
```
The policy permits only the exact `apps/<app>/<secret>` paths and
`shared/<name>/*` paths listed in its declaration. The CLI stores one property
named `value` at each `apps/<app>/<secret>` or `shared/<group>/<secret>` path.
A shared value is stored once, for example at `shared/rabbitmq/password`, and
each service requiring it declares that shared grant. Do not copy it into
application paths.
## GitOps resources
The application environment manifests create the matching ServiceAccount,
SecretStore, and ExternalSecret. These resources are reviewed GitOps content;
never create them with `kubectl apply`.
For `orders-api` staging, use the matching identity and namespace:
```yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: maidn-orders-api-runtime-staging
namespace: orders-api-staging
---
apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
name: openbao-orders-api-staging
namespace: orders-api-staging
spec:
provider:
vault:
server: http://openbao.openbao.svc:8200
path: secret
version: v2
auth:
kubernetes:
mountPath: kubernetes
role: maidn-orders-api-runtime-staging
serviceAccountRef:
name: maidn-orders-api-runtime-staging
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: orders-api-rabbitmq
namespace: orders-api-staging
spec:
refreshInterval: 1h
secretStoreRef:
name: openbao-orders-api-staging
kind: SecretStore
target:
name: orders-api-rabbitmq
creationPolicy: Owner
data:
- secretKey: value
remoteRef:
key: shared/rabbitmq/password
property: value
```
The workload references only `orders-api-rabbitmq` in its own namespace. Each
application gets a dedicated ServiceAccount, SecretStore, and target Secret in
the shared environment namespace; do not use another application's identity.
## Artifact repositories
Create one credential per application and artifact target. Its upstream
permissions must be limited to the exact package, hosted repository, or object
prefix. Examples: one npm scope, one Maven hosted repository, one OCI image,
or S3 `PutObject` for one prefix.
Use `publish` for credentials needed to upload a completed artifact. Use
`build` only for credentials that a build must read, such as a private package
registry. A custom build upload is an exception: it exposes the token to build
code and therefore requires a narrowly scoped, disposable credential.
## Operations
1. Create the least-privilege upstream credential.
2. Write its value with `cicd-tool app secret set` using stdin, `--file`, or
`--generate`. Use a least-privilege `--token-file` or a bootstrap-provisioned
`--identity`; never put a value or token in YAML, a URL, a command argument,
output, or Git.
3. Add the reviewed grant and GitOps resources.
4. Bootstrap or reconcile to create the OpenBao role and policy.
5. Verify the target ExternalSecret becomes Ready without printing its Secret.
6. On revocation, remove the grant and ExternalSecret, revoke the upstream
credential, then restart affected workloads.
See [secrets.md](secrets.md) for encrypted operational-material rules and
[runbooks/credential-rotation.md](runbooks/credential-rotation.md) for rotation.

View file

@ -1,52 +1,34 @@
# Bootstrap Secret Inputs
# Secrets and recovery material
`bootstrap` uses the Flux age identity in ignored `.age/` storage to encrypt
the configured Democratic CSI Secret directly into the generated cluster
repository. Its TrueNAS API key is never printed or committed in plaintext.
## Storage contract
`operational-secrets.sops.yaml` is decrypted only in MaidnCLI memory after
OpenBao is initialized. It is not copied to the cluster repository. Its schema
is:
- `sops.operationalSecretsPath` defaults to `<workspace>/operational-secrets.sops.yaml`. It is a SOPS-encrypted input containing operational secrets, keyed by logical OpenBao paths. It is never plaintext Git content.
- `sops.ageKeyPath` defaults to `<workspace>/.age/key.txt`. This Flux SOPS identity is private local material; do not commit, print, or copy it.
- `sops.recoveryIdentityPath` defaults to `<workspace>/.age/recovery-key.txt`. This separate OpenBao-recovery identity is private local material; do not commit, print, or copy it.
- `sops.recoveryBundlePath` defaults to `<workspace>/.recovery/openbao-recovery.age`. This bundle is encrypted to `sops.recoveryRecipient`; it contains OpenBao recovery material and is never plaintext Git content.
- During generated Flux repository reconciliation, recovery material is rendered only into `base/openbao/unseal.sops.yaml`, encrypted with the Flux SOPS identity. This is the narrowly scoped encrypted recovery path for the `openbao-unseal` controller; no plaintext recovery material belongs in a workload, config, log, or command argument.
```yaml
secrets:
cicd/forgejo:
username: encrypted-value
token: encrypted-value
cicd/forgejo-registry:
dockerconfigjson: encrypted-value
cicd/forgejo-webhook:
authorization: encrypted-value
platform/pihole:
server: encrypted-value
password: encrypted-value
platform/cloudflare:
api-token: encrypted-value
platform/cloudflare-tunnel:
token: encrypted-value
```
## Bootstrap gates
Keys are written to OpenBao KV v2 under `secret/<path>`. Additional paths are
allowed when they use lowercase path characters and scalar property names.
`bootstrap` resolves and validates the configuration before reconciliation. Any bootstrap requires a readable SOPS age identity. `talos.autoBootstrapFlux: true` additionally requires an existing encrypted operational-secrets file and a nonempty recovery recipient. A non-webhook bootstrap additionally requires configured, existing recovery-identity and recovery-bundle files.
Set all `democraticCsi` settings in the bootstrap configuration or provide
them through the interactive wizard. The CLI writes those values only to
`base/democratic-csi/secret.sops.yaml` in the generated cluster repository.
For an existing configuration, run `bootstrap --config <path>
--prompt-democratic-csi` to enter the settings with the API key masked.
The webhook-only path requires a complete delivery contract, an approved configuration, and a readable SOPS age identity. It must reseed OpenBao and observe the refreshed `forgejo-webhook` target Secret plus the EventListener and Pipeline before Forgejo is changed. The registry-token, delivery-status-token, and webhook-authorization flows also require an explicit configuration; they are live credential operations and are not offline-safe.
For a new operational-secret input, run `bootstrap --config <path>
--prompt-operational-secrets`. It derives Forgejo Git and registry credentials
from the configured Forgejo account, prompts for the Pi-hole server and masked
password, masked Cloudflare DNS-01 and Tunnel tokens, and generates the webhook
authorization value. The DNS-01 token issues the Gateway certificate; it is not
used by ExternalDNS.
## Rules
`cicd/forgejo-webhook.authorization` is required for delivery bootstrap. The
CLI supplies it as the Forgejo webhook Authorization header and Tekton compares
that header against the ExternalSecret-derived `forgejo-webhook` Secret.
- Credential values are accepted only at an approved secure input boundary and stored only in encrypted operational or recovery material.
- Never pass credential values in CLI arguments, URLs, logs, Git commits, generated config, tickets, or evidence.
- Do not revoke a previous credential until OpenBao, External Secrets, and every listed consumer have passed validation.
- Use the sanitized procedure in [runbooks/credential-rotation.md](runbooks/credential-rotation.md) for any live rotation.
- Application, artifact, and shared-secret access is documented in [secret-grants.md](secret-grants.md). Secret values remain outside that declaration.
Run `bootstrap --config <path> --initialize-openbao-recovery` to create and
save a separate recovery age identity for `openbao-recovery.age`. The Flux SOPS
age identity is installed in `flux-system`; it must not encrypt OpenBao
recovery material.
## Automated app-secret identities
Use `bootstrap --provision-app-secret-identities --e2e-app <app>` to create a
short-lived non-root `admin` identity and an exact-path `e2e-probe` identity.
Bootstrap reads root recovery material only through its encrypted recovery
bundle, writes the generated tokens only into encrypted operational state, and
never prints either value. `app secret --identity admin` and
`app secret --identity e2e:<app>` create a temporary local token file only for
the command lifetime. Root tokens, recovery bundles, and unseal shares are not
valid app-secret identities.

View file

@ -7,3 +7,6 @@ var ManifestsReadmeTmpl string
//go:embed templates/flux.md.tmpl
var FluxReadmeTmpl string
//go:embed templates/delivery-pipeline.yaml.tmpl
var DeliveryPipelineTmpl string

View file

@ -0,0 +1,826 @@
apiVersion: tekton.dev/v1
kind: Task
metadata:
name: {{ .AppName }}-update-manifest
namespace: tekton-pipelines
spec:
stepTemplate:
env:
- name: HOME
value: /tekton/home
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
seccompProfile:
type: RuntimeDefault
params:
- name: manifests-url
- name: manifests-branch
- name: app-name
- name: app-repository
- name: image
- name: tag
- name: environment
- name: pr-number
default: ""
- name: forgejo-base-url
default: ""
- name: forgejo-owner
default: ""
- name: manifests-repo
default: ""
steps:
- name: update
image: alpine/git:2.47.2
env:
- name: MANIFESTS_URL
value: $(params.manifests-url)
- name: MANIFESTS_BRANCH
value: $(params.manifests-branch)
- name: APP_NAME
value: $(params.app-name)
- name: APP_REPOSITORY
value: $(params.app-repository)
- name: IMAGE
value: $(params.image)
- name: TAG
value: $(params.tag)
- name: ENVIRONMENT
value: $(params.environment)
- name: PR_NUMBER
value: $(params.pr-number)
- name: FORGEJO_BASE_URL
value: $(params.forgejo-base-url)
- name: FORGEJO_OWNER
value: $(params.forgejo-owner)
- name: MANIFESTS_REPO
value: $(params.manifests-repo)
script: |
#!/bin/sh
set -eu
fail() { exit 1; }
valid_name() { case "$1" in ''|*[!a-z0-9-]*|-*|*-) fail ;; esac; }
valid_revision() { case "$1" in [A-Za-z0-9]*) ;; *) fail ;; esac; case "$1" in *[!A-Za-z0-9._/-]*|*..*|*//*|*/) fail ;; esac; }
valid_commit() { [ "${#1}" -eq 40 ] || fail; case "$1" in *[!0-9a-fA-F]*) fail ;; esac; }
valid_url() { case "$1" in https://*/*.git) ;; *) fail ;; esac; case "$1" in *[@?#]*) fail ;; esac; }
valid_repository() { case "$1" in */*) ;; *) fail ;; esac; case "$1" in *..*|*//*|/*|*/) fail ;; esac; }
valid_repository_part() { case "$1" in ''|*[!A-Za-z0-9._-]*|.*|*.) fail ;; esac; }
valid_forgejo_base_url() { case "$1" in https://*) ;; *) fail ;; esac; host=${1#https://}; case "$host" in *:*) name=${host%:*}; port=${host##*:}; case "$port" in ''|*[!0-9]*) fail ;; esac ;; *) name=$host ;; esac; case "$name" in ''|*[!A-Za-z0-9.-]*|.*|*.) fail ;; esac; }
valid_pr_number() { case "$1" in [1-9]*) ;; *) fail ;; esac; case "$1" in *[!0-9]*) fail ;; esac; [ $((${#APP_NAME} + ${#1} + 4)) -le 63 ] || fail; }
valid_name "$APP_NAME"
valid_url "$MANIFESTS_URL"
valid_revision "$MANIFESTS_BRANCH"
valid_repository "$APP_REPOSITORY"
valid_commit "$TAG"
if [ "$ENVIRONMENT" = production ]; then
valid_forgejo_base_url "$FORGEJO_BASE_URL"
valid_repository_part "$FORGEJO_OWNER"
valid_repository_part "$MANIFESTS_REPO"
[ "$MANIFESTS_URL" = "$FORGEJO_BASE_URL/$FORGEJO_OWNER/$MANIFESTS_REPO.git" ] || fail
PROMOTION_BRANCH="maidn/promotion-$APP_NAME-$TAG"
if git ls-remote --exit-code "$MANIFESTS_URL" "refs/heads/$PROMOTION_BRANCH" >/dev/null 2>&1; then
git clone --branch "$PROMOTION_BRANCH" "$MANIFESTS_URL" /tmp/manifests
else
git clone --branch "$MANIFESTS_BRANCH" "$MANIFESTS_URL" /tmp/manifests
git -C /tmp/manifests checkout -b "$PROMOTION_BRANCH"
fi
else
git clone --branch "$MANIFESTS_BRANCH" "$MANIFESTS_URL" /tmp/manifests
fi
cd /tmp/manifests
if [ "$ENVIRONMENT" = preview ]; then
valid_pr_number "$PR_NUMBER"
app_dir="apps/previews/$APP_NAME-pr-$PR_NUMBER"
marker="$app_dir/ownership.yaml"
if [ -e "$app_dir" ]; then
[ -d "$app_dir" ] && [ ! -L "$app_dir" ] && [ -f "$marker" ] && [ ! -L "$marker" ] || fail
expected_marker=$(mktemp)
trap 'rm -f "$expected_marker"' EXIT
cat > "$expected_marker" <<EOF
apiVersion: v1
kind: ConfigMap
metadata:
name: maidn-preview-owner
namespace: $APP_NAME-pr-$PR_NUMBER
labels:
maidn.io/preview-owner: "true"
maidn.io/preview-app: "$APP_NAME"
maidn.io/preview-pr: "$PR_NUMBER"
annotations:
maidn.io/preview-repository: "$APP_REPOSITORY"
data:
app: "$APP_NAME"
repository: "$APP_REPOSITORY"
pr-number: "$PR_NUMBER"
EOF
cmp -s "$expected_marker" "$marker" || fail
fi
mkdir -p "$app_dir"
cat > "$marker" <<EOF
apiVersion: v1
kind: ConfigMap
metadata:
name: maidn-preview-owner
namespace: $APP_NAME-pr-$PR_NUMBER
labels:
maidn.io/preview-owner: "true"
maidn.io/preview-app: "$APP_NAME"
maidn.io/preview-pr: "$PR_NUMBER"
annotations:
maidn.io/preview-repository: "$APP_REPOSITORY"
data:
app: "$APP_NAME"
repository: "$APP_REPOSITORY"
pr-number: "$PR_NUMBER"
EOF
cat > "$app_dir/namespace.yaml" <<EOF
apiVersion: v1
kind: Namespace
metadata:
name: $APP_NAME-pr-$PR_NUMBER
EOF
cat > "$app_dir/release.yaml" <<EOF
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: $APP_NAME
namespace: $APP_NAME-pr-$PR_NUMBER
spec:
interval: 5m
chart:
spec:
chart: ./charts/$APP_NAME
reconcileStrategy: Revision
sourceRef:
kind: GitRepository
name: $APP_NAME
namespace: flux-system
valuesFiles:
- ./charts/$APP_NAME/values.yaml
- ./preview/values.yaml
values:
image:
repository: $IMAGE
tag: $TAG
gateway:
hostname: $APP_NAME-pr-$PR_NUMBER.{{ .ClusterDomain }}
EOF
cat > "$app_dir/kustomization.yaml" <<EOF
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: $APP_NAME-pr-$PR_NUMBER
resources:
- namespace.yaml
- ownership.yaml
- release.yaml
EOF
root=apps/previews/kustomization.yaml
grep -q '^resources:' "$root" || fail
grep -qxF " - $APP_NAME-pr-$PR_NUMBER" "$root" 2>/dev/null || printf ' - %s-pr-%s\n' "$APP_NAME" "$PR_NUMBER" >> "$root"
elif [ "$ENVIRONMENT" = staging ]; then
app_dir="apps/staging/$APP_NAME"
root=apps/staging/kustomization.yaml
if [ -e "$app_dir/release.yaml" ]; then
[ -d "$app_dir" ] && [ ! -L "$app_dir" ] && [ -f "$app_dir/release.yaml" ] && [ ! -L "$app_dir/release.yaml" ] || fail
sed -i -E "s|^([[:space:]]*tag:).*|\1 $TAG|" "$app_dir/release.yaml"
grep -qxF " namespace: staging" "$app_dir/release.yaml" || sed -i "/^ name: $APP_NAME$/a\ namespace: staging" "$app_dir/release.yaml"
grep -qxF " namespace: staging" "$app_dir/release.yaml" || fail
else
[ ! -e "$app_dir" ] || fail
mkdir -p "$app_dir"
cat > "$app_dir/release.yaml" <<EOF
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: $APP_NAME
namespace: staging
spec:
interval: 5m
chart:
spec:
chart: ./charts/$APP_NAME
reconcileStrategy: Revision
sourceRef:
kind: GitRepository
name: $APP_NAME
namespace: flux-system
valuesFiles:
- ./charts/$APP_NAME/values.yaml
- ./staging/values.yaml
values:
image:
repository: $IMAGE
tag: $TAG
EOF
cat > "$app_dir/kustomization.yaml" <<EOF
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- release.yaml
EOF
if [ ! -e "$root" ]; then
mkdir -p "$(dirname "$root")"
cat > "$root" <<EOF
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
EOF
fi
[ -f "$root" ] && [ ! -L "$root" ] && grep -qx 'resources:' "$root" || fail
grep -qxF " - $APP_NAME" "$root" 2>/dev/null || printf ' - %s\n' "$APP_NAME" >> "$root"
fi
elif [ "$ENVIRONMENT" = production ]; then
app_dir="apps/production/$APP_NAME"
root=apps/production/kustomization.yaml
if [ -e "$app_dir" ]; then
[ -d "$app_dir" ] && [ ! -L "$app_dir" ] || fail
fi
mkdir -p "$app_dir"
for file in "$app_dir/release.yaml" "$app_dir/kustomization.yaml"; do
[ ! -e "$file" ] || { [ -f "$file" ] && [ ! -L "$file" ]; } || fail
done
cat > "$app_dir/release.yaml" <<EOF
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: $APP_NAME
namespace: production
spec:
interval: 5m
chart:
spec:
chart: ./charts/$APP_NAME
reconcileStrategy: Revision
sourceRef:
kind: GitRepository
name: $APP_NAME
namespace: flux-system
valuesFiles:
- ./charts/$APP_NAME/values.yaml
- ./production/values.yaml
values:
image:
repository: $IMAGE
tag: $TAG
EOF
cat > "$app_dir/kustomization.yaml" <<EOF
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- release.yaml
EOF
if [ ! -e "$root" ]; then
mkdir -p "$(dirname "$root")"
cat > "$root" <<EOF
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
EOF
fi
[ -f "$root" ] && [ ! -L "$root" ] && grep -qx 'resources:' "$root" || fail
grep -qxF " - $APP_NAME" "$root" 2>/dev/null || printf ' - %s\n' "$APP_NAME" >> "$root"
else
fail
fi
git config user.name Maidn
git config user.email maidn@free-maidn.com
git add apps
git diff --cached --quiet || git commit -m "chore: deploy $APP_NAME $TAG"
if [ "$ENVIRONMENT" = production ]; then
git push origin "HEAD:$PROMOTION_BRANCH"
credentials=$(printf 'url=%s\n\n' "$MANIFESTS_URL" | GIT_TERMINAL_PROMPT=0 git credential fill)
forgejo_user=$(printf '%s\n' "$credentials" | sed -n 's/^username=//p')
forgejo_password=$(printf '%s\n' "$credentials" | sed -n 's/^password=//p')
unset credentials
[ -n "$forgejo_user" ] && [ -n "$forgejo_password" ] || fail
forgejo_auth=$(printf '%s:%s' "$forgejo_user" "$forgejo_password" | base64 | tr -d '\n')
unset forgejo_user forgejo_password
pr_endpoint="$FORGEJO_BASE_URL/api/v1/repos/$FORGEJO_OWNER/$MANIFESTS_REPO/pulls"
pr_query="$pr_endpoint?state=open&head=$FORGEJO_OWNER%3A$PROMOTION_BRANCH"
pr_response=$(mktemp)
pr_body=$(mktemp)
trap 'rm -f "$pr_response" "$pr_body"' EXIT
open_pr_count() {
grep -q '^[[:space:]]*\[' "$pr_response" || fail
grep -o '"number"[[:space:]]*:[[:space:]]*[0-9][0-9]*' "$pr_response" | wc -l | tr -d ' '
}
wget -q -O "$pr_response" --header="Authorization: Basic $forgejo_auth" "$pr_query" || fail
case "$(open_pr_count)" in
0)
printf '{"title":"chore: promote %s %s","head":"%s","base":"%s"}' "$APP_NAME" "$TAG" "$PROMOTION_BRANCH" "$MANIFESTS_BRANCH" > "$pr_body"
wget -q -O "$pr_response" --header="Authorization: Basic $forgejo_auth" --header="Content-Type: application/json" --post-file "$pr_body" "$pr_endpoint" || true
wget -q -O "$pr_response" --header="Authorization: Basic $forgejo_auth" "$pr_query" || fail
[ "$(open_pr_count)" = 1 ] || fail
;;
1) ;;
*) fail ;;
esac
promotion_pr_number=$(grep -o '"number"[[:space:]]*:[[:space:]]*[0-9][0-9]*' "$pr_response" | sed -n '1s/.*:[[:space:]]*//p')
valid_pr_number "$promotion_pr_number"
printf 'Promotion PR opened or updated: %s/%s/%s/pulls/%s\n' "$FORGEJO_BASE_URL" "$FORGEJO_OWNER" "$MANIFESTS_REPO" "$promotion_pr_number"
unset forgejo_auth
else
git push origin "$MANIFESTS_BRANCH"
fi
---
apiVersion: tekton.dev/v1
kind: Task
metadata:
name: {{ .AppName }}-wait-delivery
namespace: tekton-pipelines
spec:
params:
- name: app-name
- name: environment
- name: image
- name: tag
- name: pr-number
default: ""
results:
- name: preview-url
description: Verified preview or staging HTTPRoute URL.
stepTemplate:
env:
- name: HOME
value: /tmp
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
seccompProfile:
type: RuntimeDefault
steps:
- name: wait-for-traffic
image: alpine/k8s:1.33.4
env:
- name: APP_NAME
value: $(params.app-name)
- name: ENVIRONMENT
value: $(params.environment)
- name: PR_NUMBER
value: $(params.pr-number)
- name: IMAGE
value: $(params.image)
- name: TAG
value: $(params.tag)
- name: RESULT_PATH
value: $(results.preview-url.path)
script: |
#!/bin/sh
set -eu
fail() { exit 1; }
valid_app() { case "$1" in ''|*[!a-z0-9-]*|-*|*-) fail ;; esac; [ "${#1}" -le 47 ] || fail; }
valid_pr() { case "$1" in [1-9]*) ;; *) fail ;; esac; case "$1" in *[!0-9]*) fail ;; esac; }
valid_image() { case "$1" in ''|/*|*/|*..*|*//*|*[!A-Za-z0-9._/:-]*) fail ;; esac; }
valid_tag() { [ "${#1}" -eq 40 ] || fail; case "$1" in *[!0-9a-fA-F]*) fail ;; esac; }
valid_host() { case "$1" in ''|.*|*.) fail ;; esac; case "$1" in *[!A-Za-z0-9.-]*) fail ;; esac; }
valid_app "$APP_NAME"
valid_image "$IMAGE"
valid_tag "$TAG"
case "$ENVIRONMENT" in
preview)
valid_pr "$PR_NUMBER"
namespace="$APP_NAME-pr-$PR_NUMBER"
;;
staging) namespace=staging ;;
*) fail ;;
esac
attempts=120
while :; do
deployed_image=$(kubectl -n "$namespace" get "deployment/$APP_NAME" -o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || true)
[ "$deployed_image" = "$IMAGE:$TAG" ] && break
attempts=$((attempts - 1))
[ "$attempts" -gt 0 ] || fail
sleep 5
done
kubectl -n "$namespace" rollout status "deployment/$APP_NAME" --timeout=600s
kubectl -n "$namespace" wait --for=condition=Available "deployment/$APP_NAME" --timeout=600s
kubectl -n "$namespace" wait --for=jsonpath='{.status.parents[0].conditions[?(@.type=="Accepted")].status}'=True "httproute/$APP_NAME" --timeout=600s
host=$(kubectl -n "$namespace" get "httproute/$APP_NAME" -o jsonpath='{.spec.hostnames[0]}')
valid_host "$host"
url="https://$host"
attempts=12
while ! wget -q --spider --timeout=10 "$url" >/dev/null 2>&1; do
attempts=$((attempts - 1))
[ "$attempts" -gt 0 ] || fail
sleep 5
done
printf '%s' "$url" > "$RESULT_PATH"
---
apiVersion: tekton.dev/v1
kind: Task
metadata:
name: {{ .AppName }}-report-delivery
namespace: tekton-pipelines
spec:
params:
- name: app-name
- name: app-repository
- name: pr-number
- name: event-action
- name: pipeline-run
- name: clone-status
- name: build-status
- name: push-status
- name: update-status
- name: readiness-status
- name: cleanup-status
volumes:
- name: delivery-status
emptyDir: {}
stepTemplate:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
seccompProfile:
type: RuntimeDefault
steps:
- name: read-preview-url
image: alpine/k8s:1.33.4
env:
- name: APP_NAME
value: $(params.app-name)
- name: PR_NUMBER
value: $(params.pr-number)
- name: EVENT_ACTION
value: $(params.event-action)
- name: READINESS_STATUS
value: $(params.readiness-status)
volumeMounts:
- name: delivery-status
mountPath: /delivery
script: |
#!/bin/sh
set -eu
fail() { exit 1; }
valid_app() { case "$1" in ''|*[!a-z0-9-]*|-*|*-) fail ;; esac; [ "${#1}" -le 47 ] || fail; }
valid_pr() { case "$1" in [1-9]*) ;; *) fail ;; esac; case "$1" in *[!0-9]*) fail ;; esac; }
valid_host() { case "$1" in ''|.*|*.) fail ;; esac; case "$1" in *[!A-Za-z0-9.-]*) fail ;; esac; }
[ "$EVENT_ACTION" = closed ] && exit 0
[ "$READINESS_STATUS" = Succeeded ] || exit 0
valid_app "$APP_NAME"
valid_pr "$PR_NUMBER"
host=$(kubectl -n "$APP_NAME-pr-$PR_NUMBER" get "httproute/$APP_NAME" -o jsonpath='{.spec.hostnames[0]}')
valid_host "$host"
printf 'https://%s' "$host" > /delivery/preview-url
- name: update-pr-comment
image: python:3.13-alpine
env:
- name: FORGEJO_BASE_URL
value: {{ quote .ForgejoBaseURL }}
- name: APP_NAME
value: $(params.app-name)
- name: APP_REPOSITORY
value: $(params.app-repository)
- name: PR_NUMBER
value: $(params.pr-number)
- name: EVENT_ACTION
value: $(params.event-action)
- name: PIPELINE_RUN
value: $(params.pipeline-run)
- name: CLONE_STATUS
value: $(params.clone-status)
- name: BUILD_STATUS
value: $(params.build-status)
- name: PUSH_STATUS
value: $(params.push-status)
- name: UPDATE_STATUS
value: $(params.update-status)
- name: READINESS_STATUS
value: $(params.readiness-status)
- name: CLEANUP_STATUS
value: $(params.cleanup-status)
- name: FORGEJO_DELIVERY_TOKEN
valueFrom:
secretKeyRef:
name: forgejo-delivery-status
key: token
- name: TEKTON_DASHBOARD_URL
valueFrom:
configMapKeyRef:
name: maidn-preview-delivery-config
key: tekton-dashboard-url
optional: true
volumeMounts:
- name: delivery-status
mountPath: /delivery
script: |
import json
import os
import re
import sys
from pathlib import Path
from urllib.parse import quote, urlsplit
from urllib.request import Request, urlopen
marker = "<!-- maidn-delivery-status -->"
statuses = {"Succeeded", "Failed", "None", "Skipped", "Cancelled", "Unknown", "Pending"}
def fail():
raise ValueError
def origin(value):
parsed = urlsplit(value)
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password or parsed.path or parsed.query or parsed.fragment:
fail()
if not re.fullmatch(r"[A-Za-z0-9.-]+(?::[0-9]{1,5})?", parsed.netloc):
fail()
return value
def status(name):
value = os.environ.get(name, "Unknown")
return value if value in statuses else "Unknown"
def request(method, endpoint, payload=None):
data = None if payload is None else json.dumps(payload).encode()
req = Request(endpoint, data=data, method=method)
req.add_header("Authorization", "token " + token)
req.add_header("Content-Type", "application/json")
with urlopen(req, timeout=15) as response:
return json.load(response) if response.length != 0 else None
try:
base = origin(os.environ["FORGEJO_BASE_URL"])
app = os.environ["APP_NAME"]
repository = os.environ["APP_REPOSITORY"]
pr = os.environ["PR_NUMBER"]
run = os.environ["PIPELINE_RUN"]
action = os.environ["EVENT_ACTION"]
token = os.environ["FORGEJO_DELIVERY_TOKEN"]
if not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,45}[a-z0-9])?", app) or not re.fullmatch(r"[1-9][0-9]{0,8}", pr) or not re.fullmatch(r"[a-z0-9](?:[a-z0-9.-]{0,61}[a-z0-9])?", run) or action not in {"opened", "reopened", "synchronize", "closed"} or not token:
fail()
owner, repo = repository.split("/", 1)
if not all(re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?", part) and ".." not in part for part in (owner, repo)):
fail()
task_status = {name: status(name) for name in ("CLONE_STATUS", "BUILD_STATUS", "PUSH_STATUS", "UPDATE_STATUS", "READINESS_STATUS", "CLEANUP_STATUS")}
if action == "closed":
delivery = "closed"
preview = "Preview cleanup requested."
elif task_status["READINESS_STATUS"] == "Succeeded" and task_status["UPDATE_STATUS"] == "Succeeded":
delivery = "ready"
preview_url = Path("/delivery/preview-url").read_text() if Path("/delivery/preview-url").is_file() else ""
parsed_preview = urlsplit(preview_url)
if parsed_preview.scheme != "https" or not re.fullmatch(r"[A-Za-z0-9.-]+", parsed_preview.netloc) or parsed_preview.path or parsed_preview.query or parsed_preview.fragment:
fail()
preview = "Preview: " + preview_url
else:
delivery = "failed"
preview = "Preview unavailable."
dashboard = os.environ.get("TEKTON_DASHBOARD_URL", "").strip()
run_text = "PipelineRun: `" + run + "`"
if dashboard:
dashboard = origin(dashboard.rstrip("/"))
run_text += " ([details](" + dashboard + "/#/pipelineruns/tekton-pipelines/" + quote(run, safe="") + "))"
summary = ", ".join(name.removesuffix("_STATUS").lower() + "=" + value for name, value in task_status.items())
body = "\n".join((marker, "## Maidn delivery", "Status: **" + delivery + "**", preview, run_text, "Summary: " + summary))
endpoint = base + "/api/v1/repos/" + quote(owner, safe="") + "/" + quote(repo, safe="") + "/issues/" + pr + "/comments"
comments = request("GET", endpoint + "?limit=100")
matches = [comment for comment in comments if marker in comment.get("body", "")]
if len(matches) > 1:
fail()
if matches:
request("PATCH", base + "/api/v1/repos/" + quote(owner, safe="") + "/" + quote(repo, safe="") + "/issues/comments/" + str(matches[0]["id"]), {"body": body})
else:
request("POST", endpoint, {"body": body})
except Exception:
sys.exit("delivery status update failed")
---
apiVersion: tekton.dev/v1
kind: Pipeline
metadata:
name: {{ .AppName }}
namespace: tekton-pipelines
spec:
params:
- name: git-url
default: {{ quote .AppRepoURL }}
- name: git-revision
default: {{ quote .AppRepoRef }}
- name: branch
default: {{ quote .AppRepoRef }}
- name: event-type
default: push
- name: event-action
default: ""
- name: pr-number
default: ""
- name: image
default: {{ quote .ImageRepository }}
- name: manifests-url
default: {{ quote .ManifestsURL }}
- name: manifests-branch
default: {{ quote .ManifestsBranch }}
- name: forgejo-base-url
default: {{ quote .ForgejoBaseURL }}
- name: forgejo-owner
default: {{ quote .ForgejoOwner }}
- name: manifests-repo
default: {{ quote .ManifestsRepo }}
tasks:
- name: build-layer
when:
- input: $(params.event-action)
operator: notin
values: [closed]
taskRef:
name: {{ if eq .BuildStrategy "static" }}maidn-node-static-image{{ else }}maidn-node-runtime-image{{ end }}
params:
- name: url
value: {{ quote .AppRepoURL }}
- name: revision
value: $(params.git-revision)
- name: image
value: $(params.image)
{{ if eq .BuildStrategy "static" }}
- name: output-directory
value: {{ quote .BuildOutputDirectory }}
- name: build-configuration
value: {{ quote .BuildConfiguration }}
{{ end }}
- name: update-preview
runAfter: [build-layer]
when:
- input: $(params.event-type)
operator: in
values: [pull_request]
- input: $(params.event-action)
operator: in
values: [opened, reopened, synchronize]
taskRef:
name: {{ .AppName }}-update-manifest
params:
- name: manifests-url
value: $(params.manifests-url)
- name: manifests-branch
value: $(params.manifests-branch)
- name: app-name
value: {{ quote .AppName }}
- name: app-repository
value: {{ quote .AppRepository }}
- name: image
value: $(params.image)
- name: tag
value: $(params.git-revision)
- name: environment
value: preview
- name: pr-number
value: $(params.pr-number)
- name: update-staging
runAfter: [build-layer]
when:
- input: $(params.event-type)
operator: in
values: [push]
- input: $(params.branch)
operator: in
values: [{{ quote .AppRepoRef }}]
taskRef:
name: {{ .AppName }}-update-manifest
params:
- name: manifests-url
value: $(params.manifests-url)
- name: manifests-branch
value: $(params.manifests-branch)
- name: app-name
value: {{ quote .AppName }}
- name: app-repository
value: {{ quote .AppRepository }}
- name: image
value: $(params.image)
- name: tag
value: $(params.git-revision)
- name: environment
value: staging
- name: wait-preview
runAfter: [update-preview]
when:
- input: $(params.event-type)
operator: in
values: [pull_request]
- input: $(params.event-action)
operator: in
values: [opened, reopened, synchronize]
taskRef:
name: {{ .AppName }}-wait-delivery
params:
- name: app-name
value: {{ quote .AppName }}
- name: environment
value: preview
- name: image
value: $(params.image)
- name: tag
value: $(params.git-revision)
- name: pr-number
value: $(params.pr-number)
- name: wait-staging
runAfter: [update-staging]
when:
- input: $(params.event-type)
operator: in
values: [push]
- input: $(params.branch)
operator: in
values: [{{ quote .AppRepoRef }}]
taskRef:
name: {{ .AppName }}-wait-delivery
params:
- name: app-name
value: {{ quote .AppName }}
- name: environment
value: staging
- name: image
value: $(params.image)
- name: tag
value: $(params.git-revision)
- name: promote-production
runAfter: [build-layer]
when:
- input: $(params.event-type)
operator: in
values: [promotion]
- input: $(params.branch)
operator: in
values: [{{ quote .ProductionBranch }}]
taskRef:
name: {{ .AppName }}-update-manifest
params:
- name: manifests-url
value: $(params.manifests-url)
- name: manifests-branch
value: $(params.manifests-branch)
- name: app-name
value: {{ quote .AppName }}
- name: app-repository
value: {{ quote .AppRepository }}
- name: image
value: $(params.image)
- name: tag
value: $(params.git-revision)
- name: environment
value: production
- name: forgejo-base-url
value: $(params.forgejo-base-url)
- name: forgejo-owner
value: $(params.forgejo-owner)
- name: manifests-repo
value: $(params.manifests-repo)
- name: cleanup-preview
when:
- input: $(params.event-type)
operator: in
values: [pull_request]
- input: $(params.event-action)
operator: in
values: [closed]
taskRef:
name: maidn-preview-orphan-reconciler
params:
- name: app-name
value: {{ quote .AppName }}
- name: pr-number
value: $(params.pr-number)
- name: app-repository
value: {{ quote .AppRepository }}
finally:
- name: report-delivery
when:
- input: $(params.event-type)
operator: in
values: [pull_request]
taskRef:
name: {{ .AppName }}-report-delivery
params:
- name: app-name
value: {{ quote .AppName }}
- name: app-repository
value: {{ quote .AppRepository }}
- name: pr-number
value: $(params.pr-number)
- name: event-action
value: $(params.event-action)
- name: pipeline-run
value: $(context.pipelineRun.name)
- name: clone-status
value: $(tasks.build-layer.status)
- name: build-status
value: $(tasks.build-layer.status)
- name: push-status
value: $(tasks.build-layer.status)
- name: update-status
value: $(tasks.update-preview.status)
- name: readiness-status
value: $(tasks.wait-preview.status)
- name: cleanup-status
value: $(tasks.cleanup-preview.status)

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,66 @@
package bootstrap
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"sort"
"strings"
"time"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
)
var democraticCSIHTTPClient = &http.Client{Timeout: 30 * time.Second}
func destroyDemocraticCSIStorage(csi config.DemocraticCSIConfig) error {
parent := strings.Trim(csi.DatasetParentNFS, "/")
if parent == "" || strings.Contains(parent, "..") {
return fmt.Errorf("invalid Democratic CSI dataset parent")
}
base := "http://" + strings.TrimPrefix(strings.TrimPrefix(csi.TrueNASHost, "http://"), "https://") + ":80/api/v2.0/pool/dataset"
request, err := http.NewRequest(http.MethodGet, base+"?parent="+url.QueryEscape(parent), nil)
if err != nil {
return err
}
request.Header.Set("Authorization", "Bearer "+csi.TrueNASAPIKey)
response, err := democraticCSIHTTPClient.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("list datasets: %s", response.Status)
}
var datasets []struct {
Name string `json:"name"`
}
if err := json.NewDecoder(response.Body).Decode(&datasets); err != nil {
return err
}
prefix := parent + "/"
children := make([]string, 0, len(datasets))
for _, dataset := range datasets {
if strings.HasPrefix(dataset.Name, prefix) && !strings.Contains(strings.TrimPrefix(dataset.Name, prefix), "/") {
children = append(children, dataset.Name)
}
}
sort.Strings(children)
for _, child := range children {
request, err := http.NewRequest(http.MethodDelete, base+"/id/"+url.PathEscape(child)+"?recursive=true&force=true", nil)
if err != nil {
return err
}
request.Header.Set("Authorization", "Bearer "+csi.TrueNASAPIKey)
response, err := democraticCSIHTTPClient.Do(request)
if err != nil {
return err
}
response.Body.Close()
if response.StatusCode != http.StatusNoContent {
return fmt.Errorf("delete dataset %q: %s", child, response.Status)
}
}
return nil
}

View file

@ -0,0 +1,33 @@
package bootstrap
import (
"io"
"net/http"
"strings"
"testing"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
)
type roundTripper func(*http.Request) (*http.Response, error)
func (f roundTripper) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) }
func TestDestroyDemocraticCSIStorageDeletesOnlyDirectChildren(t *testing.T) {
original := democraticCSIHTTPClient
t.Cleanup(func() { democraticCSIHTTPClient = original })
var deleted []string
democraticCSIHTTPClient = &http.Client{Transport: roundTripper(func(request *http.Request) (*http.Response, error) {
if request.Method == http.MethodGet {
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`[{"name":"pool/cluster/one"},{"name":"pool/cluster/one/child"},{"name":"pool/other"}]`))}, nil
}
deleted = append(deleted, request.URL.EscapedPath())
return &http.Response{StatusCode: http.StatusNoContent, Body: io.NopCloser(strings.NewReader(""))}, nil
})}
if err := destroyDemocraticCSIStorage(config.DemocraticCSIConfig{TrueNASHost: "truenas.test", TrueNASAPIKey: "token", DatasetParentNFS: "pool/cluster"}); err != nil {
t.Fatal(err)
}
if len(deleted) != 1 || !strings.Contains(deleted[0], "pool%2Fcluster%2Fone") {
t.Fatalf("deleted %v", deleted)
}
}

243
internal/bootstrap/fresh.go Normal file
View file

@ -0,0 +1,243 @@
package bootstrap
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
)
type FreshOrganizationOptions struct {
Organization string
CreateOrganization bool
EnableDelivery bool
Mode Mode
ConfirmRebuild bool
}
type FreshOrganizationPlan struct {
Phases []string
}
var ensureFreshTemplateRevisions = EnsureTemplateRevisions
var newFreshRepoManager = forgejo.NewRepoManager
var runFreshLifecycle = reconcileFreshOrganization
// PlanFreshOrganization validates the fresh, reversible setup phases before
// any Forgejo or Git boundary is reached.
func PlanFreshOrganization(cfg config.Config, options FreshOrganizationOptions) (config.Config, FreshOrganizationPlan, error) {
mode, err := resolveLifecycleMode(options.Mode, options.ConfirmRebuild)
if err != nil {
return cfg, FreshOrganizationPlan{}, err
}
resolved, err := config.ResolveFreshBootstrap(cfg, options.Organization, options.EnableDelivery)
if err != nil {
return cfg, FreshOrganizationPlan{}, err
}
if !options.CreateOrganization {
return cfg, FreshOrganizationPlan{}, errors.New("--create-organization is required for fresh organization bootstrap")
}
if err := validateFreshWorkspace(resolved); err != nil {
return cfg, FreshOrganizationPlan{}, err
}
phases := []string{
"validate isolated workspace and configuration",
"lock template revisions in the isolated workspace",
"ensure the Forgejo organization",
"ensure baseline Flux and manifests repositories",
}
phases = append(phases, "initialize the user-managed Tekton catalog repository")
phases = append(phases, fmt.Sprintf("%s the CI/CD cluster", mode))
return resolved, FreshOrganizationPlan{Phases: phases}, nil
}
// RunFreshOrganization completes a fresh bootstrap through the selected lifecycle.
func RunFreshOrganization(cfg config.Config, options FreshOrganizationOptions) (FreshOrganizationPlan, error) {
resolved, plan, err := PlanFreshOrganization(cfg, options)
if err != nil {
return FreshOrganizationPlan{}, err
}
if err := ensureFreshTemplateRevisions(resolved); err != nil {
return plan, fmt.Errorf("lock template revisions: %w", err)
}
manager := newFreshRepoManager(resolved.Git.BaseURL, resolved.Git.Token, resolved.Git.Owner, resolved.Git.Username, "", "", resolved.Flux.Branch, "")
if _, err := manager.EnsureOrganization(options.CreateOrganization); err != nil {
return plan, fmt.Errorf("ensure Forgejo organization: %w", err)
}
if err := runFreshLifecycle(resolved, options); err != nil {
return plan, fmt.Errorf("run fresh CI/CD bootstrap: %w", err)
}
return plan, nil
}
func reconcileFreshOrganization(cfg config.Config, options FreshOrganizationOptions) error {
return freshLifecycleRunner(cfg, options).Run()
}
func freshLifecycleRunner(cfg config.Config, options FreshOrganizationOptions) Runner {
mode := options.Mode
if mode == "" {
mode = Reconcile
}
return Runner{
Config: cfg,
Mode: mode,
ConfirmRebuild: options.ConfirmRebuild,
SkipDeliveryScaffolding: true,
AutoMergeBootstrapMigration: true,
}
}
func validateFreshWorkspace(cfg config.Config) error {
cloneRelative, err := filepath.Rel(cfg.WorkspaceDir, cfg.Git.CloneParent)
if err != nil || filepath.Dir(cloneRelative) != "." {
return errors.New("git cloneParent must be a direct child of isolated workspaceDir")
}
secretFiles, secretDirectories, err := freshWorkspaceSecretPaths(cfg, cloneRelative)
if err != nil {
return err
}
info, err := os.Lstat(cfg.WorkspaceDir)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("inspect workspaceDir: %w", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return errors.New("workspaceDir must be an isolated directory")
}
entries, err := os.ReadDir(cfg.WorkspaceDir)
if err != nil {
return fmt.Errorf("inspect workspaceDir: %w", err)
}
if len(entries) == 0 {
return nil
}
lockPath := filepath.Join(cfg.WorkspaceDir, "maidn-template-revisions.yaml")
if !freshWorkspaceRegularFile(lockPath) {
return errors.New("workspaceDir contains ambiguous state; use a new empty isolated workspaceDir")
}
lock, err := readTemplateRevisionLock(lockPath)
if err != nil || !sameTemplateSource(lock.CICD, templateCheckout{Repository: cfg.Templates.CICDRepoURL, Ref: cfg.Templates.CICDRepoRef}) || !sameTemplateSource(lock.Manifests, templateCheckout{Repository: cfg.Templates.ManifestsRepoURL, Ref: cfg.Templates.ManifestsRepoRef}) || !sameTemplateSource(lock.Talos, templateCheckout{Repository: cfg.Templates.TalosRepoURL, Ref: cfg.Templates.TalosRepoRef}) {
return errors.New("workspaceDir contains ambiguous state; use a new empty isolated workspaceDir")
}
allowedFiles := map[string]bool{
"maidn-template-revisions.yaml": true,
"maidn-bootstrap.resolved.yaml": true,
}
allowedDirectories := map[string]bool{
"maidn-cicd-cluster-template": true,
"cicd-deployment-manifests-template": true,
cloneRelative: true,
}
for path := range secretFiles {
if filepath.Dir(path) == "." {
allowedFiles[path] = true
}
}
for path := range secretDirectories {
if filepath.Dir(path) == "." {
allowedDirectories[path] = true
}
}
for _, entry := range entries {
if allowedFiles[entry.Name()] {
if !freshWorkspaceRegularFile(filepath.Join(cfg.WorkspaceDir, entry.Name())) {
return errors.New("workspaceDir contains ambiguous state; use a new empty isolated workspaceDir")
}
continue
}
if !allowedDirectories[entry.Name()] || !freshWorkspaceDirectory(filepath.Join(cfg.WorkspaceDir, entry.Name())) {
return errors.New("workspaceDir contains ambiguous state; use a new empty isolated workspaceDir")
}
}
return validateFreshWorkspaceSecretDirectories(cfg.WorkspaceDir, secretFiles, secretDirectories)
}
func freshWorkspaceSecretPaths(cfg config.Config, cloneRelative string) (map[string]bool, map[string]bool, error) {
files := map[string]bool{}
directories := map[string]bool{}
protected := map[string]bool{
"maidn-template-revisions.yaml": true,
"maidn-bootstrap.resolved.yaml": true,
"maidn-cicd-cluster-template": true,
"cicd-deployment-manifests-template": true,
cloneRelative: true,
}
for _, path := range []string{cfg.SOPS.AgeKeyPath, cfg.SOPS.BootstrapSecretsPath, cfg.SOPS.OperationalSecretsPath, cfg.SOPS.RecoveryIdentityPath, cfg.SOPS.RecoveryBundlePath} {
if path == "" || !filepath.IsAbs(path) {
continue
}
relative, err := filepath.Rel(cfg.WorkspaceDir, path)
if err != nil || relative == "." || relative == ".." || filepath.IsAbs(relative) || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
continue
}
parts := strings.Split(filepath.ToSlash(relative), "/")
if protected[parts[0]] {
return nil, nil, errors.New("SOPS and recovery paths must not use protected workspace paths")
}
files[relative] = true
for parent := filepath.Dir(relative); parent != "."; parent = filepath.Dir(parent) {
directories[parent] = true
}
}
for path := range files {
if directories[path] {
return nil, nil, errors.New("SOPS and recovery paths must not overlap")
}
}
return files, directories, nil
}
func validateFreshWorkspaceSecretDirectories(workspace string, files, directories map[string]bool) error {
for directory := range directories {
if directories[filepath.Dir(directory)] {
continue
}
if err := validateFreshWorkspaceSecretDirectory(workspace, directory, files, directories); err != nil {
return err
}
}
return nil
}
func validateFreshWorkspaceSecretDirectory(workspace, directory string, files, directories map[string]bool) error {
path := filepath.Join(workspace, directory)
entries, err := os.ReadDir(path)
if os.IsNotExist(err) {
return nil
}
if err != nil || !freshWorkspaceDirectory(path) {
return errors.New("workspaceDir contains ambiguous state; use a new empty isolated workspaceDir")
}
for _, entry := range entries {
relative := filepath.Join(directory, entry.Name())
path := filepath.Join(workspace, relative)
if files[relative] && freshWorkspaceRegularFile(path) {
continue
}
if directories[relative] && freshWorkspaceDirectory(path) {
if err := validateFreshWorkspaceSecretDirectory(workspace, relative, files, directories); err != nil {
return err
}
continue
}
return errors.New("workspaceDir contains ambiguous state; use a new empty isolated workspaceDir")
}
return nil
}
func freshWorkspaceRegularFile(path string) bool {
info, err := os.Lstat(path)
return err == nil && info.Mode()&os.ModeSymlink == 0 && info.Mode().IsRegular()
}
func freshWorkspaceDirectory(path string) bool {
info, err := os.Lstat(path)
return err == nil && info.Mode()&os.ModeSymlink == 0 && info.IsDir()
}

View file

@ -0,0 +1,288 @@
package bootstrap
import (
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
)
func freshPlanConfig(t *testing.T) config.Config {
t.Helper()
workspace := t.TempDir()
return config.Config{
WorkspaceDir: workspace,
Git: config.GitConfig{Provider: "forgejo", BaseURL: "https://git.example.test", Username: "bot", Token: "token", CloneParent: filepath.Join(workspace, "checkouts")},
Flux: config.FluxConfig{RepoName: "cluster", Branch: "main", ClusterPath: "./clusters/cluster", ClusterDomain: "cluster.example.test", ManifestsRepo: "manifests", TektonCatalogRepo: "catalog"},
Talos: config.TalosConfig{RepoDirName: "talos", GeneratedDir: "generated"},
Delivery: config.DeliveryConfig{AppName: "app", AppRepoURL: "https://git.example.test/new-org/app.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/new-org/app", BuildOutputDirectory: "dist", BuildConfiguration: "production", WebhookHostname: "tekton.cluster.example.test", WebhookPath: "/"},
Templates: config.TemplateConfig{
TalosRepoURL: "https://git.example.test/templates/talos.git", TalosRepoRef: "main",
CICDRepoURL: "https://git.example.test/templates/cicd.git", CICDRepoRef: "main",
ManifestsRepoURL: "https://git.example.test/templates/manifests.git", ManifestsRepoRef: "main",
TektonCatalogRepoURL: "https://git.example.test/templates/catalog.git", TektonCatalogRepoRef: "main",
},
}
}
func TestPlanFreshOrganizationOrdersOnlyFreshPhases(t *testing.T) {
cfg := freshPlanConfig(t)
resolved, plan, err := PlanFreshOrganization(cfg, FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true, EnableDelivery: true})
if err != nil {
t.Fatal(err)
}
if resolved.Git.Owner != "new-org" {
t.Fatal("organization was not bound to the fresh configuration")
}
want := []string{
"validate isolated workspace and configuration",
"lock template revisions in the isolated workspace",
"ensure the Forgejo organization",
"ensure baseline Flux and manifests repositories",
"initialize the user-managed Tekton catalog repository",
"reconcile the CI/CD cluster",
}
if !reflect.DeepEqual(plan.Phases, want) {
t.Fatalf("plan phases = %#v, want %#v", plan.Phases, want)
}
}
func TestPlanFreshOrganizationUsesSelectedLifecycleMode(t *testing.T) {
for _, test := range []struct {
name string
options FreshOrganizationOptions
phase string
}{
{"default", FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true}, "reconcile the CI/CD cluster"},
{"rebuild", FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true, Mode: Rebuild, ConfirmRebuild: true}, "rebuild the CI/CD cluster"},
} {
t.Run(test.name, func(t *testing.T) {
_, plan, err := PlanFreshOrganization(freshPlanConfig(t), test.options)
if err != nil {
t.Fatal(err)
}
if got := plan.Phases[len(plan.Phases)-1]; got != test.phase {
t.Fatalf("lifecycle phase = %q, want %q", got, test.phase)
}
})
}
}
func TestPlanFreshOrganizationRejectsUnconfirmedRebuild(t *testing.T) {
_, _, err := PlanFreshOrganization(freshPlanConfig(t), FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true, Mode: Rebuild})
if err == nil || !strings.Contains(err.Error(), "--mode=rebuild --yes") {
t.Fatalf("PlanFreshOrganization() error = %v", err)
}
}
func TestRunFreshOrganizationOrdersSourceControlBeforeLifecycle(t *testing.T) {
originalLock, originalManager, originalLifecycle := ensureFreshTemplateRevisions, newFreshRepoManager, runFreshLifecycle
t.Cleanup(func() {
ensureFreshTemplateRevisions, newFreshRepoManager, runFreshLifecycle = originalLock, originalManager, originalLifecycle
})
var phases []string
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.Method + " " + request.URL.Path {
case http.MethodGet + " /api/v1/orgs/new-org":
phases = append(phases, "organization lookup")
writer.WriteHeader(http.StatusNotFound)
case http.MethodPost + " /api/v1/orgs":
if !reflect.DeepEqual(phases, []string{"template lock", "organization lookup"}) {
t.Fatalf("organization creation phase order = %#v", phases)
}
phases = append(phases, "organization create")
writer.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected Forgejo request %s %s", request.Method, request.URL.Path)
}
}))
defer server.Close()
ensureFreshTemplateRevisions = func(config.Config) error {
phases = append(phases, "template lock")
return nil
}
newFreshRepoManager = func(_ string, token, owner, username, manifestsRepo, fluxRepo, branch, migrationBranch string) *forgejo.RepoManager {
manager := forgejo.NewRepoManager(server.URL, token, owner, username, manifestsRepo, fluxRepo, branch, migrationBranch)
manager.HTTPClient = server.Client()
return manager
}
runFreshLifecycle = func(cfg config.Config, options FreshOrganizationOptions) error {
if !options.EnableDelivery || cfg.Git.Owner != "new-org" || !reflect.DeepEqual(phases, []string{"template lock", "organization lookup", "organization create"}) {
t.Fatal("lifecycle ran before the locked Forgejo source-control preflight")
}
phases = append(phases, "lifecycle")
return nil
}
if _, err := RunFreshOrganization(freshPlanConfig(t), FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true, EnableDelivery: true}); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(phases, []string{"template lock", "organization lookup", "organization create", "lifecycle"}) {
t.Fatalf("fresh bootstrap phases = %#v", phases)
}
}
func TestRunFreshOrganizationStopsBeforeLifecycleOnPreflightFailure(t *testing.T) {
originalLock, originalLifecycle := ensureFreshTemplateRevisions, runFreshLifecycle
t.Cleanup(func() {
ensureFreshTemplateRevisions, runFreshLifecycle = originalLock, originalLifecycle
})
ensureFreshTemplateRevisions = func(config.Config) error { return errors.New("unavailable") }
runFreshLifecycle = func(config.Config, FreshOrganizationOptions) error {
t.Fatal("lifecycle ran after template lock failure")
return nil
}
_, err := RunFreshOrganization(freshPlanConfig(t), FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true})
if err == nil || !strings.Contains(err.Error(), "lock template revisions") {
t.Fatalf("RunFreshOrganization() error = %v", err)
}
}
func TestRunFreshOrganizationStopsBeforeLifecycleOnForgejoFailure(t *testing.T) {
originalLock, originalManager, originalLifecycle := ensureFreshTemplateRevisions, newFreshRepoManager, runFreshLifecycle
t.Cleanup(func() {
ensureFreshTemplateRevisions, newFreshRepoManager, runFreshLifecycle = originalLock, originalManager, originalLifecycle
})
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
ensureFreshTemplateRevisions = func(config.Config) error { return nil }
newFreshRepoManager = func(_ string, token, owner, username, manifestsRepo, fluxRepo, branch, migrationBranch string) *forgejo.RepoManager {
manager := forgejo.NewRepoManager(server.URL, token, owner, username, manifestsRepo, fluxRepo, branch, migrationBranch)
manager.HTTPClient = server.Client()
return manager
}
runFreshLifecycle = func(config.Config, FreshOrganizationOptions) error {
t.Fatal("lifecycle ran after Forgejo preflight failure")
return nil
}
_, err := RunFreshOrganization(freshPlanConfig(t), FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true})
if err == nil || !strings.Contains(err.Error(), "ensure Forgejo organization") {
t.Fatalf("RunFreshOrganization() error = %v", err)
}
}
func TestFreshLifecycleUsesSelectedModeWithoutAppDelivery(t *testing.T) {
originalPreflight := preflight
t.Cleanup(func() { preflight = originalPreflight })
cfg := runnerTestConfig(t.TempDir(), "")
cfg.Delivery = config.DeliveryConfig{}
for _, test := range []struct {
options FreshOrganizationOptions
mode Mode
}{
{FreshOrganizationOptions{}, Reconcile},
{FreshOrganizationOptions{Mode: Rebuild, ConfirmRebuild: true, EnableDelivery: true}, Rebuild},
} {
runner := freshLifecycleRunner(cfg, test.options)
if runner.Mode != test.mode || runner.ConfirmRebuild != test.options.ConfirmRebuild || runner.EnableDelivery || !runner.SkipDeliveryScaffolding || !runner.AutoMergeBootstrapMigration {
t.Fatalf("fresh lifecycle runner = %#v", runner)
}
preflight = func(got config.Config) error {
if got.Delivery.Configured() {
t.Fatal("fresh lifecycle resolved app delivery during platform initialization")
}
return errors.New("stop")
}
if err := runner.Run(); err == nil || !strings.Contains(err.Error(), "preflight: stop") {
t.Fatalf("fresh lifecycle run = %v", err)
}
}
}
func TestFreshPlatformRunnerInitializesOpenBaoWithoutRegisteringWebhookAfterFluxSetup(t *testing.T) {
originalInitialize, originalWebhook := initializeOpenBao, ensureForgejoWebhook
t.Cleanup(func() {
initializeOpenBao, ensureForgejoWebhook = originalInitialize, originalWebhook
})
runner := freshLifecycleRunner(runnerTestConfig(t.TempDir(), "age-key"), FreshOrganizationOptions{})
if !runner.SkipDeliveryScaffolding || runner.RegisterWebhook {
t.Fatalf("fresh platform runner = %#v", runner)
}
initialized := false
initializeOpenBao = func(kubeconfig, recipient, identityPath, bundlePath, ageKeyPath, operationalSecretsPath string) (map[string]map[string]string, error) {
if kubeconfig != filepath.Join("after-flux", "kubeconfig") || ageKeyPath != "age-key" {
t.Fatal("platform OpenBao initialization used unexpected paths")
}
initialized = true
return nil, nil
}
ensureForgejoWebhook = func(config.Config, string, string, string) error {
t.Fatal("platform initialization registered an application webhook")
return nil
}
if err := runner.completeFluxBootstrap("after-flux"); err != nil || !initialized {
t.Fatalf("post-Flux platform initialization = %v, initialized = %t", err, initialized)
}
}
func TestPlanFreshOrganizationRejectsUnrecognizedWorkspaceState(t *testing.T) {
cfg := freshPlanConfig(t)
prepareResumableFreshWorkspace(t, &cfg)
if err := os.WriteFile(filepath.Join(cfg.WorkspaceDir, ".age", "leftover"), []byte("state"), 0600); err != nil {
t.Fatal(err)
}
if _, _, err := PlanFreshOrganization(cfg, FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true}); err == nil {
t.Fatal("ambiguous workspace state was accepted")
}
}
func TestPlanFreshOrganizationResumesKnownWorkspaceState(t *testing.T) {
cfg := freshPlanConfig(t)
prepareResumableFreshWorkspace(t, &cfg)
if _, _, err := PlanFreshOrganization(cfg, FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true}); err != nil {
t.Fatalf("resumable workspace state was rejected: %v", err)
}
}
func prepareResumableFreshWorkspace(t *testing.T, cfg *config.Config) {
t.Helper()
cfg.SOPS = config.SOPSConfig{
AgeKeyPath: filepath.Join(cfg.WorkspaceDir, ".age", "key.txt"),
BootstrapSecretsPath: filepath.Join(cfg.WorkspaceDir, "bootstrap-secrets.sops.yaml"),
OperationalSecretsPath: filepath.Join(cfg.WorkspaceDir, "operational-secrets.sops.yaml"),
RecoveryIdentityPath: filepath.Join(cfg.WorkspaceDir, ".age", "recovery-key.txt"),
RecoveryBundlePath: filepath.Join(cfg.WorkspaceDir, ".recovery", "openbao-recovery.age"),
}
for _, directory := range []string{
filepath.Join(cfg.WorkspaceDir, "maidn-cicd-cluster-template"),
filepath.Join(cfg.WorkspaceDir, "cicd-deployment-manifests-template"),
cfg.Git.CloneParent,
filepath.Join(cfg.WorkspaceDir, ".age"),
filepath.Join(cfg.WorkspaceDir, ".recovery"),
} {
if err := os.MkdirAll(directory, 0700); err != nil {
t.Fatal(err)
}
}
if err := writeTemplateRevisionLock(filepath.Join(cfg.WorkspaceDir, "maidn-template-revisions.yaml"), templateRevisionLock{
Version: 1,
CICD: templateRevision{Repository: cfg.Templates.CICDRepoURL, Ref: cfg.Templates.CICDRepoRef, Commit: strings.Repeat("a", 40)},
Manifests: templateRevision{Repository: cfg.Templates.ManifestsRepoURL, Ref: cfg.Templates.ManifestsRepoRef, Commit: strings.Repeat("b", 40)},
Talos: templateRevision{Repository: cfg.Templates.TalosRepoURL, Ref: cfg.Templates.TalosRepoRef, Commit: strings.Repeat("c", 40)},
}); err != nil {
t.Fatal(err)
}
for _, path := range []string{
filepath.Join(cfg.WorkspaceDir, "maidn-bootstrap.resolved.yaml"),
cfg.SOPS.AgeKeyPath,
cfg.SOPS.BootstrapSecretsPath,
cfg.SOPS.OperationalSecretsPath,
cfg.SOPS.RecoveryIdentityPath,
cfg.SOPS.RecoveryBundlePath,
} {
if err := os.WriteFile(path, nil, 0600); err != nil {
t.Fatal(err)
}
}
}

View file

@ -0,0 +1,368 @@
package bootstrap
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
"gopkg.in/yaml.v3"
)
type onboardingRepoManager interface {
EnsureRepository(string, string) (bool, error)
RemoteBranchRevision(string, string) (string, error)
PushRef(string, string, string, string) error
EnsureProtectedBranch(string, string) error
PublishRepositoryPullRequest(string, string, string, string, func(string) error) (bool, error)
EnsureWebhook(string, string, string) error
}
var newOnboardingRepoManager = func(baseURL, token, owner, username, manifestsRepo, fluxRepo, branch, migrationBranch string) onboardingRepoManager {
return forgejo.NewRepoManager(baseURL, token, owner, username, manifestsRepo, fluxRepo, branch, migrationBranch)
}
// OnboardApp opens a reviewed central cluster-registration pull request.
func OnboardApp(cfg config.Config, sourceDir string) error {
resolved, err := config.ResolveAppOnboarding(cfg)
if err != nil {
return err
}
if err := forgejo.EnsureCleanCheckout(sourceDir); err != nil {
return err
}
sourceBranch, err := forgejo.CurrentBranch(sourceDir)
if err != nil {
return err
}
if sourceBranch != resolved.Delivery.AppRepoRef {
return errors.New("--from branch must match delivery appRepoRef")
}
sourceRevision, err := forgejo.BranchRevision(sourceDir, sourceBranch)
if err != nil {
return err
}
owner, repository, err := forgejo.RepositoryFromURL(resolved.Delivery.AppRepoURL)
if err != nil {
return err
}
sourceManager := newOnboardingRepoManager(resolved.Git.BaseURL, resolved.Git.Token, owner, resolved.Git.Username, "", "", resolved.Delivery.AppRepoRef, "")
if _, err := sourceManager.EnsureRepository(repository, "Application build input for Maidn CI/CD"); err != nil {
return err
}
if err := publishInitialAppBranches(sourceManager, sourceDir, resolved.Delivery.AppRepoURL, sourceRevision, resolved.Delivery.AppRepoRef, resolved.Delivery.ProductionBranch); err != nil {
return err
}
if err := sourceManager.EnsureProtectedBranch(repository, resolved.Delivery.ProductionBranch); err != nil {
return fmt.Errorf("protect Forgejo production branch: %w", err)
}
if err := ensurePlatformBranch(sourceManager, repository, resolved.Delivery.AppRepoURL, resolved.Delivery.AppName); err != nil {
return err
}
registrationBranch := registrationBranch(resolved.Delivery.AppName, sourceRevision)
clusterManager := newOnboardingRepoManager(resolved.Git.BaseURL, resolved.Git.Token, resolved.Git.Owner, resolved.Git.Username, "", "", resolved.Flux.Branch, "")
if _, err := clusterManager.PublishRepositoryPullRequest(resolved.Flux.RepoName, "feat: register "+resolved.Delivery.AppName+" delivery", registrationBranch, resolved.Flux.Branch, func(dir string) error {
return registerAppInCluster(dir, resolved)
}); err != nil {
return fmt.Errorf("register app in cluster repository: %w", err)
}
secrets, err := readOperationalSecrets(resolved.SOPS.OperationalSecretsPath, resolved.SOPS.AgeKeyPath)
if err != nil {
return fmt.Errorf("read encrypted webhook authorization: %w", err)
}
authorization := secrets["cicd/forgejo-webhook"]["authorization"]
if authorization == "" {
return errors.New("operational SOPS secrets requires cicd/forgejo-webhook.authorization")
}
generatedDir := filepath.Join(resolved.Git.CloneParent, resolved.Talos.RepoDirName, resolved.Talos.GeneratedDir)
if err := waitForWebhookTargets(generatedDir, resolved); err != nil {
return err
}
webhookURL := "https://tekton." + resolved.Flux.ClusterDomain + "/"
if err := sourceManager.EnsureWebhook(repository, webhookURL, authorization); err != nil {
return fmt.Errorf("register Forgejo webhook: %w", err)
}
return nil
}
func registrationBranch(appName, revision string) string {
return "maidn/register-" + appName + "-" + revision[:12]
}
func platformBranch(appName string) string {
return "maidn/platform-" + appName
}
func ensurePlatformBranch(manager onboardingRepoManager, repository, repositoryURL, appName string) error {
branch := platformBranch(appName)
revision, err := manager.RemoteBranchRevision(repositoryURL, branch)
if err != nil {
return fmt.Errorf("read Forgejo platform branch: %w", err)
}
if revision == "" {
return fmt.Errorf("approved Forgejo platform branch %q must exist before central registration", branch)
}
if err := manager.EnsureProtectedBranch(repository, branch); err != nil {
return fmt.Errorf("protect Forgejo platform branch: %w", err)
}
return nil
}
// publishInitialAppBranches establishes the immutable source baseline before central registration.
func publishInitialAppBranches(manager onboardingRepoManager, sourceDir, targetURL, sourceRevision, targetBranch, productionBranch string) error {
mainRevision, err := manager.RemoteBranchRevision(targetURL, targetBranch)
if err != nil {
return fmt.Errorf("read target base branch: %w", err)
}
createdMain := mainRevision == ""
if mainRevision == "" {
if err := manager.PushRef(sourceDir, targetURL, sourceRevision, targetBranch); err != nil {
return fmt.Errorf("publish source base branch: %w", err)
}
}
if productionRevision, err := manager.RemoteBranchRevision(targetURL, productionBranch); err != nil {
return fmt.Errorf("read target production branch: %w", err)
} else if productionRevision == "" {
if err := manager.PushRef(sourceDir, targetURL, sourceRevision, productionBranch); err != nil {
return fmt.Errorf("create production from source base branch: %w", err)
}
}
if createdMain {
mainRevision, err = manager.RemoteBranchRevision(targetURL, targetBranch)
if err != nil {
return fmt.Errorf("verify target base branch: %w", err)
}
if mainRevision != sourceRevision {
return errors.New("target base branch does not match the validated source ref")
}
}
if productionRevision, err := manager.RemoteBranchRevision(targetURL, productionBranch); err != nil {
return fmt.Errorf("verify target production branch: %w", err)
} else if productionRevision == "" {
return errors.New("target production branch was not created")
}
return nil
}
// RegisterAppInCluster writes only the managed Flux registration for one app.
func RegisterAppInCluster(dir string, cfg config.Config) error {
return registerAppInCluster(dir, cfg)
}
func registerAppInCluster(dir string, cfg config.Config) error {
content, err := renderAppRegistration(cfg)
if err != nil {
return err
}
tektonDir := filepath.Join(dir, "base", "tekton")
rootPath := filepath.Join(tektonDir, "kustomization.yaml")
root, err := readRegularFile(rootPath)
if err != nil {
return fmt.Errorf("read Tekton Kustomization: %w", err)
}
updatedRoot, err := addKustomizationResource(root, "apps")
if err != nil {
return fmt.Errorf("Tekton Kustomization: %w", err)
}
appsDir := filepath.Join(tektonDir, "apps")
if info, statErr := os.Lstat(appsDir); statErr == nil && (info.Mode()&os.ModeSymlink != 0 || !info.IsDir()) {
return errors.New("Tekton apps path must be a directory")
} else if statErr != nil && !os.IsNotExist(statErr) {
return statErr
}
appsPath := filepath.Join(appsDir, "kustomization.yaml")
apps, err := os.ReadFile(appsPath)
if err != nil && !os.IsNotExist(err) {
return err
}
if err == nil {
if info, statErr := os.Lstat(appsPath); statErr != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return errors.New("Tekton apps Kustomization must be a regular file")
}
} else {
apps = []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n")
}
updatedApps, err := addKustomizationResource(apps, cfg.Delivery.AppName+".yaml")
if err != nil {
return fmt.Errorf("Tekton apps Kustomization: %w", err)
}
registrationPath := filepath.Join(appsDir, cfg.Delivery.AppName+".yaml")
if existing, readErr := os.ReadFile(registrationPath); readErr == nil {
info, statErr := os.Lstat(registrationPath)
if statErr != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || !bytes.Equal(bytes.ReplaceAll(existing, []byte("\r\n"), []byte("\n")), content) {
return errors.New("Tekton app registration conflicts with unmanaged content")
}
} else if !os.IsNotExist(readErr) {
return readErr
}
if err := os.MkdirAll(appsDir, 0755); err != nil {
return err
}
if err := os.WriteFile(rootPath, updatedRoot, 0644); err != nil {
return err
}
if err := os.WriteFile(appsPath, updatedApps, 0644); err != nil {
return err
}
return os.WriteFile(registrationPath, content, 0644)
}
func renderAppRegistration(cfg config.Config) ([]byte, error) {
if err := config.ValidateDelivery(cfg); err != nil {
return nil, err
}
secretAccess, err := renderAppSecretAccess(cfg)
if err != nil {
return nil, err
}
delivery, err := renderAppDelivery(cfg)
if err != nil {
return nil, err
}
content := fmt.Sprintf(`apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
name: %s
namespace: flux-system
spec:
interval: 1m
url: %s
secretRef:
name: forgejo-flux-credentials
ref:
branch: %s
`, cfg.Delivery.AppName, cfg.Delivery.AppRepoURL, platformBranch(cfg.Delivery.AppName))
if len(secretAccess) != 0 {
content += "---\n" + string(secretAccess)
}
return append([]byte(content+"---\n"), delivery...), nil
}
// renderAppSecretAccess renders only central OpenBao references, never secret values.
func renderAppSecretAccess(cfg config.Config) ([]byte, error) {
if err := config.ValidateSecretGrants(cfg.SecretGrants); err != nil {
return nil, err
}
var manifests []string
for _, grant := range cfg.SecretGrants {
if grant.Application != cfg.Delivery.AppName || grant.Consumer != "runtime" {
continue
}
name := cfg.Delivery.AppName + "-runtime-" + grant.Environment
manifest := fmt.Sprintf(`apiVersion: v1
kind: ServiceAccount
metadata:
name: maidn-%s
namespace: %s
---
apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
name: openbao-%s
namespace: %s
spec:
provider:
vault:
server: http://openbao.openbao.svc:8200
path: secret
version: v2
auth:
kubernetes:
mountPath: kubernetes
role: maidn-%s
serviceAccountRef:
name: maidn-%s
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: %s
namespace: %s
spec:
refreshInterval: 1h
secretStoreRef:
kind: SecretStore
name: openbao-%s
target:
name: %s
creationPolicy: Owner
data:
`, name, grant.Environment, name, grant.Environment, name, name, name, grant.Environment, name, name)
for _, secret := range grant.Secrets {
manifest += fmt.Sprintf(" - secretKey: %s\n remoteRef:\n key: apps/%s/%s\n property: value\n", secret, grant.Application, secret)
}
manifests = append(manifests, manifest)
}
return []byte(strings.Join(manifests, "---\n")), nil
}
func readRegularFile(path string) ([]byte, error) {
info, err := os.Lstat(path)
if err != nil {
return nil, err
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return nil, errors.New("must be a regular file")
}
return os.ReadFile(path)
}
func addKustomizationResource(content []byte, resource string) ([]byte, error) {
decoder := yaml.NewDecoder(bytes.NewReader(content))
var document yaml.Node
if err := decoder.Decode(&document); err != nil {
return nil, err
}
if err := decoder.Decode(&yaml.Node{}); !errors.Is(err, io.EOF) {
return nil, errors.New("must contain one YAML document")
}
if document.Kind != yaml.DocumentNode || len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode {
return nil, errors.New("must be a Kustomization mapping")
}
root := document.Content[0]
apiVersion, err := yamlMappingValue(root, "apiVersion")
if err != nil || apiVersion == nil || apiVersion.Value != "kustomize.config.k8s.io/v1beta1" {
return nil, errors.New("must be a Kustomization")
}
kind, err := yamlMappingValue(root, "kind")
if err != nil || kind == nil || kind.Value != "Kustomization" {
return nil, errors.New("must be a Kustomization")
}
resources, err := yamlMappingValue(root, "resources")
if err != nil || resources == nil {
return nil, errors.New("must define a resources list")
}
if resources.Kind == yaml.ScalarNode && resources.Tag == "!!null" {
resources.Kind, resources.Tag, resources.Value = yaml.SequenceNode, "!!seq", ""
}
if resources.Kind != yaml.SequenceNode {
return nil, errors.New("must define a resources list")
}
for _, item := range resources.Content {
if item.Kind != yaml.ScalarNode || item.Value == "" {
return nil, errors.New("resources must contain non-empty scalar values")
}
if item.Value == resource {
var rendered bytes.Buffer
encoder := yaml.NewEncoder(&rendered)
encoder.SetIndent(2)
if err := encoder.Encode(&document); err != nil {
return nil, err
}
return rendered.Bytes(), nil
}
}
resources.Content = append(resources.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: resource})
var rendered bytes.Buffer
encoder := yaml.NewEncoder(&rendered)
encoder.SetIndent(2)
if err := encoder.Encode(&document); err != nil {
return nil, err
}
return rendered.Bytes(), nil
}

View file

@ -0,0 +1,287 @@
package bootstrap
import (
"bytes"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
"gopkg.in/yaml.v3"
)
func onboardingGit(t *testing.T, dir string, args ...string) string {
t.Helper()
command := exec.Command("git", args...)
command.Dir = dir
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("git %s: %v: %s", strings.Join(args, " "), err, output)
}
return strings.TrimSpace(string(output))
}
func onboardingConfig() config.Config {
return config.Config{
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "test-org-2"},
Flux: config.FluxConfig{Branch: "main", RepoName: "cluster", ManifestsRepo: "manifests", ClusterDomain: "example.test"},
Delivery: config.DeliveryConfig{
AppName: "web-ui", AppRepoURL: "https://git.example.test/test-org-2/web-ui.git", AppRepoRef: "main", ProductionBranch: "production",
ImageRepository: "registry.example.test/test-org-2/web-ui", BuildStrategy: "static", BuildOutputDirectory: "dist", BuildConfiguration: "production",
WebhookHostname: "tekton.example.test", WebhookPath: "/",
},
}
}
func TestRegisterAppInClusterRendersCentralDeliveryResources(t *testing.T) {
dir := t.TempDir()
tektonDir := filepath.Join(dir, "base", "tekton")
if err := os.MkdirAll(tektonDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tektonDir, "kustomization.yaml"), []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n"), 0644); err != nil {
t.Fatal(err)
}
if err := RegisterAppInCluster(dir, onboardingConfig()); err != nil {
t.Fatal(err)
}
registration, err := os.ReadFile(filepath.Join(tektonDir, "apps", "web-ui.yaml"))
if err != nil || !strings.Contains(string(registration), "branch: maidn/platform-web-ui") || !strings.Contains(string(registration), "secretRef:\n name: forgejo-flux-credentials") || !strings.Contains(string(registration), "kind: Task") || !strings.Contains(string(registration), "name: web-ui-update-manifest") || !strings.Contains(string(registration), "kind: Pipeline") || !strings.Contains(string(registration), "name: web-ui\n") || strings.Contains(string(registration), "maidn/delivery-") || strings.Contains(string(registration), "path: ./.tekton") || strings.Contains(string(registration), "apiVersion: kustomize.toolkit.fluxcd.io") {
t.Fatalf("registration = %q, %v", registration, err)
}
for path, resource := range map[string]string{filepath.Join(tektonDir, "kustomization.yaml"): "apps", filepath.Join(tektonDir, "apps", "kustomization.yaml"): "web-ui.yaml"} {
content, err := os.ReadFile(path)
if err != nil || !strings.Contains(string(content), resource) {
t.Fatalf("Kustomization %s does not include %s: %q, %v", path, resource, content, err)
}
}
decoder := yaml.NewDecoder(bytes.NewReader(registration))
for {
var document yaml.Node
err := decoder.Decode(&document)
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("registration YAML: %v", err)
}
}
if err := os.WriteFile(filepath.Join(tektonDir, "apps", "web-ui.yaml"), bytes.ReplaceAll(registration, []byte("\n"), []byte("\r\n")), 0644); err != nil {
t.Fatal(err)
}
if err := RegisterAppInCluster(dir, onboardingConfig()); err != nil {
t.Fatalf("CRLF registration was rejected: %v", err)
}
if err := os.WriteFile(filepath.Join(tektonDir, "apps", "web-ui.yaml"), []byte("custom: true\n"), 0644); err != nil {
t.Fatal(err)
}
if err := RegisterAppInCluster(dir, onboardingConfig()); err == nil || !strings.Contains(err.Error(), "conflicts") {
t.Fatalf("unmanaged app registration was accepted: %v", err)
}
}
func TestRenderAppRegistrationIncludesOnlyDeclaredRuntimeSecretAccess(t *testing.T) {
cfg := onboardingConfig()
cfg.SecretGrants = []config.SecretGrant{{Application: "web-ui", Consumer: "runtime", Environment: "staging", Secrets: []string{"api-key"}, Shared: []string{"payments"}}}
registration, err := renderAppRegistration(cfg)
if err != nil || !strings.Contains(string(registration), "namespace: staging") || !strings.Contains(string(registration), "key: apps/web-ui/api-key") || strings.Contains(string(registration), "shared/payments") || strings.Contains(string(registration), ".maidn") {
t.Fatalf("secret registration = %q, %v", registration, err)
}
}
func TestPublishInitialAppBranchesCreatesAndPreservesProduction(t *testing.T) {
source := filepath.Join(t.TempDir(), "source")
target := filepath.Join(t.TempDir(), "target.git")
if err := os.Mkdir(source, 0755); err != nil {
t.Fatal(err)
}
onboardingGit(t, source, "init", "-b", "source")
onboardingGit(t, source, "config", "user.name", "Test")
onboardingGit(t, source, "config", "user.email", "test@example.test")
if err := os.WriteFile(filepath.Join(source, "README.md"), []byte("source\n"), 0644); err != nil {
t.Fatal(err)
}
onboardingGit(t, source, "add", "README.md")
onboardingGit(t, source, "commit", "-m", "source")
sourceRevision := onboardingGit(t, source, "rev-parse", "source")
onboardingGit(t, "", "init", "--bare", target)
manager := forgejo.NewRepoManager("https://git.example.test", "", "owner", "", "", "", "main", "")
if err := publishInitialAppBranches(manager, source, target, sourceRevision, "main", "production"); err != nil {
t.Fatal(err)
}
for _, branch := range []string{"main", "production"} {
if got := onboardingGit(t, "", "--git-dir", target, "rev-parse", "refs/heads/"+branch); got != sourceRevision {
t.Fatalf("%s = %s, want source %s", branch, got, sourceRevision)
}
}
preservedTarget := filepath.Join(t.TempDir(), "preserved-target.git")
onboardingGit(t, "", "init", "--bare", preservedTarget)
onboardingGit(t, source, "push", preservedTarget, "source:main")
production := filepath.Join(t.TempDir(), "production")
if err := os.Mkdir(production, 0755); err != nil {
t.Fatal(err)
}
onboardingGit(t, production, "init", "-b", "production")
onboardingGit(t, production, "config", "user.name", "Test")
onboardingGit(t, production, "config", "user.email", "test@example.test")
if err := os.WriteFile(filepath.Join(production, "README.md"), []byte("existing production\n"), 0644); err != nil {
t.Fatal(err)
}
onboardingGit(t, production, "add", "README.md")
onboardingGit(t, production, "commit", "-m", "existing production")
existingProduction := onboardingGit(t, production, "rev-parse", "production")
onboardingGit(t, production, "push", preservedTarget, "production:production")
if err := publishInitialAppBranches(manager, source, preservedTarget, sourceRevision, "main", "production"); err != nil {
t.Fatal(err)
}
if got := onboardingGit(t, "", "--git-dir", preservedTarget, "rev-parse", "refs/heads/production"); got != existingProduction {
t.Fatalf("production = %s, want existing %s", got, existingProduction)
}
if err := os.WriteFile(filepath.Join(source, "README.md"), []byte("updated source\n"), 0644); err != nil {
t.Fatal(err)
}
onboardingGit(t, source, "commit", "-am", "updated source")
if err := publishInitialAppBranches(manager, source, preservedTarget, sourceRevision, "main", "production"); err != nil {
t.Fatal(err)
}
if got := onboardingGit(t, "", "--git-dir", preservedTarget, "rev-parse", "refs/heads/main"); got != sourceRevision {
t.Fatalf("main = %s, want existing %s", got, sourceRevision)
}
}
type onboardingManagerFake struct {
owner string
calls []string
remoteRevisions map[string]string
}
func (m *onboardingManagerFake) EnsureRepository(repo, _ string) (bool, error) {
m.calls = append(m.calls, "ensure "+m.owner+"/"+repo)
return false, nil
}
func (m *onboardingManagerFake) RemoteBranchRevision(targetURL, branch string) (string, error) {
m.calls = append(m.calls, "remote "+targetURL+":"+branch)
if m.remoteRevisions != nil {
return m.remoteRevisions[branch], nil
}
return "existing", nil
}
func (m *onboardingManagerFake) PushRef(_, targetURL, _, targetBranch string) error {
m.calls = append(m.calls, "push "+targetURL+":"+targetBranch)
return nil
}
func (m *onboardingManagerFake) EnsureProtectedBranch(repo, branch string) error {
m.calls = append(m.calls, "protect "+m.owner+"/"+repo+":"+branch)
return nil
}
func (m *onboardingManagerFake) HasOpenPullRequest(repo, branch string) (bool, error) {
m.calls = append(m.calls, "open-pr "+m.owner+"/"+repo+":"+branch)
return false, nil
}
func (m *onboardingManagerFake) MergePullRequest(repo, branch string) error {
m.calls = append(m.calls, "merge-pr "+m.owner+"/"+repo+":"+branch)
return nil
}
func (m *onboardingManagerFake) PublishRepositoryPullRequest(repo, _, branch, _ string, _ func(string) error) (bool, error) {
m.calls = append(m.calls, "register "+m.owner+"/"+repo+":"+branch)
return true, nil
}
func (m *onboardingManagerFake) EnsureWebhook(repo, _, _ string) error {
m.calls = append(m.calls, "webhook "+m.owner+"/"+repo)
return nil
}
func (m *onboardingManagerFake) TriggerWebhookTest(repo, _, branch string) error {
m.calls = append(m.calls, "webhook-test "+m.owner+"/"+repo+":"+branch)
return nil
}
func TestOnboardAppUsesCanonicalSourceAndCentralClusterManagers(t *testing.T) {
source := filepath.Join(t.TempDir(), "source")
if err := os.Mkdir(source, 0755); err != nil {
t.Fatal(err)
}
onboardingGit(t, source, "init", "-b", "main")
onboardingGit(t, source, "config", "user.name", "Test")
onboardingGit(t, source, "config", "user.email", "test@example.test")
if err := os.WriteFile(filepath.Join(source, "README.md"), []byte("source\n"), 0644); err != nil {
t.Fatal(err)
}
onboardingGit(t, source, "add", "README.md")
onboardingGit(t, source, "commit", "-m", "source")
cfg := onboardingConfig()
cfg.Git.Username, cfg.Git.Token = "bot", "test-token"
cfg.Delivery.AppRepoURL = "https://git.example.test/Maidn/maidn-e2e-web.git"
sourceManager := &onboardingManagerFake{owner: "Maidn"}
clusterManager := &onboardingManagerFake{owner: cfg.Git.Owner}
originalManager, originalSecrets := newOnboardingRepoManager, readOperationalSecrets
t.Cleanup(func() {
newOnboardingRepoManager, readOperationalSecrets = originalManager, originalSecrets
})
newOnboardingRepoManager = func(_, _, owner, _, _, _, _, _ string) onboardingRepoManager {
switch owner {
case "Maidn":
return sourceManager
case cfg.Git.Owner:
return clusterManager
default:
t.Fatalf("unexpected onboarding manager owner %q", owner)
return nil
}
}
readOperationalSecrets = func(string, string) (map[string]map[string]string, error) {
return nil, errors.New("stop after registration")
}
err := OnboardApp(cfg, source)
if err == nil || !strings.Contains(err.Error(), "read encrypted webhook authorization") {
t.Fatalf("OnboardApp() = %v", err)
}
sourceCalls := strings.Join(sourceManager.calls, "\n")
for _, want := range []string{
"ensure Maidn/maidn-e2e-web",
"remote https://git.example.test/Maidn/maidn-e2e-web.git:main",
"remote https://git.example.test/Maidn/maidn-e2e-web.git:production",
"protect Maidn/maidn-e2e-web:production",
"remote https://git.example.test/Maidn/maidn-e2e-web.git:maidn/platform-web-ui",
"protect Maidn/maidn-e2e-web:maidn/platform-web-ui",
} {
if !strings.Contains(sourceCalls, want) {
t.Fatalf("source manager calls = %q, missing %q", sourceCalls, want)
}
}
if strings.Contains(sourceCalls, "delivery") || strings.Contains(sourceCalls, "ensure-pr") || strings.Contains(sourceCalls, "merge-pr") {
t.Fatalf("source manager published a delivery change: %q", sourceCalls)
}
sourceRevision := onboardingGit(t, source, "rev-parse", "main")
if got := strings.Join(clusterManager.calls, "\n"); got != "register test-org-2/cluster:maidn/register-web-ui-"+sourceRevision[:12] {
t.Fatalf("cluster manager calls = %q", got)
}
}
func TestEnsurePlatformBranchRequiresExistingBranch(t *testing.T) {
manager := &onboardingManagerFake{owner: "Maidn", remoteRevisions: map[string]string{}}
err := ensurePlatformBranch(manager, "maidn-e2e-web", "https://git.example.test/Maidn/maidn-e2e-web.git", "web-ui")
if err == nil || !strings.Contains(err.Error(), "maidn/platform-web-ui") {
t.Fatalf("ensurePlatformBranch() error = %v", err)
}
if strings.Contains(strings.Join(manager.calls, "\n"), "protect") {
t.Fatalf("missing platform branch was protected: %q", manager.calls)
}
}

View file

@ -12,9 +12,7 @@ import (
func TestEnsureTemplateRevisionsLocksFirstCheckout(t *testing.T) {
workspace, cfg, git := templateRevisionTestConfig(t)
original := runGit
runGit = git.run
t.Cleanup(func() { runGit = original })
useTemplateRevisionGit(t, git)
if err := ensureTemplateRevisions(workspace, cfg); err != nil {
t.Fatal(err)
@ -37,32 +35,69 @@ func TestEnsureTemplateRevisionsLocksFirstCheckout(t *testing.T) {
func TestEnsureTemplateRevisionsReusesLockedCommitAfterBranchDrift(t *testing.T) {
workspace, cfg, git := templateRevisionTestConfig(t)
original := runGit
runGit = git.run
t.Cleanup(func() { runGit = original })
useTemplateRevisionGit(t, git)
if err := ensureTemplateRevisions(workspace, cfg); err != nil {
t.Fatal(err)
}
locked := git.commits["cicd"]
git.commits["cicd"] = strings.Repeat("d", 40)
locked := map[string]string{}
for name, commit := range git.commits {
locked[name] = commit
}
git.commits = map[string]string{"cicd": strings.Repeat("d", 40), "manifests": strings.Repeat("e", 40), "talos": strings.Repeat("f", 40)}
git.resetCalls()
if err := ensureTemplateRevisions(workspace, cfg); err != nil {
t.Fatal(err)
}
if git.fetched["cicd"] != locked || git.checkedOut["cicd"] != locked {
t.Fatalf("branch drift changed locked CICD revision: fetched %q, checked out %q", git.fetched["cicd"], git.checkedOut["cicd"])
lock, err := readTemplateRevisionLock(filepath.Join(workspace, "maidn-template-revisions.yaml"))
if err != nil {
t.Fatal(err)
}
for name, commit := range locked {
if git.fetched[name] != commit || git.checkedOut[name] != commit {
t.Fatalf("branch drift changed locked %s revision: fetched %q, checked out %q", name, git.fetched[name], git.checkedOut[name])
}
}
if lock.CICD.Commit != locked["cicd"] || lock.Manifests.Commit != locked["manifests"] || lock.Talos.Commit != locked["talos"] {
t.Fatalf("normal bootstrap rewrote template lock: %#v", lock)
}
if strings.Contains(git.commands(), "fetch origin main") {
t.Fatal("later run fetched a mutable branch instead of the lock commit")
}
}
func TestRefreshTemplateRevisionsUpdatesExistingLockToConfiguredHeads(t *testing.T) {
workspace, cfg, git := templateRevisionTestConfig(t)
useTemplateRevisionGit(t, git)
if err := ensureTemplateRevisions(workspace, cfg); err != nil {
t.Fatal(err)
}
git.commits = map[string]string{"cicd": strings.Repeat("d", 40), "manifests": strings.Repeat("e", 40), "talos": strings.Repeat("f", 40)}
git.resetCalls()
if err := refreshTemplateRevisions(workspace, cfg); err != nil {
t.Fatal(err)
}
lock, err := readTemplateRevisionLock(filepath.Join(workspace, "maidn-template-revisions.yaml"))
if err != nil {
t.Fatal(err)
}
for name, commit := range git.commits {
if git.fetched[name] != commit || git.checkedOut[name] != commit {
t.Fatalf("refresh did not use configured %s branch head: fetched %q, checked out %q", name, git.fetched[name], git.checkedOut[name])
}
}
if lock.CICD.Commit != git.commits["cicd"] || lock.Manifests.Commit != git.commits["manifests"] || lock.Talos.Commit != git.commits["talos"] {
t.Fatalf("refresh did not update template lock: %#v", lock)
}
if !strings.Contains(git.commands(), "fetch origin main") || strings.Contains(git.commands(), "template-password") {
t.Fatal("refresh did not safely fetch configured branch heads")
}
}
func TestEnsureTemplateRevisionsRejectsChangedRefWithoutGit(t *testing.T) {
workspace, cfg, git := templateRevisionTestConfig(t)
original := runGit
runGit = git.run
t.Cleanup(func() { runGit = original })
useTemplateRevisionGit(t, git)
if err := ensureTemplateRevisions(workspace, cfg); err != nil {
t.Fatal(err)
@ -80,9 +115,7 @@ func TestEnsureTemplateRevisionsRejectsChangedRefWithoutGit(t *testing.T) {
func TestEnsureTemplateRevisionsHidesSourceWhenLockedCommitIsUnavailable(t *testing.T) {
workspace, cfg, git := templateRevisionTestConfig(t)
original := runGit
runGit = git.run
t.Cleanup(func() { runGit = original })
useTemplateRevisionGit(t, git)
if err := ensureTemplateRevisions(workspace, cfg); err != nil {
t.Fatal(err)
@ -94,6 +127,71 @@ func TestEnsureTemplateRevisionsHidesSourceWhenLockedCommitIsUnavailable(t *test
}
}
func TestEnsureTemplateRevisionsAllowsOnlyGeneratedTalosChanges(t *testing.T) {
workspace, cfg, git := templateRevisionTestConfig(t)
useTemplateRevisionGit(t, git)
if err := ensureTemplateRevisions(workspace, cfg); err != nil {
t.Fatal(err)
}
git.dirty["talos"] = []string{" M generated/talsecret.yaml", "?? generated/clusterconfig/talosconfig"}
if err := ensureTemplateRevisions(workspace, cfg); err != nil {
t.Fatalf("generated Talos changes blocked reconciliation: %v", err)
}
git.dirty["talos"] = []string{" M terraform/main.tf"}
if err := ensureTemplateRevisions(workspace, cfg); err == nil {
t.Fatal("Talos changes outside generated directory were accepted")
}
if !hasUnexpectedTemplateChanges([]byte(" M generated/../terraform/main.tf\x00"), "generated") {
t.Fatal("Talos changes escaping the generated directory were accepted")
}
}
func TestTemplateGitRunnerUsesTemporaryAskPassForPrivateSource(t *testing.T) {
original := runGitEnvironment
t.Cleanup(func() { runGitEnvironment = original })
var environment []string
var command string
runGitEnvironment = func(_ string, env []string, args ...string) ([]byte, error) {
environment = env
command = strings.Join(args, " ")
return nil, nil
}
source := "https://reader:template-password@git.example.test/templates/cicd.git"
repository := config.RedactURL(source)
run, cleanup, err := templateGitRunner(config.Config{}, source, repository)
if err != nil {
t.Fatal(err)
}
defer cleanup()
if _, err := run("", "clone", repository, "checkout"); err != nil {
t.Fatal(err)
}
var askPass string
for _, item := range environment {
if strings.HasPrefix(item, "GIT_ASKPASS=") {
askPass = strings.TrimPrefix(item, "GIT_ASKPASS=")
}
}
if askPass == "" || strings.Contains(command, "template-password") || strings.Contains(command, "reader:") {
t.Fatalf("private template checkout did not isolate credentials: command=%q", command)
}
if _, err := os.Stat(askPass); err != nil {
t.Fatalf("temporary askpass script was not created: %v", err)
}
}
func TestEnsureTemplateRevisionsRejectsCredentialBearingExistingOrigin(t *testing.T) {
workspace, cfg, git := templateRevisionTestConfig(t)
useTemplateRevisionGit(t, git)
if err := ensureTemplateRevisions(workspace, cfg); err != nil {
t.Fatal(err)
}
git.origins[filepath.Join(workspace, "maidn-cicd-cluster-template")] = cfg.Templates.CICDRepoURL
if err := ensureTemplateRevisions(workspace, cfg); err == nil || strings.Contains(err.Error(), "template-password") {
t.Fatalf("credential-bearing checkout origin was accepted or exposed: %v", err)
}
}
func templateRevisionTestConfig(t *testing.T) (string, config.Config, *fakeTemplateGit) {
t.Helper()
workspace := t.TempDir()
@ -110,11 +208,12 @@ func templateRevisionTestConfig(t *testing.T) (string, config.Config, *fakeTempl
origins: map[string]string{},
fetched: map[string]string{},
checkedOut: map[string]string{},
dirty: map[string][]string{},
}
return workspace, config.Config{
WorkspaceDir: workspace,
Git: config.GitConfig{CloneParent: cloneParent},
Talos: config.TalosConfig{RepoDirName: "talos"},
Talos: config.TalosConfig{RepoDirName: "talos", GeneratedDir: "generated"},
Templates: config.TemplateConfig{
CICDRepoURL: "https://reader:template-password@git.example.test/templates/cicd.git",
CICDRepoRef: "main",
@ -126,11 +225,24 @@ func templateRevisionTestConfig(t *testing.T) (string, config.Config, *fakeTempl
}, git
}
func useTemplateRevisionGit(t *testing.T, git *fakeTemplateGit) {
t.Helper()
original := runGit
runGit = git.run
originalEnvironment := runGitEnvironment
runGitEnvironment = func(dir string, _ []string, args ...string) ([]byte, error) { return git.run(dir, args...) }
t.Cleanup(func() {
runGit = original
runGitEnvironment = originalEnvironment
})
}
type fakeTemplateGit struct {
commits map[string]string
origins map[string]string
fetched map[string]string
checkedOut map[string]string
dirty map[string][]string
calls []string
failFetch bool
}
@ -151,6 +263,9 @@ func (git *fakeTemplateGit) run(dir string, args ...string) ([]byte, error) {
case "remote":
return []byte(git.origins[dir] + "\n"), nil
case "status":
if records := git.dirty[git.templateName(dir)]; len(records) != 0 {
return []byte(strings.Join(records, "\x00") + "\x00"), nil
}
return nil, nil
case "fetch":
if git.failFetch {

View file

@ -0,0 +1,122 @@
package cloudflare
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
const apiURL = "https://api.cloudflare.com/client/v4"
// API is the narrow Cloudflare DNS boundary used by route commands.
type API interface {
EnsureCNAME(context.Context, string, string, string) error
DeleteCNAME(context.Context, string, string, string) error
}
type dnsRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Content string `json:"content"`
Proxied bool `json:"proxied"`
}
type Client struct {
baseURL string
httpClient *http.Client
token string
}
func NewClient(token string) *Client {
return &Client{baseURL: apiURL, httpClient: &http.Client{Timeout: 15 * time.Second}, token: token}
}
func (c *Client) EnsureCNAME(ctx context.Context, zoneID, hostname, tunnelID string) error {
records, err := c.cnameRecords(ctx, zoneID, hostname)
if err != nil {
return err
}
target := tunnelTarget(tunnelID)
if len(records) == 0 {
body, err := json.Marshal(struct {
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content"`
Proxied bool `json:"proxied"`
}{Type: "CNAME", Name: hostname, Content: target, Proxied: true})
if err != nil {
return errors.New("encode Cloudflare DNS record")
}
var created dnsRecord
return c.request(ctx, http.MethodPost, "/zones/"+url.PathEscape(zoneID)+"/dns_records", body, &created)
}
if len(records) != 1 || !strings.EqualFold(records[0].Name, hostname) || !strings.EqualFold(records[0].Content, target) || !records[0].Proxied {
return errors.New("existing Cloudflare DNS record does not match the managed tunnel route")
}
return nil
}
func (c *Client) DeleteCNAME(ctx context.Context, zoneID, hostname, tunnelID string) error {
records, err := c.cnameRecords(ctx, zoneID, hostname)
if err != nil {
return err
}
if len(records) == 0 {
return nil
}
target := tunnelTarget(tunnelID)
if len(records) != 1 || !strings.EqualFold(records[0].Name, hostname) || !strings.EqualFold(records[0].Content, target) || !records[0].Proxied {
return errors.New("existing Cloudflare DNS record does not match the managed tunnel route")
}
var deleted dnsRecord
return c.request(ctx, http.MethodDelete, "/zones/"+url.PathEscape(zoneID)+"/dns_records/"+url.PathEscape(records[0].ID), nil, &deleted)
}
func (c *Client) cnameRecords(ctx context.Context, zoneID, hostname string) ([]dnsRecord, error) {
var records []dnsRecord
path := "/zones/" + url.PathEscape(zoneID) + "/dns_records?" + url.Values{"type": {"CNAME"}, "name": {hostname}}.Encode()
err := c.request(ctx, http.MethodGet, path, nil, &records)
return records, err
}
func (c *Client) request(ctx context.Context, method, path string, body []byte, result any) error {
request, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(body))
if err != nil {
return errors.New("create Cloudflare API request")
}
request.Header.Set("Authorization", "Bearer "+c.token)
request.Header.Set("Content-Type", "application/json")
client := c.httpClient
if client == nil {
client = &http.Client{Timeout: 15 * time.Second}
}
response, err := client.Do(request)
if err != nil {
return errors.New("call Cloudflare API")
}
defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("Cloudflare API returned HTTP %d", response.StatusCode)
}
var envelope struct {
Success bool `json:"success"`
Result json.RawMessage `json:"result"`
}
if err := json.NewDecoder(response.Body).Decode(&envelope); err != nil || !envelope.Success {
return errors.New("Cloudflare API returned an invalid response")
}
if err := json.Unmarshal(envelope.Result, result); err != nil {
return errors.New("Cloudflare API returned an invalid result")
}
return nil
}
func tunnelTarget(tunnelID string) string {
return tunnelID + ".cfargotunnel.com"
}

View file

@ -0,0 +1,93 @@
package cloudflare
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestClientCreatesTunnelCNAME(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requests++
if request.Header.Get("Authorization") != "Bearer test-api-token" {
t.Error("Cloudflare request did not use the API token header")
writer.WriteHeader(http.StatusUnauthorized)
return
}
switch request.Method + " " + request.URL.Path {
case "GET /zones/zone-id/dns_records":
if request.URL.Query().Get("type") != "CNAME" || request.URL.Query().Get("name") != "app.example.test" {
t.Error("DNS lookup was invalid")
}
_ = json.NewEncoder(writer).Encode(map[string]any{"success": true, "result": []any{}})
case "POST /zones/zone-id/dns_records":
var body map[string]any
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body["content"] != "tunnel-id.cfargotunnel.com" || body["proxied"] != true {
t.Error("DNS create request was invalid")
}
_ = json.NewEncoder(writer).Encode(map[string]any{"success": true, "result": map[string]string{"id": "record-id"}})
default:
t.Errorf("unexpected request %s %s", request.Method, request.URL.Path)
writer.WriteHeader(http.StatusMethodNotAllowed)
}
}))
defer server.Close()
client := NewClient("test-api-token")
client.baseURL = server.URL
client.httpClient = server.Client()
if err := client.EnsureCNAME(context.Background(), "zone-id", "app.example.test", "tunnel-id"); err != nil {
t.Fatal(err)
}
if requests != 2 {
t.Fatalf("expected two Cloudflare requests, got %d", requests)
}
}
func TestClientFailureDoesNotRevealToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
client := NewClient("test-api-token")
client.baseURL = server.URL
client.httpClient = server.Client()
err := client.EnsureCNAME(context.Background(), "zone-id", "app.example.test", "tunnel-id")
if err == nil || strings.Contains(err.Error(), "test-api-token") {
t.Fatal("Cloudflare API failure exposed the token")
}
}
func TestClientDeletesOnlyMatchingTunnelCNAME(t *testing.T) {
deleted := false
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Header.Get("Authorization") != "Bearer test-api-token" {
t.Error("Cloudflare request did not use the API token header")
writer.WriteHeader(http.StatusUnauthorized)
return
}
switch request.Method + " " + request.URL.Path {
case "GET /zones/zone-id/dns_records":
_ = json.NewEncoder(writer).Encode(map[string]any{"success": true, "result": []map[string]any{{"id": "record-id", "name": "app.example.test", "content": "tunnel-id.cfargotunnel.com", "proxied": true}}})
case "DELETE /zones/zone-id/dns_records/record-id":
deleted = true
_ = json.NewEncoder(writer).Encode(map[string]any{"success": true, "result": map[string]string{"id": "record-id"}})
default:
t.Errorf("unexpected request %s %s", request.Method, request.URL.Path)
writer.WriteHeader(http.StatusMethodNotAllowed)
}
}))
defer server.Close()
client := NewClient("test-api-token")
client.baseURL = server.URL
client.httpClient = server.Client()
if err := client.DeleteCNAME(context.Background(), "zone-id", "app.example.test", "tunnel-id"); err != nil || !deleted {
t.Fatal("matching managed CNAME was not deleted")
}
}

View file

@ -0,0 +1,225 @@
package cloudflare
import (
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/url"
"os"
"regexp"
"strings"
"gopkg.in/yaml.v3"
)
const (
credentialsKey = "credentials"
configKey = "config"
legacyCredentialsKey = "credentials.json"
legacyConfigKey = "config.yml"
credentialsFile = "/etc/cloudflared/credentials.json"
)
var hostnamePattern = regexp.MustCompile(`(?i)^(?:\*\.)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$`)
type Credentials struct {
AccountTag string `json:"AccountTag"`
TunnelSecret string `json:"TunnelSecret"`
TunnelID string `json:"TunnelID"`
}
type Route struct {
Hostname string `yaml:"hostname,omitempty"`
Service string `yaml:"service"`
}
type Config struct {
Tunnel string `yaml:"tunnel"`
CredentialsFile string `yaml:"credentials-file"`
Ingress []Route `yaml:"ingress"`
}
type StoredTunnel struct {
Credentials Credentials
Config Config
}
func NewConfig(tunnelID string) Config {
return Config{Tunnel: tunnelID, CredentialsFile: credentialsFile, Ingress: []Route{{Service: "http_status:404"}}}
}
func IsLegacyRunTokenState(values map[string]string) bool {
return len(values) == 1 && strings.TrimSpace(values["token"]) != ""
}
func ReadCredentialsFile(path string) (Credentials, error) {
contents, err := os.ReadFile(path)
if err != nil {
return Credentials{}, errors.New("invalid Cloudflare credentials file")
}
if credentials, err := parseCredentials(contents); err == nil {
return credentials, nil
}
credentials, err := credentialsFromSecret(contents)
if err != nil {
return Credentials{}, errors.New("invalid Cloudflare credentials file")
}
return credentials, nil
}
func credentialsFromSecret(contents []byte) (Credentials, error) {
var secret struct {
APIVersion string `yaml:"apiVersion"`
Kind string `yaml:"kind"`
StringData map[string]string `yaml:"stringData"`
Data map[string]string `yaml:"data"`
}
decoder := yaml.NewDecoder(strings.NewReader(string(contents)))
if err := decoder.Decode(&secret); err != nil || decoder.Decode(&struct{}{}) != io.EOF || secret.APIVersion != "v1" || secret.Kind != "Secret" {
return Credentials{}, errors.New("invalid Secret")
}
plaintext, inStringData := secret.StringData[legacyCredentialsKey]
encoded, inData := secret.Data[legacyCredentialsKey]
if inStringData == inData {
return Credentials{}, errors.New("missing Secret credentials")
}
if inStringData {
return parseCredentials([]byte(plaintext))
}
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return Credentials{}, errors.New("invalid Secret credentials")
}
return parseCredentials(decoded)
}
func NewRoute(hostname, service string) (Route, error) {
hostname = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(hostname), "."))
if len(hostname) > 253 || !hostnamePattern.MatchString(hostname) {
return Route{}, errors.New("hostname must be a valid public DNS hostname")
}
parsed, err := url.ParseRequestURI(service)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" {
return Route{}, errors.New("service must be an absolute http or https URL")
}
return Route{Hostname: hostname, Service: service}, nil
}
func ParseStoredTunnel(values map[string]string) (StoredTunnel, bool, error) {
stored, present, _, err := ParseStoredTunnelState(values)
return stored, present, err
}
// ParseStoredTunnelState reports whether state uses the legacy dotted keys.
func ParseStoredTunnelState(values map[string]string) (StoredTunnel, bool, bool, error) {
credentialsJSON, hasCredentials := values[credentialsKey]
configYAML, hasConfig := values[configKey]
legacyCredentialsJSON, hasLegacyCredentials := values[legacyCredentialsKey]
legacyConfigYAML, hasLegacyConfig := values[legacyConfigKey]
if hasCredentials || hasConfig {
if !hasCredentials || !hasConfig || len(values) != 2 {
return StoredTunnel{}, false, false, errors.New("Cloudflare tunnel operational state is ambiguous; expected only credentials and config")
}
return parseStoredTunnel(credentialsJSON, configYAML, false)
}
if hasLegacyCredentials || hasLegacyConfig {
if !hasLegacyCredentials || !hasLegacyConfig || len(values) != 2 {
return StoredTunnel{}, false, false, errors.New("Cloudflare tunnel operational state is ambiguous; expected only credentials and config")
}
return parseStoredTunnel(legacyCredentialsJSON, legacyConfigYAML, true)
}
if len(values) == 0 {
return StoredTunnel{}, false, false, nil
}
return StoredTunnel{}, false, false, errors.New("Cloudflare tunnel operational state is ambiguous; expected credentials and config")
}
func parseStoredTunnel(credentialsJSON, configYAML string, legacy bool) (StoredTunnel, bool, bool, error) {
credentials, err := parseCredentials([]byte(credentialsJSON))
if err != nil {
return StoredTunnel{}, false, false, err
}
var config Config
yamlDecoder := yaml.NewDecoder(strings.NewReader(configYAML))
yamlDecoder.KnownFields(true)
if err := yamlDecoder.Decode(&config); err != nil || yamlDecoder.Decode(&struct{}{}) != io.EOF || !validConfig(config) || config.Tunnel != credentials.TunnelID {
return StoredTunnel{}, false, false, errors.New("Cloudflare tunnel config is invalid")
}
return StoredTunnel{Credentials: credentials, Config: config}, true, legacy, nil
}
func parseCredentials(contents []byte) (Credentials, error) {
var credentials Credentials
decoder := json.NewDecoder(strings.NewReader(string(contents)))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&credentials); err != nil || decoder.Decode(&struct{}{}) != io.EOF || credentials.AccountTag == "" || credentials.TunnelSecret == "" || credentials.TunnelID == "" {
return Credentials{}, errors.New("Cloudflare tunnel credentials are invalid")
}
return credentials, nil
}
func (s StoredTunnel) Values() (map[string]string, error) {
credentials, err := json.Marshal(s.Credentials)
if err != nil {
return nil, errors.New("encode Cloudflare tunnel credentials")
}
config, err := yaml.Marshal(s.Config)
if err != nil {
return nil, errors.New("encode Cloudflare tunnel config")
}
return map[string]string{credentialsKey: string(credentials), configKey: string(config)}, nil
}
func (c *Config) AddRoute(route Route) (bool, error) {
if !validConfig(*c) {
return false, errors.New("Cloudflare tunnel config is invalid")
}
for _, existing := range c.Ingress[:len(c.Ingress)-1] {
if existing.Hostname == route.Hostname {
if existing.Service == route.Service {
return false, nil
}
return false, errors.New("hostname already has a different Cloudflare tunnel route")
}
}
terminal := c.Ingress[len(c.Ingress)-1]
c.Ingress = append(c.Ingress[:len(c.Ingress)-1], route, terminal)
return true, nil
}
func (c *Config) RemoveRoute(route Route) (bool, error) {
if !validConfig(*c) {
return false, errors.New("Cloudflare tunnel config is invalid")
}
for index, existing := range c.Ingress[:len(c.Ingress)-1] {
if existing.Hostname != route.Hostname {
continue
}
if existing.Service != route.Service {
return false, errors.New("hostname does not match the requested Cloudflare tunnel service")
}
c.Ingress = append(c.Ingress[:index], c.Ingress[index+1:]...)
return true, nil
}
return false, nil
}
func validConfig(config Config) bool {
if config.Tunnel == "" || config.CredentialsFile != credentialsFile || len(config.Ingress) == 0 {
return false
}
last := len(config.Ingress) - 1
if config.Ingress[last].Hostname != "" || config.Ingress[last].Service != "http_status:404" {
return false
}
seen := map[string]bool{}
for _, route := range config.Ingress[:last] {
normalized, err := NewRoute(route.Hostname, route.Service)
if err != nil || normalized != route || seen[route.Hostname] {
return false
}
seen[route.Hostname] = true
}
return true
}

View file

@ -0,0 +1,93 @@
package cloudflare
import (
"encoding/base64"
"os"
"path/filepath"
"testing"
)
func TestStoredTunnelStartsWithoutPublicIngress(t *testing.T) {
stored := StoredTunnel{Credentials: Credentials{AccountTag: "account", TunnelSecret: "secret", TunnelID: "tunnel"}, Config: NewConfig("tunnel")}
values, err := stored.Values()
if err != nil {
t.Fatal(err)
}
if _, hasCredentials := values["credentials"]; !hasCredentials {
t.Fatal("new tunnel state did not use a simple credentials key")
}
parsed, present, err := ParseStoredTunnel(values)
if err != nil || !present || len(parsed.Config.Ingress) != 1 || parsed.Config.Ingress[0].Service != "http_status:404" {
t.Fatal("new tunnel state must contain only the terminal ingress rule")
}
legacy := map[string]string{"credentials.json": values["credentials"], "config.yml": values["config"]}
_, present, isLegacy, err := ParseStoredTunnelState(legacy)
if err != nil || !present || !isLegacy {
t.Fatal("valid dotted tunnel state was not recognized for migration")
}
}
func TestStoredTunnelRejectsLegacyOrPartialState(t *testing.T) {
for _, values := range []map[string]string{
{"token": "legacy-run-token"},
{"credentials.json": "{}"},
} {
if _, _, err := ParseStoredTunnel(values); err == nil {
t.Fatal("ambiguous tunnel state was accepted")
}
}
}
func TestReadCredentialsFileRequiresKnownFields(t *testing.T) {
path := filepath.Join(t.TempDir(), "credentials.json")
raw := `{"AccountTag":"account","TunnelSecret":"secret","TunnelID":"tunnel"}`
for _, contents := range []string{
raw,
"apiVersion: v1\nkind: Secret\nstringData:\n credentials.json: |\n " + raw + "\n",
"apiVersion: v1\nkind: Secret\ndata:\n credentials.json: " + base64.StdEncoding.EncodeToString([]byte(raw)) + "\n",
} {
if err := os.WriteFile(path, []byte(contents), 0600); err != nil {
t.Fatal(err)
}
credentials, err := ReadCredentialsFile(path)
if err != nil || credentials.TunnelID != "tunnel" {
t.Fatal("supported credential file was not accepted")
}
}
if err := os.WriteFile(path, []byte(`{"AccountTag":"account","TunnelSecret":"secret","TunnelID":"tunnel","extra":"value"}`), 0600); err != nil {
t.Fatal(err)
}
if _, err := ReadCredentialsFile(path); err == nil || err.Error() != "invalid Cloudflare credentials file" {
t.Fatal("credential file with unknown fields did not return a safe error")
}
if err := os.WriteFile(path, []byte("apiVersion: v1\nkind: Secret\ndata: {}\n"), 0600); err != nil {
t.Fatal(err)
}
if _, err := ReadCredentialsFile(path); err == nil || err.Error() != "invalid Cloudflare credentials file" {
t.Fatal("Secret without credentials did not return a safe error")
}
}
func TestRouteConfigAddAndRemoveAreIdempotent(t *testing.T) {
config := NewConfig("tunnel")
route, err := NewRoute("app.example.test", "http://service.default.svc:8080")
if err != nil {
t.Fatal(err)
}
changed, err := config.AddRoute(route)
if err != nil || !changed || len(config.Ingress) != 2 {
t.Fatal("route was not added before the terminal ingress")
}
changed, err = config.AddRoute(route)
if err != nil || changed {
t.Fatal("matching route add was not idempotent")
}
changed, err = config.RemoveRoute(route)
if err != nil || !changed || len(config.Ingress) != 1 || config.Ingress[0].Service != "http_status:404" {
t.Fatal("route removal did not preserve the terminal ingress")
}
changed, err = config.RemoveRoute(route)
if err != nil || changed {
t.Fatal("matching route remove was not idempotent")
}
}

View file

@ -66,6 +66,7 @@ func WriteRedacted(path string, cfg Config) error {
redacted.Templates.TektonCatalogRepoURL = RedactURL(redacted.Templates.TektonCatalogRepoURL)
redacted.Delivery.AppRepoURL = RedactURL(redacted.Delivery.AppRepoURL)
redacted.Delivery.ImageRepository = RedactURL(redacted.Delivery.ImageRepository)
redacted.Delivery.TektonDashboardURL = RedactURL(redacted.Delivery.TektonDashboardURL)
data, err := yaml.Marshal(redacted)
if err != nil {
return err
@ -100,7 +101,123 @@ func Resolve(cfg Config) (Config, error) {
return cfg, Validate(cfg)
}
// ResolveDelivery applies delivery defaults for an explicitly requested app operation.
func ResolveDelivery(cfg Config) (Config, error) {
applyDeliveryDefaults(&cfg)
return cfg, ValidateDelivery(cfg)
}
// ResolveFreshBootstrap validates the scoped contract needed before a new
// Forgejo organization can be scaffolded. It deliberately does not validate
// recovery, SOPS, Proxmox, or Talos state.
func ResolveFreshBootstrap(cfg Config, organization string, enableDelivery bool) (Config, error) {
if !validRepositoryPart(organization) {
return cfg, errors.New("organization must be a Forgejo owner name")
}
if cfg.Git.Owner != "" && cfg.Git.Owner != organization {
return cfg, errors.New("git owner must match --organization")
}
cfg.Git.Owner = organization
if cfg.WorkspaceDir == "" || !filepath.IsAbs(cfg.WorkspaceDir) {
return cfg, errors.New("workspaceDir must be an absolute isolated workspace path")
}
if cfg.Git.CloneParent == "" || !filepath.IsAbs(cfg.Git.CloneParent) || !isChildPath(cfg.WorkspaceDir, cfg.Git.CloneParent) {
return cfg, errors.New("git cloneParent must be an absolute child of workspaceDir")
}
if cfg.Git.Provider != "forgejo" || cfg.Git.BaseURL == "" || cfg.Git.Username == "" || cfg.Git.Token == "" {
return cfg, errors.New("git provider, baseUrl, username, and token are required for fresh bootstrap")
}
if err := validateForgejoOrigin(cfg.Git.BaseURL); err != nil {
return cfg, err
}
if !validRepositoryPart(cfg.Flux.RepoName) || !validRepositoryPart(cfg.Flux.ManifestsRepo) || !validRepositoryPart(cfg.Flux.TektonCatalogRepo) || cfg.Flux.Branch == "" || cfg.Flux.ClusterPath == "" || cfg.Flux.ClusterDomain == "" {
return cfg, errors.New("flux repoName, manifestsRepo, tektonCatalogRepo, branch, clusterPath, and clusterDomain are required")
}
if cfg.Talos.RepoDirName == "" || filepath.Base(cfg.Talos.RepoDirName) != cfg.Talos.RepoDirName || cfg.Talos.GeneratedDir == "" {
return cfg, errors.New("talos repoDirName and generatedDir are required for template locking")
}
for _, source := range []struct{ URL, Ref string }{
{cfg.Templates.TalosRepoURL, cfg.Templates.TalosRepoRef},
{cfg.Templates.CICDRepoURL, cfg.Templates.CICDRepoRef},
{cfg.Templates.ManifestsRepoURL, cfg.Templates.ManifestsRepoRef},
{cfg.Templates.TektonCatalogRepoURL, cfg.Templates.TektonCatalogRepoRef},
} {
if source.URL == "" || source.Ref == "" || RedactURL(source.URL) != source.URL {
return cfg, errors.New("template repository URLs and refs must be explicit and credential-free")
}
if err := validateRepositoryURL(source.URL); err != nil {
return cfg, err
}
}
return cfg, nil
}
// ResolveAppOnboarding validates only the source-owned delivery contract.
func ResolveAppOnboarding(cfg Config) (Config, error) {
if cfg.Git.Owner == "" || cfg.Git.Username == "" || cfg.Git.Token == "" || cfg.Flux.RepoName == "" || cfg.Flux.ManifestsRepo == "" || cfg.Flux.Branch == "" || cfg.Flux.ClusterDomain == "" {
return cfg, errors.New("git owner, username, token, flux repoName, manifestsRepo, branch, and clusterDomain are required for app onboarding")
}
if err := validateForgejoOrigin(cfg.Git.BaseURL); err != nil {
return cfg, err
}
resolved, err := ResolveDelivery(cfg)
if err != nil {
return cfg, err
}
owner, repository, err := deliveryRepositoryOwner(resolved.Delivery.AppRepoURL)
if err != nil {
return cfg, err
}
if owner != resolved.Git.Owner && (owner != "Maidn" || !strings.HasPrefix(repository, "maidn-e2e-")) {
return cfg, errors.New("delivery appRepoUrl owner must match git owner or identify a canonical Maidn E2E fixture")
}
if resolved.Delivery.ProductionBranch != "production" {
return cfg, errors.New("delivery productionBranch must be literal production for app onboarding")
}
return resolved, nil
}
func deliveryRepositoryOwner(value string) (string, string, error) {
parsed, err := url.Parse(value)
if err != nil {
return "", "", errors.New("delivery appRepoUrl must identify one Forgejo owner/repository.git")
}
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
if len(parts) != 2 || !strings.HasSuffix(parts[1], ".git") || !validRepositoryPart(parts[0]) || !validRepositoryPart(strings.TrimSuffix(parts[1], ".git")) {
return "", "", errors.New("delivery appRepoUrl must identify one Forgejo owner/repository.git")
}
return parts[0], strings.TrimSuffix(parts[1], ".git"), nil
}
func validRepositoryPart(value string) bool {
return value != "" && !strings.Contains(value, "..") && regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`).MatchString(value)
}
func isChildPath(parent, child string) bool {
relative, err := filepath.Rel(filepath.Clean(parent), filepath.Clean(child))
return err == nil && relative != "." && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative)
}
func validateForgejoOrigin(value string) error {
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawPath != "" || parsed.RawQuery != "" || parsed.Fragment != "" || strings.Trim(parsed.Path, "/") != "" {
return errors.New("git baseUrl must be a credential-free HTTPS origin")
}
return nil
}
func validateRepositoryURL(value string) error {
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || strings.Trim(parsed.Path, "/") == "" {
return errors.New("template repository URL must be a credential-free HTTPS repository URL")
}
return nil
}
func applyDefaults(cfg *Config) {
if cfg.Delivery.BuildStrategy == "" {
cfg.Delivery.BuildStrategy = "static"
}
if cfg.ClusterID == "" {
cfg.ClusterID = cfg.Talos.Cluster.Name
}
@ -122,6 +239,9 @@ func applyDefaults(cfg *Config) {
if cfg.Flux.ManifestsRepo == "" {
cfg.Flux.ManifestsRepo = "cicd-deployment-manifests"
}
if cfg.Flux.TektonCatalogRepo == "" {
cfg.Flux.TektonCatalogRepo = "tekton-pipelines"
}
if cfg.Templates.TalosRepoURL == "" {
cfg.Templates.TalosRepoURL = "https://git.pingu.pw/Maidn/maidn-talos-proxmox.git"
}
@ -155,24 +275,6 @@ func applyDefaults(cfg *Config) {
if cfg.DemocraticCSI.InitiatorGroup == "" {
cfg.DemocraticCSI.InitiatorGroup = "1"
}
if cfg.Delivery.AppName == "" {
cfg.Delivery.AppName = "easycsr-frontend"
}
if cfg.Delivery.AppRepoURL == "" {
cfg.Delivery.AppRepoURL = strings.TrimRight(cfg.Git.BaseURL, "/") + "/" + cfg.Git.Owner + "/" + cfg.Delivery.AppName + ".git"
}
if cfg.Delivery.AppRepoRef == "" {
cfg.Delivery.AppRepoRef = cfg.Flux.Branch
}
if cfg.Delivery.ImageRepository == "" {
cfg.Delivery.ImageRepository = strings.TrimPrefix(strings.TrimPrefix(cfg.Git.BaseURL, "https://"), "http://") + "/" + strings.ToLower(cfg.Git.Owner) + "/" + cfg.Delivery.AppName
}
if cfg.Delivery.WebhookHostname == "" && cfg.Flux.ClusterDomain != "" {
cfg.Delivery.WebhookHostname = "tekton." + cfg.Flux.ClusterDomain
}
if cfg.Delivery.WebhookPath == "" {
cfg.Delivery.WebhookPath = "/"
}
if cfg.Talos.RepoDirName == "" {
cfg.Talos.RepoDirName = "maidn-talos-proxmox"
}
@ -217,6 +319,27 @@ func applyDefaults(cfg *Config) {
}
}
func applyDeliveryDefaults(cfg *Config) {
if cfg.Delivery.BuildStrategy == "" {
cfg.Delivery.BuildStrategy = "static"
}
if cfg.Delivery.AppRepoRef == "" {
cfg.Delivery.AppRepoRef = cfg.Flux.Branch
}
if cfg.Delivery.BuildOutputDirectory == "" {
cfg.Delivery.BuildOutputDirectory = "dist"
}
if cfg.Delivery.BuildConfiguration == "" {
cfg.Delivery.BuildConfiguration = "production"
}
if cfg.Delivery.WebhookHostname == "" && cfg.Flux.ClusterDomain != "" {
cfg.Delivery.WebhookHostname = "tekton." + cfg.Flux.ClusterDomain
}
if cfg.Delivery.WebhookPath == "" {
cfg.Delivery.WebhookPath = "/"
}
}
func Validate(cfg Config) error {
if !regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`).MatchString(cfg.ClusterID) {
return errors.New("clusterId must be a lowercase DNS label")
@ -233,14 +356,25 @@ func Validate(cfg Config) error {
if cfg.Git.Token == "" {
return errors.New("git token is required; SSH bootstrap is not implemented")
}
if cfg.Flux.RepoName == "" || cfg.Flux.ClusterPath == "" || cfg.Flux.ManifestsRepo == "" || cfg.Flux.ClusterDomain == "" {
return errors.New("flux repoName, clusterDomain, manifestsRepo, and clusterPath are required")
if RedactURL(cfg.Git.BaseURL) != cfg.Git.BaseURL {
return errors.New("git baseUrl must not contain credentials, a query, or a fragment")
}
if cfg.Delivery.AppName == "" || cfg.Delivery.AppRepoURL == "" || cfg.Delivery.AppRepoRef == "" || cfg.Delivery.ImageRepository == "" || cfg.Delivery.WebhookHostname == "" || cfg.Delivery.WebhookPath == "" {
return errors.New("delivery appName, appRepoUrl, appRepoRef, imageRepository, webhookHostname, and webhookPath are required")
if cfg.Flux.RepoName == "" || cfg.Flux.ClusterPath == "" || cfg.Flux.ManifestsRepo == "" || cfg.Flux.TektonCatalogRepo == "" || cfg.Flux.ClusterDomain == "" {
return errors.New("flux repoName, clusterDomain, manifestsRepo, tektonCatalogRepo, and clusterPath are required")
}
if strings.ContainsAny(cfg.Delivery.WebhookHostname, "/:@?#") || !strings.HasPrefix(cfg.Delivery.WebhookPath, "/") || strings.ContainsAny(cfg.Delivery.WebhookPath, "?#") {
return errors.New("delivery webhookHostname must be a hostname and webhookPath must be an absolute path")
if !regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`).MatchString(cfg.Flux.TektonCatalogRepo) || strings.Contains(cfg.Flux.TektonCatalogRepo, "..") {
return errors.New("flux tektonCatalogRepo must be a repository name")
}
if len(cfg.Flux.ClusterDomain) > 253 || !regexp.MustCompile(`^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?(\.[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?)*$`).MatchString(cfg.Flux.ClusterDomain) {
return errors.New("flux clusterDomain must be a lowercase DNS subdomain")
}
if cfg.Delivery.Configured() {
if err := ValidateDelivery(cfg); err != nil {
return err
}
}
if err := ValidateSecretGrants(cfg.SecretGrants); err != nil {
return err
}
if cfg.Templates.TalosRepoURL == "" || cfg.Templates.TalosRepoRef == "" || cfg.Templates.CICDRepoURL == "" || cfg.Templates.CICDRepoRef == "" || cfg.Templates.ManifestsRepoURL == "" || cfg.Templates.ManifestsRepoRef == "" || cfg.Templates.TektonCatalogRepoURL == "" || cfg.Templates.TektonCatalogRepoRef == "" {
return errors.New("all template repository URLs and refs are required")
@ -251,6 +385,10 @@ func Validate(cfg Config) error {
if cfg.Talos.TerraformDir == "" || cfg.Talos.GeneratedDir == "" || cfg.Talos.ConfigFileName == "" {
return errors.New("talos terraformDir, generatedDir, and configFileName are required")
}
generatedDir := filepath.Clean(cfg.Talos.GeneratedDir)
if filepath.IsAbs(generatedDir) || generatedDir == "." || generatedDir == ".." || strings.HasPrefix(generatedDir, ".."+string(filepath.Separator)) {
return errors.New("talos generatedDir must be a child directory")
}
if cfg.Talos.Proxmox.APIURL == "" || cfg.Talos.Proxmox.APITokenID == "" || cfg.Talos.Proxmox.APITokenSecret == "" {
return errors.New("talos proxmox apiUrl, apiTokenId, and apiTokenSecret are required")
}
@ -359,6 +497,104 @@ func Validate(cfg Config) error {
return nil
}
// ValidateSecretGrants prevents a configuration change from widening an
// application's OpenBao policy outside its own path or named shared grants.
func ValidateSecretGrants(grants []SecretGrant) error {
name := regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
seen := map[string]bool{}
for _, grant := range grants {
if !name.MatchString(grant.Application) {
return errors.New("secret grant application must be a lowercase DNS label")
}
if grant.Consumer != "build" && grant.Consumer != "publish" && grant.Consumer != "runtime" {
return errors.New("secret grant consumer must be build, publish, or runtime")
}
if grant.Consumer == "runtime" {
if grant.Environment != "staging" && grant.Environment != "production" {
return errors.New("runtime secret grant environment must be staging or production")
}
} else if grant.Environment != "" {
return errors.New("build and publish secret grants must not set environment")
}
key := grant.Application + "/" + grant.Consumer + "/" + grant.Environment
if seen[key] {
return errors.New("duplicate secret grant consumer")
}
seen[key] = true
if len(grant.Secrets) == 0 {
return errors.New("secret grant requires at least one application secret")
}
secretNames := map[string]bool{}
for _, secret := range grant.Secrets {
if !name.MatchString(secret) || secretNames[secret] {
return errors.New("secret grant application secret names must be unique lowercase DNS labels")
}
secretNames[secret] = true
}
for _, shared := range grant.Shared {
if !name.MatchString(shared) {
return errors.New("shared secret grant name must be a lowercase DNS label")
}
}
}
return nil
}
// ValidateDelivery requires the complete app-delivery contract before rendering or publishing it.
func ValidateDelivery(cfg Config) error {
if cfg.Delivery.AppName == "" || cfg.Delivery.AppRepoURL == "" || cfg.Delivery.AppRepoRef == "" || cfg.Delivery.ProductionBranch == "" || cfg.Delivery.ImageRepository == "" || cfg.Delivery.BuildOutputDirectory == "" || cfg.Delivery.BuildConfiguration == "" || cfg.Delivery.WebhookHostname == "" || cfg.Delivery.WebhookPath == "" {
return errors.New("delivery appName, appRepoUrl, appRepoRef, productionBranch, imageRepository, buildOutputDirectory, buildConfiguration, webhookHostname, and webhookPath are required")
}
if !regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,45}[a-z0-9])?$`).MatchString(cfg.Delivery.AppName) {
return errors.New("delivery appName must be a lowercase DNS label of at most 47 characters")
}
if cfg.Delivery.BuildStrategy != "static" && cfg.Delivery.BuildStrategy != "runtime" {
return errors.New("delivery buildStrategy must be static or runtime")
}
if cfg.Delivery.ProductionBranch == cfg.Delivery.AppRepoRef || !validDeliveryBranch(cfg.Delivery.ProductionBranch) {
return errors.New("delivery productionBranch must be a valid branch distinct from appRepoRef")
}
if RedactURL(cfg.Delivery.AppRepoURL) != cfg.Delivery.AppRepoURL {
return errors.New("delivery appRepoUrl must not contain credentials, a query, or a fragment")
}
if err := validateDeliveryRepositoryOrigin(cfg.Git.BaseURL, cfg.Delivery.AppRepoURL); err != nil {
return err
}
if cfg.Delivery.TektonDashboardURL != "" {
if err := validateForgejoOrigin(cfg.Delivery.TektonDashboardURL); err != nil {
return errors.New("delivery tektonDashboardUrl must be a credential-free HTTPS origin")
}
}
if strings.ContainsAny(cfg.Delivery.WebhookHostname, "/:@?#") || !strings.HasPrefix(cfg.Delivery.WebhookPath, "/") || strings.ContainsAny(cfg.Delivery.WebhookPath, "?#") {
return errors.New("delivery webhookHostname must be a hostname and webhookPath must be an absolute path")
}
return nil
}
func validateDeliveryRepositoryOrigin(baseURL, repositoryURL string) error {
base, err := url.Parse(baseURL)
if err != nil || base.Scheme != "https" || base.Host == "" || base.User != nil || base.RawPath != "" || base.RawQuery != "" || base.Fragment != "" || strings.Trim(base.Path, "/") != "" {
return errors.New("git baseUrl must be a credential-free HTTPS origin")
}
repository, err := url.Parse(repositoryURL)
if err != nil || repository.Scheme != "https" || repository.Host == "" || repository.User != nil || repository.RawPath != "" || repository.RawQuery != "" || repository.Fragment != "" || !strings.EqualFold(repository.Scheme+"://"+repository.Host, base.Scheme+"://"+base.Host) {
return errors.New("delivery appRepoUrl must be a credential-free HTTPS repository on the configured Forgejo origin")
}
return nil
}
func validDeliveryBranch(value string) bool {
if !regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`).MatchString(value) || strings.Contains(value, "..") || strings.Contains(value, "//") || strings.HasSuffix(value, ".") || strings.HasSuffix(value, ".lock") {
return false
}
for _, part := range strings.Split(value, "/") {
if strings.HasPrefix(part, ".") {
return false
}
}
return true
}
func Preflight(cfg Config) error {
return talos.RequireCLICompatibility(cfg.Talos.Image.TalosVersion)
}

View file

@ -17,6 +17,7 @@ func validConfig(t *testing.T) Config {
Templates: TemplateConfig{TalosRepoURL: "https://git.example.test/talos.git", TalosRepoRef: "main", CICDRepoURL: "https://git.example.test/template.git", CICDRepoRef: "main", ManifestsRepoURL: "https://git.example.test/manifests.git", ManifestsRepoRef: "main"},
Cilium: CiliumConfig{TrafficInterface: "eth1", LoadBalancerStart: "192.168.45.19", LoadBalancerEnd: "192.168.45.30"},
DemocraticCSI: DemocraticCSIConfig{TrueNASAPIKey: "api-key", TrueNASHost: "truenas.example.test", TargetPortal: "truenas.example.test:3260", ShareHost: "truenas.example.test", DatasetParentNFS: "pool/kubernetes/nfs/v", DatasetSnapshotsNFS: "pool/kubernetes/nfs/s", AllowedNetworks: "192.168.45.0/24", NameSuffix: "-test", PortalGroup: "1", InitiatorGroup: "1"},
Delivery: DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/test-org/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/test-org/web-ui", BuildStrategy: "static", BuildOutputDirectory: "dist", BuildConfiguration: "production", WebhookHostname: "tekton.example.test", WebhookPath: "/"},
Talos: TalosConfig{
RepoDirName: "talos", TerraformDir: "terraform", GeneratedDir: "generated", ConfigFileName: "terraform.tfvars",
Proxmox: TalosProxmoxConfig{APIURL: "https://proxmox.example.test:8006", APITokenID: "id", APITokenSecret: "secret"},
@ -27,19 +28,217 @@ func validConfig(t *testing.T) Config {
}
}
func TestResolveDeliveryStates(t *testing.T) {
platformOnly := validConfig(t)
platformOnly.Delivery = DeliveryConfig{}
partialLegacy := platformOnly
partialLegacy.Delivery = DeliveryConfig{AppName: "legacy-app", AppRepoURL: "https://git.example.test/test-org/legacy-app.git", ProductionBranch: "production", ImageRepository: "registry.example.test/test-org/legacy-app"}
partialLegacy.Talos.AutoBootstrapFlux = true
for _, test := range []struct {
name string
cfg Config
configured bool
}{
{name: "absent", cfg: platformOnly},
{name: "partial legacy", cfg: partialLegacy},
{name: "complete", cfg: validConfig(t), configured: true},
} {
t.Run(test.name, func(t *testing.T) {
resolved, err := Resolve(test.cfg)
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Delivery.Configured() != test.configured {
t.Fatalf("Configured() = %t, want %t", resolved.Delivery.Configured(), test.configured)
}
if !test.configured && resolved.Delivery.AppRepoRef != "" {
t.Fatal("Resolve() defaulted delivery configuration treated as absent")
}
})
}
}
func TestResolveDeliveryAppliesDefaultsOnlyForExplicitAppOperations(t *testing.T) {
cfg := validConfig(t)
cfg.Delivery.AppRepoRef = ""
cfg.Delivery.BuildOutputDirectory = ""
cfg.Delivery.BuildConfiguration = ""
cfg.Delivery.BuildStrategy = ""
cfg.Delivery.WebhookHostname = ""
cfg.Delivery.WebhookPath = ""
platform, err := Resolve(cfg)
if err != nil {
t.Fatal(err)
}
if platform.Delivery.Configured() {
t.Fatal("platform resolution treated incomplete delivery as configured")
}
delivery, err := ResolveDelivery(platform)
if err != nil {
t.Fatal(err)
}
if !delivery.Delivery.Configured() || delivery.Delivery.BuildStrategy != "static" || delivery.Delivery.AppRepoRef != "main" || delivery.Delivery.BuildOutputDirectory != "dist" || delivery.Delivery.BuildConfiguration != "production" || delivery.Delivery.WebhookURL() != "https://tekton.example.test/" {
t.Fatalf("ResolveDelivery() did not apply the complete delivery contract: %#v", delivery.Delivery)
}
}
func TestValidateDeliveryBuildStrategy(t *testing.T) {
cfg := validConfig(t)
for _, strategy := range []string{"static", "runtime"} {
cfg.Delivery.BuildStrategy = strategy
if err := ValidateDelivery(cfg); err != nil {
t.Fatalf("ValidateDelivery() rejected %q: %v", strategy, err)
}
}
cfg.Delivery.BuildStrategy = "container"
if err := ValidateDelivery(cfg); err == nil || !strings.Contains(err.Error(), "buildStrategy") {
t.Fatalf("ValidateDelivery() accepted invalid build strategy: %v", err)
}
}
func TestValidateDeliveryRequiresCompleteConfig(t *testing.T) {
cfg := validConfig(t)
cfg.Delivery.ImageRepository = ""
if err := ValidateDelivery(cfg); err == nil || !strings.Contains(err.Error(), "imageRepository") {
t.Fatalf("ValidateDelivery() error = %v, want incomplete delivery error", err)
}
}
func TestValidateDeliveryRejectsUnsafeTektonDashboardURL(t *testing.T) {
cfg := validConfig(t)
cfg.Delivery.TektonDashboardURL = "https://token@example.test"
if err := ValidateDelivery(cfg); err == nil || !strings.Contains(err.Error(), "tektonDashboardUrl") {
t.Fatalf("ValidateDelivery() accepted credential-bearing dashboard URL: %v", err)
}
}
func TestResolveAppOnboardingAllowsOnlyCanonicalCrossOwnerSource(t *testing.T) {
cfg := validConfig(t)
cfg.Git.Owner = "test-org-2"
cfg.Delivery.AppRepoURL = "https://git.example.test/Maidn/maidn-e2e-web.git"
if _, err := ResolveAppOnboarding(cfg); err != nil {
t.Fatalf("ResolveAppOnboarding() rejected canonical source: %v", err)
}
cfg.Delivery.AppRepoURL = "https://git.example.test/test-org-2/web-ui.git"
if resolved, err := ResolveAppOnboarding(cfg); err != nil || resolved.Delivery.WebhookURL() != "https://tekton.example.test/" {
t.Fatalf("ResolveAppOnboarding() = %#v, %v", resolved.Delivery, err)
}
for _, appRepoURL := range []string{
"https://git.example.test/Maidn/web-ui.git",
"https://git.example.test/other-org/maidn-e2e-web.git",
} {
cfg.Delivery.AppRepoURL = appRepoURL
if _, err := ResolveAppOnboarding(cfg); err == nil || !strings.Contains(err.Error(), "canonical Maidn E2E fixture") {
t.Fatalf("ResolveAppOnboarding() accepted noncanonical cross-owner source %q: %v", appRepoURL, err)
}
}
}
func TestResolveAppOnboardingRequiresProductionBranch(t *testing.T) {
cfg := validConfig(t)
cfg.Delivery.ProductionBranch = "release"
if _, err := ResolveAppOnboarding(cfg); err == nil || !strings.Contains(err.Error(), "literal production") {
t.Fatalf("ResolveAppOnboarding() accepted nonstandard production branch: %v", err)
}
}
func TestValidateRejectsCredentialBearingDeliveryURLs(t *testing.T) {
cfg := validConfig(t)
cfg.Delivery.AppRepoURL = "https://reader:token@git.example.test/test-org/web-ui.git"
if _, err := Resolve(cfg); err == nil || !strings.Contains(err.Error(), "credentials") {
t.Fatalf("Resolve() error = %v, want credential-free app repository error", err)
}
}
func TestValidateSecretGrants(t *testing.T) {
if err := ValidateSecretGrants([]SecretGrant{{Application: "orders-api", Consumer: "runtime", Environment: "production", Secrets: []string{"database"}, Shared: []string{"rabbitmq"}}}); err != nil {
t.Fatal(err)
}
for _, grant := range []SecretGrant{
{Application: "orders-api", Consumer: "runtime", Environment: "preview"},
{Application: "orders-api", Consumer: "build", Environment: "staging"},
{Application: "orders-api", Consumer: "publish"},
{Application: "orders-api", Consumer: "publish", Shared: []string{"../platform"}},
{Application: "orders-api", Consumer: "publish", Secrets: []string{"database", "database"}},
} {
if err := ValidateSecretGrants([]SecretGrant{grant}); err == nil {
t.Fatalf("invalid secret grant accepted: %#v", grant)
}
}
}
func TestValidateRequiresDeliveryRepositoryOnForgejoOrigin(t *testing.T) {
cfg := validConfig(t)
cfg.Delivery.AppRepoURL = "https://attacker.example.test/test-org/web-ui.git"
if _, err := Resolve(cfg); err == nil || !strings.Contains(err.Error(), "configured Forgejo origin") {
t.Fatalf("Resolve() error = %v, want Forgejo origin error", err)
}
cfg.Delivery.AppRepoURL = "https://git.example.test/test-org/web-ui.git"
if _, err := Resolve(cfg); err != nil {
t.Fatalf("Resolve() rejected delivery repository on configured Forgejo origin: %v", err)
}
}
func TestValidateRequiresDistinctValidProductionBranch(t *testing.T) {
cfg := validConfig(t)
cfg.Delivery.ProductionBranch = cfg.Delivery.AppRepoRef
if _, err := Resolve(cfg); err == nil || !strings.Contains(err.Error(), "productionBranch") {
t.Fatalf("Resolve() error = %v, want distinct production branch error", err)
}
cfg.Delivery.ProductionBranch = "production/../unsafe"
if _, err := Resolve(cfg); err == nil || !strings.Contains(err.Error(), "productionBranch") {
t.Fatalf("Resolve() error = %v, want valid production branch error", err)
}
}
func TestResolveDefaultsWebhookEndpoint(t *testing.T) {
cfg, err := Resolve(validConfig(t))
input := validConfig(t)
input.Delivery.WebhookHostname = ""
input.Delivery.WebhookPath = ""
platform, err := Resolve(input)
if err != nil {
t.Fatal(err)
}
cfg, err := ResolveDelivery(platform)
if err != nil {
t.Fatal(err)
}
if cfg.Delivery.WebhookURL() != "https://tekton.example.test/" {
t.Fatalf("WebhookURL() = %q", cfg.Delivery.WebhookURL())
}
if cfg.Templates.TektonCatalogRepoURL != "https://git.pingu.pw/Maidn/tekton-pipelines.git" || cfg.Templates.TektonCatalogRepoRef != "main" {
if cfg.Templates.TektonCatalogRepoURL != "https://git.pingu.pw/Maidn/tekton-pipelines.git" || cfg.Templates.TektonCatalogRepoRef != "main" || cfg.Flux.TektonCatalogRepo != "tekton-pipelines" {
t.Fatalf("Tekton catalog defaults = %q@%q", cfg.Templates.TektonCatalogRepoURL, cfg.Templates.TektonCatalogRepoRef)
}
}
func TestResolveFreshBootstrapRequiresAnIsolatedExplicitWorkspace(t *testing.T) {
cfg := validConfig(t)
cfg.Git.Owner = ""
cfg.Git.CloneParent = filepath.Join(cfg.WorkspaceDir, "checkouts")
cfg.Flux.TektonCatalogRepo = "catalog"
cfg.Templates.TektonCatalogRepoURL = "https://git.example.test/templates/catalog.git"
cfg.Templates.TektonCatalogRepoRef = "main"
resolved, err := ResolveFreshBootstrap(cfg, "new-org", false)
if err != nil || resolved.Git.Owner != "new-org" {
t.Fatalf("ResolveFreshBootstrap() = (%#v, %v)", resolved.Git.Owner, err)
}
cfg.Git.CloneParent = cfg.WorkspaceDir
if _, err := ResolveFreshBootstrap(cfg, "new-org", false); err == nil || !strings.Contains(err.Error(), "cloneParent") {
t.Fatalf("ResolveFreshBootstrap() error = %v, want isolated clone parent error", err)
}
}
func TestResolveFreshBootstrapRejectsConflictingOwner(t *testing.T) {
cfg := validConfig(t)
cfg.Git.CloneParent = filepath.Join(cfg.WorkspaceDir, "checkouts")
if _, err := ResolveFreshBootstrap(cfg, "other-org", false); err == nil || !strings.Contains(err.Error(), "owner") {
t.Fatalf("ResolveFreshBootstrap() error = %v, want owner mismatch", err)
}
}
func TestLoadRejectsUnknownFields(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte("workspaceDir: test\nunknown: value\n"), 0600); err != nil {

View file

@ -12,9 +12,20 @@ type Config struct {
Cilium CiliumConfig `yaml:"cilium"`
DemocraticCSI DemocraticCSIConfig `yaml:"democraticCsi"`
Delivery DeliveryConfig `yaml:"delivery"`
SecretGrants []SecretGrant `yaml:"secretGrants,omitempty"`
SOPS SOPSConfig `yaml:"sops"`
}
// SecretGrant gives one application consumer access to named application and
// shared OpenBao paths. It contains references, never values.
type SecretGrant struct {
Application string `yaml:"application"`
Consumer string `yaml:"consumer"`
Environment string `yaml:"environment,omitempty"`
Secrets []string `yaml:"secrets"`
Shared []string `yaml:"shared,omitempty"`
}
type DemocraticCSIConfig struct {
TrueNASAPIKey string `yaml:"truenasApiKey"`
TrueNASHost string `yaml:"truenasHost"`
@ -32,11 +43,21 @@ type DeliveryConfig struct {
AppName string `yaml:"appName"`
AppRepoURL string `yaml:"appRepoUrl"`
AppRepoRef string `yaml:"appRepoRef"`
ProductionBranch string `yaml:"productionBranch"`
ImageRepository string `yaml:"imageRepository"`
BuildStrategy string `yaml:"buildStrategy"`
BuildOutputDirectory string `yaml:"buildOutputDirectory"`
BuildConfiguration string `yaml:"buildConfiguration"`
TektonDashboardURL string `yaml:"tektonDashboardUrl,omitempty"`
WebhookHostname string `yaml:"webhookHostname"`
WebhookPath string `yaml:"webhookPath"`
}
// Configured reports whether the app-delivery rendering contract is complete.
func (c DeliveryConfig) Configured() bool {
return c.AppName != "" && c.AppRepoURL != "" && c.AppRepoRef != "" && c.ProductionBranch != "" && c.ImageRepository != "" && c.BuildOutputDirectory != "" && c.BuildConfiguration != "" && c.WebhookHostname != "" && c.WebhookPath != ""
}
func (c DeliveryConfig) WebhookURL() string {
return (&url.URL{Scheme: "https", Host: c.WebhookHostname, Path: c.WebhookPath}).String()
}
@ -67,6 +88,7 @@ type FluxConfig struct {
ClusterPath string `yaml:"clusterPath"`
ClusterDomain string `yaml:"clusterDomain"`
ManifestsRepo string `yaml:"manifestsRepo"`
TektonCatalogRepo string `yaml:"tektonCatalogRepo"`
}
type TemplateConfig struct {

360
internal/e2e/e2e.go Normal file
View file

@ -0,0 +1,360 @@
// Package e2e contains read-only delivery verification primitives.
package e2e
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"regexp"
"strings"
"time"
)
const maxResponseBytes = 1 << 20
var (
dnsLabel = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$`)
forgejoPart = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
branchPattern = regexp.MustCompile(`^[A-Za-z0-9._/-]+$`)
)
type Options struct {
Kubeconfig string
Context string
FluxKustomizations []string
ExternalSecret string
PipelineRun string
PreviewURL string
PreviewSentinel string
PromotionPullsURL string
PromotionOwner string
PromotionHead string
PromotionToken string
Timeout time.Duration
Interval time.Duration
}
type Check struct {
Name string `json:"name"`
Status string `json:"status"`
Detail string `json:"detail"`
}
type Result struct {
Passed bool `json:"passed"`
Checks []Check `json:"checks"`
}
// Command is deliberately small so command boundaries can be faked in tests.
type Command interface {
Output(context.Context, string, ...string) ([]byte, error)
}
type HTTPDoer interface {
Do(*http.Request) (*http.Response, error)
}
type Runner struct {
Kubectl Command
HTTP HTTPDoer
}
type execCommand struct{}
func (execCommand) Output(ctx context.Context, name string, args ...string) ([]byte, error) {
return exec.CommandContext(ctx, name, args...).Output()
}
// ReadToken accepts only a reference to a token, never a token flag.
func ReadToken(environment, path string) (string, error) {
if environment != "" && path != "" {
return "", errors.New("use only one promotion token reference")
}
var token string
if environment != "" {
var present bool
token, present = os.LookupEnv(environment)
if !present {
return "", errors.New("promotion token environment variable is not set")
}
} else if path != "" {
data, err := os.ReadFile(path)
if err != nil {
return "", errors.New("read promotion token file")
}
token = string(data)
} else {
return "", errors.New("a promotion token environment or file reference is required")
}
token = strings.TrimSpace(token)
if token == "" || strings.ContainsAny(token, "\r\n") {
return "", errors.New("promotion token reference is empty or invalid")
}
return token, nil
}
func (o Options) Validate() error {
if o.Kubeconfig == "" {
return errors.New("kubeconfig path is required")
}
if len(o.FluxKustomizations) == 0 {
return errors.New("at least one Flux Kustomization is required")
}
seen := map[string]bool{}
for _, resource := range o.FluxKustomizations {
if !validNamespacedName(resource) || seen[resource] {
return errors.New("Flux Kustomizations must be unique namespace/name identifiers")
}
seen[resource] = true
}
for _, resource := range []string{o.ExternalSecret, o.PipelineRun} {
if !validNamespacedName(resource) {
return errors.New("ExternalSecret and PipelineRun must be namespace/name identifiers")
}
}
if err := validURL(o.PreviewURL); err != nil {
return fmt.Errorf("preview URL: %w", err)
}
if o.PreviewSentinel == "" {
return errors.New("preview sentinel is required")
}
if err := validURL(o.PromotionPullsURL); err != nil {
return fmt.Errorf("promotion pulls URL: %w", err)
}
if !forgejoPart.MatchString(o.PromotionOwner) || strings.Contains(o.PromotionOwner, "..") {
return errors.New("promotion owner is invalid")
}
if !branchPattern.MatchString(o.PromotionHead) || strings.Contains(o.PromotionHead, "..") || strings.HasPrefix(o.PromotionHead, "/") || strings.HasSuffix(o.PromotionHead, "/") || strings.Contains(o.PromotionHead, "//") {
return errors.New("promotion head is invalid")
}
if o.PromotionToken == "" || strings.ContainsAny(o.PromotionToken, "\r\n") {
return errors.New("promotion token is required")
}
if o.Timeout <= 0 || o.Timeout > 10*time.Minute {
return errors.New("timeout must be between zero and ten minutes")
}
if o.Interval <= 0 || o.Interval > o.Timeout {
return errors.New("interval must be positive and no longer than timeout")
}
return nil
}
func validNamespacedName(value string) bool {
parts := strings.Split(value, "/")
return len(parts) == 2 && dnsLabel.MatchString(parts[0]) && dnsLabel.MatchString(parts[1])
}
func validURL(value string) error {
parsed, err := url.Parse(value)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return errors.New("must be a credential-free HTTP(S) URL without query or fragment")
}
return nil
}
// Run executes the fixed read-only verification order. It never applies,
// reconciles, bootstraps, or writes cluster state.
func (r Runner) Run(ctx context.Context, options Options) (Result, error) {
if err := options.Validate(); err != nil {
return Result{}, err
}
if r.Kubectl == nil || r.HTTP == nil {
return Result{}, errors.New("e2e runner dependencies are required")
}
result := Result{Passed: true}
result.add("flux_ready", r.wait(ctx, options, func(ctx context.Context) state {
for _, resource := range options.FluxKustomizations {
if !r.readyCondition(ctx, options, "kustomizations.kustomize.toolkit.fluxcd.io", resource) {
return pending
}
}
return ready
}))
result.add("external_secret_ready", r.wait(ctx, options, func(ctx context.Context) state {
if r.readyCondition(ctx, options, "externalsecrets.external-secrets.io", options.ExternalSecret) {
return ready
}
return pending
}))
result.add("pipeline_run_terminal", r.wait(ctx, options, func(ctx context.Context) state {
return r.pipelineState(ctx, options)
}))
result.add("preview_sentinel", r.wait(ctx, options, func(ctx context.Context) state {
return r.previewState(ctx, options)
}))
result.add("promotion_pr_open", r.wait(ctx, options, func(ctx context.Context) state {
return r.promotionState(ctx, options)
}))
return result, nil
}
func (r *Result) add(name string, status state) {
check := Check{Name: name, Status: "pass", Detail: "ready"}
if status == failed {
check.Status, check.Detail, r.Passed = "fail", "failed", false
}
if status == timedOut {
check.Status, check.Detail, r.Passed = "fail", "timed_out", false
}
r.Checks = append(r.Checks, check)
}
type state int
const (
pending state = iota
ready
failed
timedOut
)
func (r Runner) wait(parent context.Context, options Options, probe func(context.Context) state) state {
ctx, cancel := context.WithTimeout(parent, options.Timeout)
defer cancel()
for {
if current := probe(ctx); current != pending {
return current
}
timer := time.NewTimer(options.Interval)
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return timedOut
case <-timer.C:
}
}
}
func (r Runner) readyCondition(ctx context.Context, options Options, kind, resource string) bool {
output, err := r.kubectl(ctx, options, kind, resource)
if err != nil {
return false
}
var value struct {
Status struct {
Conditions []struct {
Type string `json:"type"`
Status string `json:"status"`
} `json:"conditions"`
} `json:"status"`
}
if json.Unmarshal(output, &value) != nil {
return false
}
for _, condition := range value.Status.Conditions {
if condition.Type == "Ready" && condition.Status == "True" {
return true
}
}
return false
}
func (r Runner) pipelineState(ctx context.Context, options Options) state {
output, err := r.kubectl(ctx, options, "pipelineruns.tekton.dev", options.PipelineRun)
if err != nil {
return pending
}
var value struct {
Status struct {
Conditions []struct {
Type string `json:"type"`
Status string `json:"status"`
} `json:"conditions"`
} `json:"status"`
}
if json.Unmarshal(output, &value) != nil {
return pending
}
for _, condition := range value.Status.Conditions {
if condition.Type != "Succeeded" {
continue
}
switch condition.Status {
case "True":
return ready
case "False":
return failed
}
}
return pending
}
func (r Runner) previewState(ctx context.Context, options Options) state {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, options.PreviewURL, nil)
if err != nil {
return failed
}
response, err := r.HTTP.Do(request)
if err != nil {
return pending
}
defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return pending
}
body, err := io.ReadAll(io.LimitReader(response.Body, maxResponseBytes))
if err != nil {
return pending
}
if strings.Contains(string(body), options.PreviewSentinel) {
return ready
}
return pending
}
func (r Runner) promotionState(ctx context.Context, options Options) state {
endpoint, err := url.Parse(options.PromotionPullsURL)
if err != nil {
return failed
}
query := url.Values{"state": {"open"}, "head": {options.PromotionOwner + ":" + options.PromotionHead}}
endpoint.RawQuery = query.Encode()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return failed
}
request.Header.Set("Authorization", "token "+options.PromotionToken)
response, err := r.HTTP.Do(request)
if err != nil {
return pending
}
defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return pending
}
var pulls []struct {
State string `json:"state"`
}
if err := json.NewDecoder(io.LimitReader(response.Body, maxResponseBytes)).Decode(&pulls); err != nil {
return pending
}
if len(pulls) == 0 {
return pending
}
if len(pulls) != 1 || pulls[0].State != "open" {
return failed
}
return ready
}
func (r Runner) kubectl(ctx context.Context, options Options, kind, resource string) ([]byte, error) {
namespace, name, _ := strings.Cut(resource, "/")
args := []string{"--kubeconfig=" + options.Kubeconfig}
if options.Context != "" {
args = append(args, "--context="+options.Context)
}
args = append(args, "--namespace="+namespace, "get", kind, name, "-o=json")
return r.Kubectl.Output(ctx, "kubectl", args...)
}
func DefaultRunner() Runner {
return Runner{Kubectl: execCommand{}, HTTP: &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
}

137
internal/e2e/e2e_test.go Normal file
View file

@ -0,0 +1,137 @@
package e2e
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"time"
)
type fakeCommand struct {
output func(string, []string) ([]byte, error)
calls [][]string
}
func (f *fakeCommand) Output(_ context.Context, name string, args ...string) ([]byte, error) {
f.calls = append(f.calls, append([]string{name}, args...))
return f.output(name, args)
}
type fakeHTTP struct {
do func(*http.Request) (*http.Response, error)
}
func (f fakeHTTP) Do(request *http.Request) (*http.Response, error) { return f.do(request) }
func response(status int, body string) *http.Response {
return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}
}
func testOptions() Options {
return Options{
Kubeconfig: "/run/secrets/kubeconfig",
FluxKustomizations: []string{"flux-system/tekton"},
ExternalSecret: "tekton-pipelines/forgejo-webhook",
PipelineRun: "tekton-pipelines/delivery-1",
PreviewURL: "https://preview.example.test/",
PreviewSentinel: "maidn-e2e-ok",
PromotionPullsURL: "https://git.example.test/api/v1/repos/Maidn/manifests/pulls",
PromotionOwner: "Maidn",
PromotionHead: "maidn/promotion-app-0123456789abcdef0123456789abcdef01234567",
PromotionToken: "test-token",
Timeout: time.Second,
Interval: time.Millisecond,
}
}
func TestRunUsesReadOnlyBoundariesAndRedactsResponses(t *testing.T) {
kubectl := &fakeCommand{output: func(_ string, args []string) ([]byte, error) {
if strings.Contains(strings.Join(args, " "), "pipelineruns.tekton.dev") {
return []byte(`{"status":{"conditions":[{"type":"Succeeded","status":"True"}]}}`), nil
}
return []byte(`{"status":{"conditions":[{"type":"Ready","status":"True"}]},"data":"secret-value"}`), nil
}}
http := fakeHTTP{do: func(request *http.Request) (*http.Response, error) {
if strings.Contains(request.URL.Path, "/pulls") {
if request.Header.Get("Authorization") != "token test-token" {
t.Fatal("promotion request did not use the supplied token")
}
if got := request.URL.Query().Get("head"); got != "Maidn:maidn/promotion-app-0123456789abcdef0123456789abcdef01234567" {
t.Fatalf("promotion head = %q", got)
}
return response(http.StatusOK, `[{"state":"open","body":"secret-value"}]`), nil
}
return response(http.StatusOK, "maidn-e2e-ok secret-value"), nil
}}
result, err := (Runner{Kubectl: kubectl, HTTP: http}).Run(context.Background(), testOptions())
if err != nil || !result.Passed || len(result.Checks) != 5 {
t.Fatalf("Run() = %#v, %v", result, err)
}
encoded, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(encoded), "secret-value") || strings.Contains(string(encoded), "test-token") {
t.Fatalf("result exposed response data: %s", encoded)
}
for _, call := range kubectl.calls {
joined := strings.Join(call, " ")
if !strings.Contains(joined, " get ") || strings.Contains(joined, "apply") || strings.Contains(joined, "reconcile") {
t.Fatalf("unexpected kubectl invocation: %q", joined)
}
}
}
func TestRunReportsTerminalPipelineFailureWithoutWaiting(t *testing.T) {
kubectl := &fakeCommand{output: func(_ string, args []string) ([]byte, error) {
if strings.Contains(strings.Join(args, " "), "pipelineruns.tekton.dev") {
return []byte(`{"status":{"conditions":[{"type":"Succeeded","status":"False"}]}}`), nil
}
return []byte(`{"status":{"conditions":[{"type":"Ready","status":"True"}]}}`), nil
}}
http := fakeHTTP{do: func(request *http.Request) (*http.Response, error) {
if strings.Contains(request.URL.Path, "/pulls") {
return response(http.StatusOK, `[{"state":"open"}]`), nil
}
return response(http.StatusOK, "maidn-e2e-ok"), nil
}}
result, err := (Runner{Kubectl: kubectl, HTTP: http}).Run(context.Background(), testOptions())
if err != nil || result.Passed || result.Checks[2].Detail != "failed" {
t.Fatalf("Run() = %#v, %v", result, err)
}
}
func TestRunTimesOutWhenAReadinessConditionNeverArrives(t *testing.T) {
kubectl := &fakeCommand{output: func(_ string, args []string) ([]byte, error) {
if strings.Contains(strings.Join(args, " "), "externalsecrets.external-secrets.io") {
return []byte(`{"status":{"conditions":[{"type":"Ready","status":"False"}]}}`), nil
}
if strings.Contains(strings.Join(args, " "), "pipelineruns.tekton.dev") {
return []byte(`{"status":{"conditions":[{"type":"Succeeded","status":"True"}]}}`), nil
}
return []byte(`{"status":{"conditions":[{"type":"Ready","status":"True"}]}}`), nil
}}
http := fakeHTTP{do: func(request *http.Request) (*http.Response, error) {
if strings.Contains(request.URL.Path, "/pulls") {
return response(http.StatusOK, `[{"state":"open"}]`), nil
}
return response(http.StatusOK, "maidn-e2e-ok"), nil
}}
options := testOptions()
options.Timeout, options.Interval = 5*time.Millisecond, time.Millisecond
result, err := (Runner{Kubectl: kubectl, HTTP: http}).Run(context.Background(), options)
if err != nil || result.Passed || result.Checks[1].Detail != "timed_out" {
t.Fatalf("Run() = %#v, %v", result, err)
}
}
func TestReadTokenRejectsAmbiguousReferences(t *testing.T) {
if _, err := ReadToken("PROMOTION_TOKEN", "token.txt"); err == nil {
t.Fatal("ReadToken accepted two token references")
}
}

View file

@ -0,0 +1,232 @@
// Package e2emutate contains narrowly scoped Forgejo fixture mutations.
package e2emutate
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
)
const (
fixtureOwner = "Maidn"
fixturePrefix = "maidn-e2e-"
maxBodyBytes = 1 << 20
)
var (
forgejoName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
gitSHA = regexp.MustCompile(`^[0-9a-fA-F]{40}([0-9a-fA-F]{24})?$`)
)
// Options identifies the only Forgejo resources this command may mutate.
type Options struct {
ForgejoURL string
Owner string
Repo string
Branch string
SHA string
Token string
OpenPR bool
}
// HTTPDoer is the Forgejo API boundary and can be faked in tests.
type HTTPDoer interface {
Do(*http.Request) (*http.Response, error)
}
type Mutator struct {
HTTP HTTPDoer
}
func ReadToken(environment, path string) (string, error) {
if environment != "" && path != "" {
return "", errors.New("use only one Forgejo token reference")
}
var token string
if environment != "" {
var present bool
token, present = os.LookupEnv(environment)
if !present {
return "", errors.New("Forgejo token environment variable is not set")
}
} else if path != "" {
data, err := os.ReadFile(path)
if err != nil {
return "", errors.New("read Forgejo token file")
}
token = string(data)
} else {
return "", errors.New("a Forgejo token environment or file reference is required")
}
token = strings.TrimSpace(token)
if token == "" || strings.ContainsAny(token, "\r\n") {
return "", errors.New("Forgejo token reference is empty or invalid")
}
return token, nil
}
func (o Options) Validate() error {
if err := validForgejoURL(o.ForgejoURL); err != nil {
return err
}
if o.Owner != fixtureOwner {
return errors.New("Forgejo mutation owner must be Maidn")
}
for _, value := range []string{o.Repo, o.Branch} {
if !fixtureName(value) {
return errors.New("Forgejo mutation repository and branch must be maidn-e2e fixture identifiers")
}
}
if !gitSHA.MatchString(o.SHA) {
return errors.New("Forgejo mutation SHA must be a full Git object ID")
}
if o.Token == "" || strings.ContainsAny(o.Token, "\r\n") {
return errors.New("Forgejo token is required")
}
return nil
}
func fixtureName(value string) bool {
return strings.HasPrefix(value, fixturePrefix) && forgejoName.MatchString(value) && !strings.Contains(value, "..") && !strings.HasSuffix(value, ".") && !strings.HasSuffix(value, ".lock")
}
func validForgejoURL(value string) error {
parsed, err := url.Parse(value)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return errors.New("Forgejo URL must be a credential-free HTTP(S) URL without query or fragment")
}
return nil
}
// Run updates one fixture branch and may ensure its single PR to main.
func (m Mutator) Run(ctx context.Context, options Options) error {
if err := options.Validate(); err != nil {
return err
}
if m.HTTP == nil {
return errors.New("Forgejo mutation API is required")
}
if err := m.updateRef(ctx, options); err != nil {
return err
}
if options.OpenPR {
return m.ensurePR(ctx, options)
}
return nil
}
func (m Mutator) updateRef(ctx context.Context, options Options) error {
endpoint := options.apiURL("git", "refs", "heads", options.Branch)
status, err := m.request(ctx, options, http.MethodPatch, endpoint, struct {
SHA string `json:"sha"`
Force bool `json:"force"`
}{SHA: options.SHA})
if err != nil {
return err
}
if status >= http.StatusOK && status < http.StatusMultipleChoices {
return nil
}
if status != http.StatusNotFound {
return errors.New("Forgejo branch update failed")
}
status, err = m.request(ctx, options, http.MethodPost, options.apiURL("git", "refs"), struct {
Ref string `json:"ref"`
SHA string `json:"sha"`
}{Ref: "refs/heads/" + options.Branch, SHA: options.SHA})
if err != nil {
return err
}
if status < http.StatusOK || status >= http.StatusMultipleChoices {
return errors.New("Forgejo branch creation failed")
}
return nil
}
func (m Mutator) ensurePR(ctx context.Context, options Options) error {
endpoint, _ := url.Parse(options.apiURL("pulls"))
endpoint.RawQuery = url.Values{"state": {"open"}, "head": {options.Owner + ":" + options.Branch}}.Encode()
request, err := m.newRequest(ctx, options, http.MethodGet, endpoint.String(), nil)
if err != nil {
return err
}
response, err := m.HTTP.Do(request)
if err != nil {
return errors.New("Forgejo mutation request failed")
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return errors.New("Forgejo pull request lookup failed")
}
var pulls []json.RawMessage
if json.NewDecoder(io.LimitReader(response.Body, maxBodyBytes)).Decode(&pulls) != nil {
return errors.New("Forgejo pull request lookup returned invalid data")
}
if len(pulls) > 1 {
return errors.New("multiple open Forgejo pull requests exist for the fixture branch")
}
if len(pulls) == 1 {
return nil
}
status, err := m.request(ctx, options, http.MethodPost, options.apiURL("pulls"), struct {
Title string `json:"title"`
Head string `json:"head"`
Base string `json:"base"`
}{Title: "maidn e2e mutation", Head: options.Branch, Base: "main"})
if err != nil {
return err
}
if status < http.StatusOK || status >= http.StatusMultipleChoices {
return errors.New("Forgejo pull request creation failed")
}
return nil
}
func (m Mutator) request(ctx context.Context, options Options, method, endpoint string, body any) (int, error) {
request, err := m.newRequest(ctx, options, method, endpoint, body)
if err != nil {
return 0, err
}
response, err := m.HTTP.Do(request)
if err != nil {
return 0, errors.New("Forgejo mutation request failed")
}
defer response.Body.Close()
return response.StatusCode, nil
}
func (m Mutator) newRequest(ctx context.Context, options Options, method, endpoint string, body any) (*http.Request, error) {
var reader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, errors.New("encode Forgejo mutation request")
}
reader = bytes.NewReader(data)
}
request, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
if err != nil {
return nil, errors.New("create Forgejo mutation request")
}
request.Header.Set("Authorization", "token "+options.Token)
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
return request, nil
}
func (o Options) apiURL(parts ...string) string {
return strings.TrimRight(o.ForgejoURL, "/") + "/api/v1/repos/" + o.Owner + "/" + o.Repo + "/" + strings.Join(parts, "/")
}
func DefaultMutator() Mutator {
return Mutator{HTTP: &http.Client{Timeout: 15 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
}

View file

@ -0,0 +1,138 @@
package e2emutate
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"testing"
)
type fakeHTTP struct {
do func(*http.Request) (*http.Response, error)
calls []*http.Request
}
func (f *fakeHTTP) Do(request *http.Request) (*http.Response, error) {
f.calls = append(f.calls, request)
return f.do(request)
}
func mutationResponse(status int, body string) *http.Response {
return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}
}
func testOptions() Options {
return Options{
ForgejoURL: "https://git.example.test",
Owner: "Maidn",
Repo: "maidn-e2e-repo",
Branch: "maidn-e2e-branch",
SHA: "0123456789abcdef0123456789abcdef01234567",
Token: "test-token",
}
}
func TestOptionsValidateAcceptsOnlyFixtureTargets(t *testing.T) {
if err := testOptions().Validate(); err != nil {
t.Fatalf("valid fixture options: %v", err)
}
for _, update := range []func(*Options){
func(o *Options) { o.Owner = "other-org" },
func(o *Options) { o.Repo = "production" },
func(o *Options) { o.Branch = "feature/maidn-e2e-branch" },
func(o *Options) { o.Branch = "maidn-e2e-branch..unsafe" },
func(o *Options) { o.Repo = "maidn-e2e-repo.lock" },
} {
options := testOptions()
update(&options)
if err := options.Validate(); err == nil {
t.Fatalf("Validate accepted %#v", options)
}
}
}
func TestMutatorUpdatesFixtureRefAndEnsuresOnePR(t *testing.T) {
fake := &fakeHTTP{do: func(request *http.Request) (*http.Response, error) {
if request.Header.Get("Authorization") != "token test-token" {
t.Fatal("mutation request did not authenticate at the API boundary")
}
switch {
case request.Method == http.MethodPatch && request.URL.Path == "/api/v1/repos/Maidn/maidn-e2e-repo/git/refs/heads/maidn-e2e-branch":
var body struct {
SHA string `json:"sha"`
Force bool `json:"force"`
}
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.SHA != testOptions().SHA || body.Force {
t.Fatalf("unexpected branch update: %#v, %v", body, err)
}
return mutationResponse(http.StatusOK, ""), nil
case request.Method == http.MethodGet && request.URL.Path == "/api/v1/repos/Maidn/maidn-e2e-repo/pulls":
if request.URL.Query().Get("head") != "Maidn:maidn-e2e-branch" || request.URL.Query().Get("state") != "open" {
t.Fatal("pull request lookup did not target the fixture branch")
}
return mutationResponse(http.StatusOK, "[]"), nil
case request.Method == http.MethodPost && request.URL.Path == "/api/v1/repos/Maidn/maidn-e2e-repo/pulls":
var body struct {
Head string `json:"head"`
Base string `json:"base"`
}
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.Head != "maidn-e2e-branch" || body.Base != "main" {
t.Fatalf("unexpected pull request creation: %#v, %v", body, err)
}
return mutationResponse(http.StatusCreated, ""), nil
default:
t.Fatalf("unexpected Forgejo request: %s %s", request.Method, request.URL)
return nil, nil
}
}}
options := testOptions()
options.OpenPR = true
if err := (Mutator{HTTP: fake}).Run(context.Background(), options); err != nil || len(fake.calls) != 3 {
t.Fatalf("Run() = %v, calls = %d", err, len(fake.calls))
}
}
func TestMutatorCreatesFixtureRefWhenAbsent(t *testing.T) {
fake := &fakeHTTP{do: func(request *http.Request) (*http.Response, error) {
switch request.Method {
case http.MethodPatch:
return mutationResponse(http.StatusNotFound, ""), nil
case http.MethodPost:
if request.URL.Path != "/api/v1/repos/Maidn/maidn-e2e-repo/git/refs" {
t.Fatalf("branch creation targeted %q", request.URL.Path)
}
return mutationResponse(http.StatusCreated, ""), nil
default:
t.Fatalf("unexpected Forgejo request: %s %s", request.Method, request.URL)
return nil, nil
}
}}
if err := (Mutator{HTTP: fake}).Run(context.Background(), testOptions()); err != nil || len(fake.calls) != 2 {
t.Fatalf("Run() = %v, calls = %d", err, len(fake.calls))
}
}
func TestMutatorRejectsUnsafeTargetsBeforeAPIAndDoesNotExposeToken(t *testing.T) {
fake := &fakeHTTP{do: func(*http.Request) (*http.Response, error) {
t.Fatal("unsafe target reached the Forgejo API")
return nil, nil
}}
options := testOptions()
options.Owner = "production"
options.Token = "secret-token"
err := (Mutator{HTTP: fake}).Run(context.Background(), options)
if err == nil || strings.Contains(err.Error(), options.Token) {
t.Fatalf("Run() returned unsafe error: %v", err)
}
fake.do = func(*http.Request) (*http.Response, error) { return nil, errors.New(options.Token) }
options = testOptions()
options.Token = "secret-token"
err = (Mutator{HTTP: fake}).Run(context.Background(), options)
if err == nil || strings.Contains(err.Error(), options.Token) {
t.Fatalf("Run() exposed token: %v", err)
}
}

View file

@ -3,6 +3,7 @@ package forgejo
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
@ -23,7 +24,7 @@ type RepoManager struct {
FluxRepoName string
Branch string
MigrationBranch string
MigrationPending bool
MigrationRepositories []string
HTTPClient *http.Client
}
@ -35,6 +36,10 @@ type createRepoRequest struct {
DefaultBranch string `json:"default_branch"`
}
type createOrganizationRequest struct {
Username string `json:"username"`
}
type pullRequestRequest struct {
Title string `json:"title"`
Head string `json:"head"`
@ -52,6 +57,7 @@ type mergePullRequestRequest struct {
type hook struct {
ID int64 `json:"id"`
URL string `json:"url"`
Config map[string]string `json:"config"`
}
type hookRequest struct {
@ -62,6 +68,20 @@ type hookRequest struct {
Events []string `json:"events"`
}
type branchProtection struct {
ID int64 `json:"id"`
BranchName string `json:"branch_name"`
RuleName string `json:"rule_name"`
EnablePush bool `json:"enable_push"`
EnablePushWhitelist bool `json:"enable_push_whitelist"`
}
type branchProtectionRequest struct {
BranchName string `json:"branch_name"`
EnablePush bool `json:"enable_push"`
EnablePushWhitelist bool `json:"enable_push_whitelist"`
}
type APIError struct {
StatusCode int
Status string
@ -76,6 +96,9 @@ type accessToken struct {
SHA1 string `json:"sha1"`
}
var copyGit = runGit
var hasRemoteBranch = (*RepoManager).HasRemoteBranch
func (e *APIError) Error() string {
return fmt.Sprintf("forgejo returned %s", e.Status)
}
@ -100,10 +123,24 @@ func CreateRegistryToken(baseURL, username, password, otp, name string) (string,
}
func createRegistryToken(client *http.Client, baseURL, username, password, otp, name string) (string, error) {
return createToken(client, baseURL, username, password, otp, name, []string{"read:package", "write:package"})
}
// CreateDeliveryStatusToken creates the dedicated token used only to publish
// commit statuses and pull-request comments.
func CreateDeliveryStatusToken(baseURL, username, password, otp string) (string, error) {
return createDeliveryStatusToken(&http.Client{Timeout: 15 * time.Second}, baseURL, username, password, otp)
}
func createDeliveryStatusToken(client *http.Client, baseURL, username, password, otp string) (string, error) {
return createToken(client, baseURL, username, password, otp, "maidn-delivery-status", []string{"write:issue", "write:repository"})
}
func createToken(client *http.Client, baseURL, username, password, otp, name string, scopes []string) (string, error) {
if strings.TrimSpace(baseURL) == "" || username == "" || password == "" || strings.TrimSpace(name) == "" {
return "", fmt.Errorf("Forgejo base URL, username, password, and token name are required")
}
body, err := json.Marshal(createTokenRequest{Name: name, Scopes: []string{"read:package", "write:package"}})
body, err := json.Marshal(createTokenRequest{Name: name, Scopes: scopes})
if err != nil {
return "", err
}
@ -127,10 +164,10 @@ func createRegistryToken(client *http.Client, baseURL, username, password, otp,
}
var token accessToken
if err := json.NewDecoder(response.Body).Decode(&token); err != nil {
return "", fmt.Errorf("parse Forgejo registry token response: %w", err)
return "", fmt.Errorf("parse Forgejo token response: %w", err)
}
if token.SHA1 == "" {
return "", fmt.Errorf("Forgejo did not return a registry token")
return "", fmt.Errorf("Forgejo did not return a token")
}
return token.SHA1, nil
}
@ -142,6 +179,53 @@ func (rm *RepoManager) InitializeAll(createRepo func(string) error, createFlux f
return rm.ensureRepo(rm.FluxRepoName, "Flux CD cluster configurations", createFlux)
}
// EnsureOrganization creates the configured owner only when explicitly allowed.
func (rm *RepoManager) EnsureOrganization(create bool) (bool, error) {
exists, err := rm.organizationExists()
if err != nil {
return false, err
}
if exists {
return false, nil
}
if !create {
return false, errors.New("Forgejo organization does not exist; rerun with --create-organization")
}
body, err := json.Marshal(createOrganizationRequest{Username: rm.Owner})
if err != nil {
return false, err
}
status, err := rm.apiRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/orgs", rm.BaseURL), body)
if err != nil {
return false, err
}
if status == http.StatusCreated {
return true, nil
}
if status == http.StatusConflict {
exists, err = rm.organizationExists()
if err == nil && exists {
return false, nil
}
}
return false, fmt.Errorf("unexpected Forgejo organization create status %d", status)
}
func (rm *RepoManager) organizationExists() (bool, error) {
status, err := rm.apiRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/orgs/%s", rm.BaseURL, url.PathEscape(rm.Owner)), nil)
if err != nil {
return false, err
}
switch status {
case http.StatusOK:
return true, nil
case http.StatusNotFound:
return false, nil
default:
return false, fmt.Errorf("unexpected Forgejo organization lookup status %d", status)
}
}
func (rm *RepoManager) ensureRepo(name, description string, createStructure func(string) error) error {
exists, err := rm.repoExists(name)
if err != nil {
@ -171,15 +255,11 @@ func (rm *RepoManager) repoExists(name string) (bool, error) {
}
func (rm *RepoManager) createRepo(name, description string, autoInit bool) error {
createURL := fmt.Sprintf("%s/api/v1/orgs/%s/repos", rm.BaseURL, rm.Owner)
if rm.Owner == rm.Username {
createURL = fmt.Sprintf("%s/api/v1/user/repos", rm.BaseURL)
}
body, err := json.Marshal(createRepoRequest{Name: name, Description: description, Private: true, AutoInit: autoInit, DefaultBranch: rm.Branch})
if err != nil {
return err
}
status, err := rm.apiRequest(http.MethodPost, createURL, body)
status, err := rm.apiRequest(http.MethodPost, rm.repositoryCreateURL(), body)
if err != nil {
return err
}
@ -190,6 +270,93 @@ func (rm *RepoManager) createRepo(name, description string, autoInit bool) error
}
func (rm *RepoManager) EnsureRepository(name, description string) (bool, error) {
return rm.ensureRepository(name, description, false)
}
// EnsureInitializedRepository creates a base branch without application content.
func (rm *RepoManager) EnsureInitializedRepository(name, description string) (bool, error) {
return rm.ensureRepository(name, description, true)
}
// EnsureRepositoryCopy creates an independent, user-owned copy of source.
// Existing repositories are left untouched so user-managed catalog changes are never overwritten.
func (rm *RepoManager) EnsureRepositoryCopy(name, description, source string) (bool, error) {
sourceURL, err := catalogSourceURL(source)
if err != nil {
return false, err
}
exists, err := rm.repoExists(name)
if err != nil {
return false, err
}
if exists {
return false, rm.ensureRepositoryCopyRef(name)
}
if err := rm.createRepositoryCopy(name, description); err != nil {
return false, err
}
temporary, err := os.MkdirTemp("", "maidn-catalog-*")
if err != nil {
return false, err
}
defer os.RemoveAll(temporary)
cleanupAskPass, environment, err := rm.gitEnvironment()
if err != nil {
return false, err
}
defer cleanupAskPass()
if err := copyGit("", environment, "clone", "--mirror", sourceURL, temporary); err != nil {
return false, err
}
if err := copyGit(temporary, environment, "push", "--mirror", CloneURL(rm.BaseURL, rm.Owner, name)); err != nil {
return false, err
}
return true, rm.ensureRepositoryCopyRef(name)
}
func (rm *RepoManager) ensureRepositoryCopyRef(name string) error {
cleanupAskPass, environment, err := rm.gitEnvironment()
if err != nil {
return err
}
defer cleanupAskPass()
if err := copyGit("", environment, "ls-remote", "--exit-code", CloneURL(rm.BaseURL, rm.Owner, name), "refs/heads/"+rm.Branch); err != nil {
return errors.New("Tekton catalog repository does not contain the configured catalog ref; refusing to use ambiguous state")
}
return nil
}
func (rm *RepoManager) createRepositoryCopy(name, description string) error {
body, err := json.Marshal(createRepoRequest{Name: name, Description: description, Private: true, AutoInit: false, DefaultBranch: rm.Branch})
if err != nil {
return err
}
status, err := rm.apiRequest(http.MethodPost, rm.repositoryCreateURL(), body)
if err != nil {
return err
}
if status != http.StatusCreated {
return fmt.Errorf("Forgejo catalog repository creation returned status %d; refusing to copy into an ambiguous existing repository", status)
}
return nil
}
func (rm *RepoManager) repositoryCreateURL() string {
if rm.Owner == rm.Username {
return fmt.Sprintf("%s/api/v1/user/repos", rm.BaseURL)
}
return fmt.Sprintf("%s/api/v1/orgs/%s/repos", rm.BaseURL, rm.Owner)
}
func catalogSourceURL(source string) (string, error) {
parsed, err := url.Parse(source)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || strings.Trim(parsed.Path, "/") == "" {
return "", fmt.Errorf("Tekton catalog source must be a credential-free HTTPS repository URL")
}
return parsed.String(), nil
}
func (rm *RepoManager) ensureRepository(name, description string, autoInit bool) (bool, error) {
exists, err := rm.repoExists(name)
if err != nil {
return false, err
@ -197,7 +364,7 @@ func (rm *RepoManager) EnsureRepository(name, description string) (bool, error)
if exists {
return false, nil
}
return true, rm.createRepo(name, description, false)
return true, rm.createRepo(name, description, autoInit)
}
func (rm *RepoManager) setupRepository(repoURL, repoName string, existing bool, createStructure func(string) error) error {
@ -206,7 +373,7 @@ func (rm *RepoManager) setupRepository(repoURL, repoName string, existing bool,
return err
}
defer os.RemoveAll(tempDir)
cleanupAskPass, environment, err := rm.gitEnvironment(tempDir)
cleanupAskPass, environment, err := rm.gitEnvironment()
if err != nil {
return err
}
@ -228,7 +395,12 @@ func (rm *RepoManager) setupRepository(repoURL, repoName string, existing bool,
if err != nil || !changed || !existing {
return err
}
rm.MigrationPending = true
for _, repository := range rm.MigrationRepositories {
if repository == repoName {
return rm.createMigrationPullRequest(repoName, targetBranch)
}
}
rm.MigrationRepositories = append(rm.MigrationRepositories, repoName)
return rm.createMigrationPullRequest(repoName, targetBranch)
}
@ -237,6 +409,9 @@ func (rm *RepoManager) createMigrationPullRequest(repo, branch string) error {
}
func (rm *RepoManager) CreatePullRequest(repo, title, head, base string) error {
if head == "" || base == "" || head == base {
return fmt.Errorf("Forgejo pull request head and base must be different non-empty branches")
}
body, err := json.Marshal(pullRequestRequest{Title: title, Head: head, Base: base})
if err != nil {
return err
@ -245,12 +420,57 @@ func (rm *RepoManager) CreatePullRequest(repo, title, head, base string) error {
if err != nil {
return err
}
if status != http.StatusCreated && status != http.StatusUnprocessableEntity {
if status != http.StatusCreated && status != http.StatusUnprocessableEntity && status != http.StatusConflict {
return fmt.Errorf("unexpected Forgejo pull request status %d", status)
}
return nil
}
// EnsurePullRequest creates one pull request or returns the one already open
// for the exact head branch. It refuses duplicate or otherwise ambiguous state.
func (rm *RepoManager) EnsurePullRequest(repo, title, head, base string) error {
if head == "" || base == "" || head == base {
return fmt.Errorf("Forgejo pull request head and base must be different non-empty branches")
}
open, err := rm.HasOpenPullRequest(repo, head)
if err != nil {
return err
}
if open {
return nil
}
body, err := json.Marshal(pullRequestRequest{Title: title, Head: head, Base: base})
if err != nil {
return err
}
status, err := rm.apiRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls", rm.BaseURL, rm.Owner, repo), body)
if err != nil {
return err
}
if status != http.StatusCreated {
return fmt.Errorf("unexpected Forgejo pull request status %d", status)
}
return nil
}
// HasOpenPullRequest reports whether exactly one pull request is open for head.
func (rm *RepoManager) HasOpenPullRequest(repo, head string) (bool, error) {
values := url.Values{"state": {"open"}, "head": {head}}
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls?%s", rm.BaseURL, rm.Owner, repo, values.Encode())
var pullRequests []pullRequest
status, err := rm.apiJSONRequest(http.MethodGet, endpoint, nil, &pullRequests)
if err != nil {
return false, err
}
if status != http.StatusOK {
return false, fmt.Errorf("unexpected Forgejo pull request lookup status %d", status)
}
if len(pullRequests) > 1 {
return false, fmt.Errorf("multiple open Forgejo pull requests exist for branch %q", head)
}
return len(pullRequests) == 1, nil
}
func (rm *RepoManager) MergePullRequest(repo, head string) error {
values := url.Values{"state": {"open"}, "head": {head}}
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls?%s", rm.BaseURL, rm.Owner, repo, values.Encode())
@ -281,7 +501,7 @@ func (rm *RepoManager) PushBranch(dir, repoURL, branch string) error {
}
func (rm *RepoManager) PushRef(dir, repoURL, sourceRef, targetBranch string) error {
cleanupAskPass, environment, err := rm.gitEnvironment(dir)
cleanupAskPass, environment, err := rm.gitEnvironment()
if err != nil {
return err
}
@ -289,8 +509,89 @@ func (rm *RepoManager) PushRef(dir, repoURL, sourceRef, targetBranch string) err
return runGit(dir, environment, "push", repoURL, sourceRef+":refs/heads/"+targetBranch)
}
// PublishRepositoryPullRequest applies a managed change on a dedicated branch.
// An existing branch is accepted only when it has exactly one open pull request.
func (rm *RepoManager) PublishRepositoryPullRequest(repo, title, branch, base string, change func(string) error) (bool, error) {
if repo == "" || branch == "" || base == "" || branch == base {
return false, errors.New("repository pull request requires distinct non-empty branches")
}
repoURL := CloneURL(rm.BaseURL, rm.Owner, repo)
hasBranch, err := hasRemoteBranch(rm, repoURL, branch)
if err != nil {
return false, err
}
if hasBranch {
open, err := rm.HasOpenPullRequest(repo, branch)
if err != nil {
return false, err
}
if !open {
return false, fmt.Errorf("Forgejo branch %q exists without exactly one open pull request; refusing to reuse it", branch)
}
}
temporary, err := os.MkdirTemp("", "maidn-registration-*")
if err != nil {
return false, err
}
defer os.RemoveAll(temporary)
cleanupAskPass, environment, err := rm.gitEnvironment()
if err != nil {
return false, err
}
defer cleanupAskPass()
checkout := base
if hasBranch {
checkout = branch
}
if err := runGit("", environment, "clone", "--branch", checkout, repoURL, temporary); err != nil {
return false, err
}
if !hasBranch {
if err := runGit(temporary, environment, "checkout", "-B", branch, "origin/"+base); err != nil {
return false, err
}
}
if err := change(temporary); err != nil {
return false, err
}
changed, err := commitAndPush(temporary, repo, branch, environment)
if err != nil {
return false, err
}
if !changed {
if !hasBranch {
return false, nil
}
open, err := rm.HasOpenPullRequest(repo, branch)
if err != nil {
return false, err
}
if !open {
return false, nil
}
return true, nil
}
if err := rm.EnsurePullRequest(repo, title, branch, base); err != nil {
return false, err
}
return true, nil
}
func gitDiffQuiet(dir string, environment []string, args ...string) (bool, error) {
command := exec.Command("git", append([]string{"diff", "--quiet"}, args...)...)
command.Dir = dir
command.Env = environment
if err := command.Run(); err != nil {
if exitError, ok := err.(*exec.ExitError); ok && exitError.ExitCode() == 1 {
return true, nil
}
return false, err
}
return false, nil
}
func (rm *RepoManager) HasRemoteBranch(repoURL, branch string) (bool, error) {
cleanupAskPass, environment, err := rm.gitEnvironment("")
cleanupAskPass, environment, err := rm.gitEnvironment()
if err != nil {
return false, err
}
@ -306,6 +607,43 @@ func (rm *RepoManager) HasRemoteBranch(repoURL, branch string) (bool, error) {
return true, nil
}
// BranchRevision resolves branch to the checked-out commit that may be published.
func BranchRevision(dir, branch string) (string, error) {
command := exec.Command("git", "rev-parse", "--verify", branch+"^{commit}")
command.Dir = dir
revision, err := command.Output()
if err != nil {
return "", fmt.Errorf("resolve source branch %q: %w", branch, err)
}
if revision = bytes.TrimSpace(revision); len(revision) == 0 {
return "", fmt.Errorf("source branch %q has no commit", branch)
}
return string(revision), nil
}
// RemoteBranchRevision returns the remote branch commit, or an empty string when absent.
func (rm *RepoManager) RemoteBranchRevision(repoURL, branch string) (string, error) {
cleanupAskPass, environment, err := rm.gitEnvironment()
if err != nil {
return "", err
}
defer cleanupAskPass()
command := exec.Command("git", "ls-remote", "--refs", repoURL, "refs/heads/"+branch)
command.Env = environment
output, err := command.Output()
if err != nil {
return "", err
}
fields := strings.Fields(string(output))
if len(fields) == 0 {
return "", nil
}
if len(fields) != 2 || fields[1] != "refs/heads/"+branch {
return "", fmt.Errorf("unexpected remote ref response for branch %q", branch)
}
return fields[0], nil
}
func CurrentBranch(dir string) (string, error) {
command := exec.Command("git", "branch", "--show-current")
command.Dir = dir
@ -319,6 +657,35 @@ func CurrentBranch(dir string) (string, error) {
return "", fmt.Errorf("source repository is in detached HEAD state")
}
// EnsureCleanCheckout refuses to publish an ambiguous local worktree.
func EnsureCleanCheckout(dir string) error {
command := exec.Command("git", "status", "--porcelain=v1", "--untracked-files=all")
command.Dir = dir
status, err := command.Output()
if err != nil {
return fmt.Errorf("inspect application checkout: %w", err)
}
if len(status) != 0 {
return errors.New("application checkout has uncommitted changes; commit or discard them before publishing delivery")
}
return nil
}
// CheckoutOrigin returns the credential-free origin identity used before publishing.
func CheckoutOrigin(dir string) (string, error) {
command := exec.Command("git", "remote", "get-url", "origin")
command.Dir = dir
output, err := command.Output()
if err != nil {
return "", fmt.Errorf("read application checkout origin: %w", err)
}
origin := strings.TrimSpace(string(output))
if origin == "" {
return "", errors.New("application checkout origin is empty")
}
return origin, nil
}
func RepositoryFromURL(repoURL string) (string, string, error) {
parsed, err := url.Parse(repoURL)
if err != nil {
@ -352,7 +719,7 @@ func (rm *RepoManager) EnsureWebhook(repo, webhookURL, authorization string) err
Events: []string{"push", "pull_request"},
}
for _, existing := range hooks {
if existing.URL != webhookURL {
if hookURL(existing) != webhookURL {
continue
}
request, err := json.Marshal(hookRequest{Active: createRequest.Active, AuthorizationHeader: createRequest.AuthorizationHeader, Config: createRequest.Config, Events: createRequest.Events})
@ -382,6 +749,90 @@ func (rm *RepoManager) EnsureWebhook(repo, webhookURL, authorization string) err
return nil
}
func hookURL(existing hook) string {
if existing.URL != "" {
return existing.URL
}
return existing.Config["url"]
}
// TriggerWebhookTest asks Forgejo to deliver a test push for the managed hook.
func (rm *RepoManager) TriggerWebhookTest(repo, webhookURL, branch string) error {
if repo == "" || webhookURL == "" || branch == "" {
return errors.New("Forgejo repository, webhook URL, and branch are required")
}
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/hooks", rm.BaseURL, rm.Owner, repo)
var hooks []hook
status, err := rm.apiJSONRequest(http.MethodGet, endpoint, nil, &hooks)
if err != nil {
return err
}
if status != http.StatusOK {
return fmt.Errorf("unexpected Forgejo webhook lookup status %d", status)
}
var matching []hook
for _, candidate := range hooks {
if hookURL(candidate) == webhookURL {
matching = append(matching, candidate)
}
}
if len(matching) != 1 {
return fmt.Errorf("expected one Forgejo webhook for URL %q", webhookURL)
}
values := url.Values{"ref": {branch}}
status, err = rm.apiRequest(http.MethodPost, fmt.Sprintf("%s/%d/tests?%s", endpoint, matching[0].ID, values.Encode()), nil)
if err != nil {
return err
}
if status != http.StatusNoContent {
return fmt.Errorf("unexpected Forgejo webhook test status %d", status)
}
return nil
}
// EnsureProtectedBranch disables direct pushes to a managed branch.
func (rm *RepoManager) EnsureProtectedBranch(repo, branch string) error {
if repo == "" || branch == "" {
return errors.New("Forgejo repository and branch are required")
}
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/branch_protections", rm.BaseURL, rm.Owner, repo)
var protections []branchProtection
status, err := rm.apiJSONRequest(http.MethodGet, endpoint, nil, &protections)
if err != nil {
return err
}
if status != http.StatusOK {
return fmt.Errorf("unexpected Forgejo branch protection lookup status %d", status)
}
var matching []branchProtection
for _, protection := range protections {
if protection.BranchName == branch || protection.RuleName == branch {
matching = append(matching, protection)
}
}
if len(matching) > 1 {
return fmt.Errorf("multiple Forgejo branch protections match branch %q", branch)
}
if len(matching) == 1 {
if matching[0].EnablePush || matching[0].EnablePushWhitelist {
return fmt.Errorf("Forgejo branch %q permits direct pushes", branch)
}
return nil
}
body, err := json.Marshal(branchProtectionRequest{BranchName: branch})
if err != nil {
return err
}
status, err = rm.apiRequest(http.MethodPost, endpoint, body)
if err != nil {
return err
}
if status != http.StatusCreated {
return fmt.Errorf("unexpected Forgejo branch protection create status %d", status)
}
return nil
}
func commitAndPush(tempDir, repoName, branch string, environment []string) (bool, error) {
for _, args := range [][]string{{"config", "user.name", "Maidn"}, {"config", "user.email", "maidn@free-maidn.com"}, {"add", "."}} {
if err := runGit(tempDir, environment, args...); err != nil {
@ -408,8 +859,13 @@ func commitAndPush(tempDir, repoName, branch string, environment []string) (bool
return true, nil
}
func (rm *RepoManager) gitEnvironment(tempDir string) (func(), []string, error) {
if rm.Token == "" {
func (rm *RepoManager) gitEnvironment() (func(), []string, error) {
return GitEnvironment(rm.Username, rm.Token)
}
// GitEnvironment returns a temporary Git askpass environment without persisting credentials.
func GitEnvironment(username, token string) (func(), []string, error) {
if token == "" {
return func() {}, append(os.Environ(), "GIT_TERMINAL_PROMPT=0"), nil
}
askPassDir, err := os.MkdirTemp("", "maidn-askpass-*")
@ -426,7 +882,7 @@ func (rm *RepoManager) gitEnvironment(tempDir string) (func(), []string, error)
_ = os.RemoveAll(askPassDir)
return nil, nil, err
}
environment := append(os.Environ(), "GIT_ASKPASS="+path, "GIT_TERMINAL_PROMPT=0", "MAIDN_GIT_USERNAME="+rm.Username, "MAIDN_GIT_TOKEN="+rm.Token)
environment := append(os.Environ(), "GIT_ASKPASS="+path, "GIT_TERMINAL_PROMPT=0", "MAIDN_GIT_USERNAME="+username, "MAIDN_GIT_TOKEN="+token)
return func() { _ = os.RemoveAll(askPassDir) }, environment, nil
}

View file

@ -2,9 +2,11 @@ package forgejo
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
)
@ -39,6 +41,122 @@ func TestCreateRegistryTokenUsesBasicAuthAndPackageScopes(t *testing.T) {
}
}
func TestCreateDeliveryStatusTokenUsesOnlyStatusAndCommentScopes(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost || request.URL.Path != "/api/v1/users/delivery-bot/tokens" {
t.Fatal("unexpected Forgejo delivery-token request")
}
username, password, ok := request.BasicAuth()
if !ok || username != "delivery-bot" || password != "password" || request.Header.Get("X-Forgejo-OTP") != "123456" {
t.Fatal("delivery token request did not use the supplied credentials through HTTP authentication")
}
var body createTokenRequest
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.Name != "maidn-delivery-status" || !reflect.DeepEqual(body.Scopes, []string{"write:issue", "write:repository"}) {
t.Fatalf("delivery token request has unexpected privileges: %#v", body)
}
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(http.StatusCreated)
_, _ = writer.Write([]byte(`{"sha1":"delivery-token"}`))
}))
defer server.Close()
token, err := createDeliveryStatusToken(server.Client(), server.URL, "delivery-bot", "password", "123456")
if err != nil || token != "delivery-token" {
t.Fatal("CreateDeliveryStatusToken() did not return the Forgejo token")
}
}
func TestEnsureRepositoryCopyUsesAskPassAndCredentialFreeGitArguments(t *testing.T) {
original := copyGit
t.Cleanup(func() { copyGit = original })
var commands []string
copyGit = func(_ string, _ []string, args ...string) error {
commands = append(commands, strings.Join(args, " "))
return nil
}
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.Method {
case http.MethodGet:
if request.URL.Path != "/api/v1/repos/owner/catalog" {
t.Fatalf("unexpected lookup %q", request.URL.Path)
}
writer.WriteHeader(http.StatusNotFound)
case http.MethodPost:
if request.URL.Path != "/api/v1/orgs/owner/repos" {
t.Fatalf("unexpected create %q", request.URL.Path)
}
var body createRepoRequest
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.AutoInit || body.DefaultBranch != "release" {
t.Fatalf("unexpected catalog create request: %#v, %v", body, err)
}
writer.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected request method %q", request.Method)
}
}))
defer server.Close()
manager := NewRepoManager(server.URL, "test-token", "owner", "bot", "", "", "release", "")
manager.HTTPClient = server.Client()
if created, err := manager.EnsureRepositoryCopy("catalog", "catalog", "https://catalog.example.test/tekton.git"); err != nil || !created {
t.Fatalf("EnsureRepositoryCopy() = (%t, %v)", created, err)
}
joined := strings.Join(commands, "\n")
if !strings.Contains(joined, "clone --mirror https://catalog.example.test/tekton.git") || !strings.Contains(joined, "push --mirror "+CloneURL(server.URL, "owner", "catalog")) || !strings.Contains(joined, "ls-remote --exit-code "+CloneURL(server.URL, "owner", "catalog")+" refs/heads/release") || strings.Contains(joined, "test-token") {
t.Fatalf("catalog copy command boundary is unsafe: %q", joined)
}
}
func TestEnsureRepositoryCopyRejectsMissingConfiguredRefAfterCopy(t *testing.T) {
original := copyGit
t.Cleanup(func() { copyGit = original })
var commands []string
copyGit = func(_ string, _ []string, args ...string) error {
commands = append(commands, strings.Join(args, " "))
if args[0] == "ls-remote" {
return errors.New("missing ref")
}
return nil
}
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.Method {
case http.MethodGet:
writer.WriteHeader(http.StatusNotFound)
case http.MethodPost:
writer.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected request method %q", request.Method)
}
}))
defer server.Close()
manager := NewRepoManager(server.URL, "test-token", "owner", "bot", "", "", "release", "")
manager.HTTPClient = server.Client()
if created, err := manager.EnsureRepositoryCopy("catalog", "catalog", "https://catalog.example.test/tekton.git"); !created || err == nil || !strings.Contains(err.Error(), "configured catalog ref") {
t.Fatalf("EnsureRepositoryCopy() = (%t, %v), want copied catalog ref error", created, err)
}
joined := strings.Join(commands, "\n")
if !strings.Contains(joined, "push --mirror "+CloneURL(server.URL, "owner", "catalog")) || !strings.Contains(joined, "ls-remote --exit-code "+CloneURL(server.URL, "owner", "catalog")+" refs/heads/release") {
t.Fatalf("catalog copy did not verify the missing non-default ref: %q", joined)
}
}
func TestEnsureRepositoryCopyRejectsCredentialBearingSourceBeforeHTTP(t *testing.T) {
manager := NewRepoManager("https://git.example.test", "test-token", "owner", "bot", "", "", "main", "")
manager.HTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
t.Fatal("credential-bearing source reached Forgejo HTTP")
return nil, nil
})}
if _, err := manager.EnsureRepositoryCopy("catalog", "catalog", "https://reader:secret@catalog.example.test/tekton.git"); err == nil || strings.Contains(err.Error(), "secret") {
t.Fatalf("EnsureRepositoryCopy() error = %v", err)
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) }
func TestRepoExistsOnlyCreatesOnNotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusUnauthorized)
@ -51,6 +169,40 @@ func TestRepoExistsOnlyCreatesOnNotFound(t *testing.T) {
}
}
func TestEnsureOrganizationCreatesOnlyWhenRequested(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requests++
switch request.Method {
case http.MethodGet:
if request.URL.Path != "/api/v1/orgs/new-org" {
t.Fatalf("unexpected organization lookup %q", request.URL.Path)
}
writer.WriteHeader(http.StatusNotFound)
case http.MethodPost:
var body createOrganizationRequest
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.Username != "new-org" {
t.Fatalf("unexpected organization create request: %#v, %v", body, err)
}
writer.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected Forgejo method %q", request.Method)
}
}))
defer server.Close()
manager := NewRepoManager(server.URL, "token", "new-org", "user", "", "", "main", "")
manager.HTTPClient = server.Client()
if _, err := manager.EnsureOrganization(false); err == nil {
t.Fatal("missing organization was accepted without explicit create")
}
if requests != 1 {
t.Fatal("organization lookup performed unexpected remote actions")
}
if created, err := manager.EnsureOrganization(true); err != nil || !created {
t.Fatalf("EnsureOrganization(true) = (%t, %v)", created, err)
}
}
func TestEnsureWebhookUpdatesMatchingURL(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
@ -60,7 +212,7 @@ func TestEnsureWebhookUpdatesMatchingURL(t *testing.T) {
if request.URL.Path != "/api/v1/repos/owner/app/hooks" {
t.Fatalf("unexpected lookup path %q", request.URL.Path)
}
_ = json.NewEncoder(writer).Encode([]hook{{ID: 7, URL: "https://tekton.example.test/"}})
_ = json.NewEncoder(writer).Encode([]hook{{ID: 7, Config: map[string]string{"url": "https://tekton.example.test/"}}})
case http.MethodPatch:
if request.URL.Path != "/api/v1/repos/owner/app/hooks/7" {
t.Fatalf("unexpected update path %q", request.URL.Path)
@ -114,6 +266,94 @@ func TestEnsureWebhookCreatesMissingWebhook(t *testing.T) {
}
}
func TestTriggerWebhookTestUsesManagedHookAndBaseBranch(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.Method + " " + request.URL.Path {
case http.MethodGet + " /api/v1/repos/owner/app/hooks":
_ = json.NewEncoder(writer).Encode([]hook{{ID: 7, URL: "https://tekton.example.test/"}})
case http.MethodPost + " /api/v1/repos/owner/app/hooks/7/tests":
if request.URL.Query().Get("ref") != "main" || request.Header.Get("Authorization") == "" {
t.Fatal("webhook test did not use the managed hook and base branch")
}
writer.WriteHeader(http.StatusNoContent)
default:
t.Fatalf("unexpected Forgejo request %s %s", request.Method, request.URL.String())
}
}))
defer server.Close()
manager := NewRepoManager(server.URL, "test-token", "owner", "user", "", "", "main", "")
manager.HTTPClient = server.Client()
if err := manager.TriggerWebhookTest("app", "https://tekton.example.test/", "main"); err != nil {
t.Fatal(err)
}
}
func TestEnsureProtectedBranchCreatesDirectPushProtection(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/api/v1/repos/owner/app/branch_protections" {
t.Fatalf("unexpected branch protection path %q", request.URL.Path)
}
switch request.Method {
case http.MethodGet:
_ = json.NewEncoder(writer).Encode([]branchProtection{})
case http.MethodPost:
var body branchProtectionRequest
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.BranchName != "production" || body.EnablePush || body.EnablePushWhitelist {
t.Fatalf("unexpected branch protection request: %#v", body)
}
writer.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected method %q", request.Method)
}
}))
defer server.Close()
manager := NewRepoManager(server.URL, "token", "owner", "user", "", "", "main", "")
manager.HTTPClient = server.Client()
if err := manager.EnsureProtectedBranch("app", "production"); err != nil {
t.Fatal(err)
}
}
func TestEnsureProtectedBranchUsesCanonicalSourceOwnerPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/api/v1/repos/Maidn/maidn-e2e-web/branch_protections" {
t.Fatalf("canonical source mutation targeted %q", request.URL.Path)
}
switch request.Method {
case http.MethodGet:
_ = json.NewEncoder(writer).Encode([]branchProtection{})
case http.MethodPost:
writer.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected method %q", request.Method)
}
}))
defer server.Close()
manager := NewRepoManager(server.URL, "token", "Maidn", "user", "", "", "main", "")
manager.HTTPClient = server.Client()
if err := manager.EnsureProtectedBranch("maidn-e2e-web", "production"); err != nil {
t.Fatal(err)
}
}
func TestEnsureProtectedBranchRejectsExistingDirectPushRule(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodGet {
t.Fatal("existing unsafe protection must not be changed")
}
_ = json.NewEncoder(writer).Encode([]branchProtection{{ID: 1, BranchName: "production", EnablePush: true}})
}))
defer server.Close()
manager := NewRepoManager(server.URL, "token", "owner", "user", "", "", "main", "")
manager.HTTPClient = server.Client()
if err := manager.EnsureProtectedBranch("app", "production"); err == nil || !strings.Contains(err.Error(), "permits direct pushes") {
t.Fatalf("EnsureProtectedBranch() error = %v, want unsafe rule error", err)
}
}
func TestRepositoryFromURL(t *testing.T) {
owner, repo, err := RepositoryFromURL("https://git.example.test/team/app.git")
if err != nil || owner != "team" || repo != "app" {
@ -160,6 +400,34 @@ func TestHasRemoteBranchReturnsFalseForMissingBranch(t *testing.T) {
}
}
func TestPublishRepositoryPullRequestRejectsExistingBranchWithoutOpenPullRequest(t *testing.T) {
original := hasRemoteBranch
t.Cleanup(func() { hasRemoteBranch = original })
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodGet || request.URL.Path != "/api/v1/repos/owner/cluster/pulls" || request.URL.Query().Get("state") != "open" || request.URL.Query().Get("head") != "maidn/register-web-ui-deadbeefcafe" {
t.Fatalf("unexpected pull request lookup: %s %s", request.Method, request.URL.String())
}
_ = json.NewEncoder(writer).Encode([]pullRequest{})
}))
defer server.Close()
manager := NewRepoManager(server.URL, "test-token", "owner", "user", "", "", "main", "")
manager.HTTPClient = server.Client()
hasRemoteBranch = func(got *RepoManager, repoURL, branch string) (bool, error) {
if got != manager || repoURL != CloneURL(server.URL, "owner", "cluster") || branch != "maidn/register-web-ui-deadbeefcafe" {
t.Fatalf("unexpected remote branch lookup: %q %q", repoURL, branch)
}
return true, nil
}
_, err := manager.PublishRepositoryPullRequest("cluster", "register web-ui", "maidn/register-web-ui-deadbeefcafe", "main", func(string) error {
t.Fatal("change ran for a stale registration branch")
return nil
})
if err == nil || !strings.Contains(err.Error(), "without exactly one open pull request") {
t.Fatalf("PublishRepositoryPullRequest() error = %v", err)
}
}
func TestMergePullRequest(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.Method {
@ -186,6 +454,49 @@ func TestMergePullRequest(t *testing.T) {
}
}
func TestCreatePullRequestAcceptsExistingConflict(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost || request.URL.Path != "/api/v1/repos/owner/cluster/pulls" {
t.Fatalf("unexpected pull request request: %s %s", request.Method, request.URL.Path)
}
writer.WriteHeader(http.StatusConflict)
}))
defer server.Close()
manager := NewRepoManager(server.URL, "token", "owner", "user", "manifests", "cluster", "main", "maidn/bootstrap-test")
manager.HTTPClient = server.Client()
if err := manager.CreatePullRequest("cluster", "title", "maidn/bootstrap-test", "main"); err != nil {
t.Fatal(err)
}
}
func TestEnsurePullRequestChecksExactOpenBranchBeforeCreating(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requests++
switch request.Method {
case http.MethodGet:
if request.URL.Path != "/api/v1/repos/owner/app/pulls" || request.URL.Query().Get("state") != "open" || request.URL.Query().Get("head") != "maidn/delivery-app" {
t.Fatalf("unexpected pull request lookup: %s", request.URL.String())
}
_, _ = writer.Write([]byte("[]"))
case http.MethodPost:
var body pullRequestRequest
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.Head != "maidn/delivery-app" || body.Base != "main" {
t.Fatalf("unexpected pull request create: %#v, %v", body, err)
}
writer.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected request method %s", request.Method)
}
}))
defer server.Close()
manager := NewRepoManager(server.URL, "test-token", "owner", "bot", "", "", "main", "")
manager.HTTPClient = server.Client()
if err := manager.EnsurePullRequest("app", "delivery", "maidn/delivery-app", "main"); err != nil || requests != 2 {
t.Fatalf("EnsurePullRequest() = %v, requests = %d", err, requests)
}
}
func TestRepoExistsReturnsFalseOnNotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusNotFound)

View file

@ -0,0 +1,138 @@
package openbao
import (
"encoding/json"
"errors"
"os"
"regexp"
"strings"
)
var managedSecretPart = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
// ManagedSecretPath limits application-secret operations to the declared app
// and shared OpenBao namespaces.
func ManagedSecretPath(shared bool, owner, name string) (string, error) {
if !managedSecretPart.MatchString(owner) || !managedSecretPart.MatchString(name) {
return "", errors.New("application, shared group, and secret names must be lowercase DNS labels")
}
if shared {
return "shared/" + owner + "/" + name, nil
}
return "apps/" + owner + "/" + name, nil
}
func managedSecretScope(shared bool, owner string) (string, error) {
if !managedSecretPart.MatchString(owner) {
return "", errors.New("application and shared group names must be lowercase DNS labels")
}
if shared {
return "shared/" + owner, nil
}
return "apps/" + owner, nil
}
// StoreManagedSecret keeps the token and value on stdin all the way to OpenBao.
func StoreManagedSecret(kubeconfig, tokenPath, path string, value []byte) error {
if _, err := managedSecretPathParts(path); err != nil {
return err
}
if len(value) == 0 {
return errors.New("secret value must not be empty")
}
token, err := readRestrictedToken(tokenPath)
if err != nil {
return err
}
return writeSecret(kubeconfig, token, path, map[string]string{"value": string(value)})
}
// ListManagedSecrets returns only secret names from KV metadata.
func ListManagedSecrets(kubeconfig, tokenPath string, shared bool, owner string) ([]string, error) {
scope, err := managedSecretScope(shared, owner)
if err != nil {
return nil, err
}
token, err := readRestrictedToken(tokenPath)
if err != nil {
return nil, err
}
output, err := execInPod(kubeconfig, []byte(token+"\n"), "sh", "-ec", "read -r token\nexport BAO_TOKEN=\"$token\"\nbao kv list -format=json secret/metadata/"+scope)
if err != nil {
if strings.Contains(strings.ToLower(string(output)), "no value found") {
return nil, nil
}
return nil, errors.New("list OpenBao secret metadata")
}
var response struct {
Data struct {
Keys []string `json:"keys"`
} `json:"data"`
}
if err := json.Unmarshal(output, &response); err != nil {
return nil, errors.New("parse OpenBao secret metadata")
}
return response.Data.Keys, nil
}
// ManagedSecretStatus checks KV metadata without reading the secret value.
func ManagedSecretStatus(kubeconfig, tokenPath, path string) (bool, error) {
if _, err := managedSecretPathParts(path); err != nil {
return false, err
}
token, err := readRestrictedToken(tokenPath)
if err != nil {
return false, err
}
output, err := execInPod(kubeconfig, []byte(token+"\n"), "sh", "-ec", "read -r token\nexport BAO_TOKEN=\"$token\"\nbao kv metadata get -format=json secret/"+path)
if err != nil {
if strings.Contains(strings.ToLower(string(output)), "no value found") {
return false, nil
}
return false, errors.New("read OpenBao secret metadata")
}
var response struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(output, &response); err != nil || len(response.Data) == 0 {
return false, errors.New("parse OpenBao secret metadata")
}
return true, nil
}
func DeleteManagedSecret(kubeconfig, tokenPath, path string) error {
if _, err := managedSecretPathParts(path); err != nil {
return err
}
token, err := readRestrictedToken(tokenPath)
if err != nil {
return err
}
if _, err := execInPodMutation(kubeconfig, []byte(token+"\n"), "sh", "-ec", "read -r token\nexport BAO_TOKEN=\"$token\"\nbao kv metadata delete secret/"+path+" >/dev/null"); err != nil {
return errors.New("delete OpenBao secret")
}
return nil
}
func readRestrictedToken(path string) (string, error) {
if path == "" {
return "", errors.New("--token-file is required")
}
contents, err := os.ReadFile(path)
if err != nil {
return "", errors.New("read OpenBao token file")
}
token := strings.TrimSpace(string(contents))
if token == "" || strings.ContainsAny(token, " \t\r\n") {
return "", errors.New("OpenBao token file must contain one token")
}
return token, nil
}
func managedSecretPathParts(path string) ([]string, error) {
parts := strings.Split(path, "/")
if len(parts) != 3 || (parts[0] != "apps" && parts[0] != "shared") || !managedSecretPart.MatchString(parts[1]) || !managedSecretPart.MatchString(parts[2]) {
return nil, errors.New("invalid managed OpenBao secret path")
}
return parts, nil
}

View file

@ -0,0 +1,122 @@
package openbao
import (
"encoding/base64"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func restrictedTokenFile(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "openbao-token")
if err := os.WriteFile(path, []byte("test-restricted-token\n"), 0600); err != nil {
t.Fatal(err)
}
return path
}
func TestManagedSecretPathOnlyAllowsAppAndSharedNamespaces(t *testing.T) {
for _, test := range []struct {
shared bool
owner string
name string
want string
}{
{false, "orders-api", "publish", "apps/orders-api/publish"},
{true, "rabbitmq", "password", "shared/rabbitmq/password"},
} {
got, err := ManagedSecretPath(test.shared, test.owner, test.name)
if err != nil || got != test.want {
t.Fatalf("ManagedSecretPath(%t, %q, %q) = %q, %v", test.shared, test.owner, test.name, got, err)
}
}
if _, err := ManagedSecretPath(false, "orders-api", "../root"); err == nil {
t.Fatal("ManagedSecretPath accepted an unsafe path")
}
}
func TestStoreManagedSecretUsesRestrictedTokenStdinWithoutRecoveryInputs(t *testing.T) {
originalDecrypt, originalExec := decryptRecovery, execInPodMutation
t.Cleanup(func() { decryptRecovery, execInPodMutation = originalDecrypt, originalExec })
decryptRecovery = func(_, _ string) ([]byte, error) {
t.Fatal("application secret CRUD must not read recovery material")
return nil, nil
}
const value = "must-not-appear-in-command"
execInPodMutation = func(_ string, input []byte, args ...string) ([]byte, error) {
if string(input) != "test-restricted-token\n"+base64.StdEncoding.EncodeToString([]byte(value))+"\n" {
t.Fatal("secret token and value were not framed on stdin")
}
command := strings.Join(args, " ")
if !strings.Contains(command, "bao kv put secret/apps/orders-api/publish value=\"$value0\"") || strings.Contains(command, value) || strings.Contains(command, "test-restricted-token") {
t.Fatal("secret command exposed a value or token")
}
return nil, nil
}
if err := StoreManagedSecret("kubeconfig", restrictedTokenFile(t), "apps/orders-api/publish", []byte(value)); err != nil {
t.Fatal(err)
}
}
func TestListAndStatusReadMetadataOnly(t *testing.T) {
originalDecrypt, originalExec := decryptRecovery, execInPod
t.Cleanup(func() { decryptRecovery, execInPod = originalDecrypt, originalExec })
decryptRecovery = func(_, _ string) ([]byte, error) {
t.Fatal("application secret CRUD must not read recovery material")
return nil, nil
}
calls := 0
execInPod = func(_ string, input []byte, args ...string) ([]byte, error) {
calls++
if string(input) != "test-restricted-token\n" || strings.Contains(strings.Join(args, " "), " kv get ") || strings.Contains(strings.Join(args, " "), "test-restricted-token") {
t.Fatal("metadata query used an unsafe secret-read boundary")
}
if strings.Contains(strings.Join(args, " "), "kv list") {
return []byte(`{"data":{"keys":["publish"]}}`), nil
}
return []byte(`{"data":{"created_time":"2026-01-01T00:00:00Z"}}`), nil
}
tokenPath := restrictedTokenFile(t)
values, err := ListManagedSecrets("kubeconfig", tokenPath, false, "orders-api")
if err != nil || len(values) != 1 || values[0] != "publish" {
t.Fatalf("ListManagedSecrets() = %q, %v", values, err)
}
present, err := ManagedSecretStatus("kubeconfig", tokenPath, "apps/orders-api/publish")
if err != nil || !present || calls != 2 {
t.Fatalf("ManagedSecretStatus() = %t, %v; calls = %d", present, err, calls)
}
}
func TestDeleteManagedSecretUsesMetadataDelete(t *testing.T) {
originalDecrypt, originalExec := decryptRecovery, execInPodMutation
t.Cleanup(func() { decryptRecovery, execInPodMutation = originalDecrypt, originalExec })
decryptRecovery = func(_, _ string) ([]byte, error) {
t.Fatal("application secret CRUD must not read recovery material")
return nil, nil
}
execInPodMutation = func(_ string, input []byte, args ...string) ([]byte, error) {
if string(input) != "test-restricted-token\n" || !strings.Contains(strings.Join(args, " "), "bao kv metadata delete secret/shared/rabbitmq/password") || strings.Contains(strings.Join(args, " "), "test-restricted-token") {
t.Fatal("delete did not use a metadata delete with token on stdin")
}
return nil, nil
}
if err := DeleteManagedSecret("kubeconfig", restrictedTokenFile(t), "shared/rabbitmq/password"); err != nil {
t.Fatal(err)
}
}
func TestStoreManagedSecretRedactsTokenAndValueFromFailures(t *testing.T) {
original := execInPodMutation
t.Cleanup(func() { execInPodMutation = original })
const value = "must-not-leak-value"
execInPodMutation = func(_ string, _ []byte, _ ...string) ([]byte, error) {
return []byte("test-restricted-token " + value), errors.New("failed")
}
err := StoreManagedSecret("kubeconfig", restrictedTokenFile(t), "apps/orders-api/publish", []byte(value))
if err == nil || strings.Contains(err.Error(), "test-restricted-token") || strings.Contains(err.Error(), value) {
t.Fatal("secret operation leaked a token or value")
}
}

View file

@ -2,6 +2,8 @@ package openbao
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@ -14,9 +16,15 @@ import (
"strings"
"time"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
"gopkg.in/yaml.v3"
)
type AppSecretIdentityTokens struct {
Admin string
E2E string
}
type status struct {
Initialized bool `json:"initialized"`
Sealed bool `json:"sealed"`
@ -33,14 +41,25 @@ type operationalSecrets struct {
}
var decryptRecovery = func(identityPath, bundlePath string) ([]byte, error) {
cmd := exec.Command("age", "-d", "-i", identityPath, bundlePath)
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "age", "-d", "-i", identityPath, bundlePath)
output, err := cmd.Output()
if err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return nil, fmt.Errorf("decrypt OpenBao recovery material timed out after %s", commandTimeout)
}
return nil, fmt.Errorf("decrypt OpenBao recovery material: %w", err)
}
return output, nil
}
var openBaoStatus = getStatus
var commandTimeout = time.Minute
var openBaoMutationTimeout = 5 * time.Minute
func EnsureRecoveryIdentity(identityPath string) (string, error) {
if _, err := os.Stat(identityPath); os.IsNotExist(err) {
if err := os.MkdirAll(filepath.Dir(identityPath), 0700); err != nil {
@ -63,18 +82,22 @@ func EnsureRecoveryIdentity(identityPath string) (string, error) {
}
func Initialize(kubeconfig, recipient, identityPath, bundlePath, ageKeyPath, operationalSecretsPath string) (map[string]map[string]string, error) {
fmt.Fprintln(os.Stderr, "OpenBao: validate recovery recipient")
if err := validateRecoveryRecipient(recipient, bundlePath); err != nil {
return nil, err
}
fmt.Fprintln(os.Stderr, "OpenBao: wait for pod")
if err := waitForPod(kubeconfig); err != nil {
return nil, err
}
fmt.Fprintln(os.Stderr, "OpenBao: read status")
current, err := getStatus(kubeconfig)
if err != nil {
return nil, err
}
var material RecoveryMaterial
if !current.Initialized {
fmt.Fprintln(os.Stderr, "OpenBao: initialize")
output, err := execInPod(kubeconfig, nil, "bao", "operator", "init", "-format=json")
if err != nil {
return nil, fmt.Errorf("initialize OpenBao: %w", err)
@ -87,12 +110,14 @@ func Initialize(kubeconfig, recipient, identityPath, bundlePath, ageKeyPath, ope
return nil, err
}
} else {
fmt.Fprintln(os.Stderr, "OpenBao: decrypt recovery material")
material, err = ReadRecoveryMaterial(identityPath, bundlePath)
if err != nil {
return nil, err
}
}
if current.Sealed {
fmt.Fprintln(os.Stderr, "OpenBao: unseal")
if err := unseal(kubeconfig, material); err != nil {
return nil, err
}
@ -101,17 +126,32 @@ func Initialize(kubeconfig, recipient, identityPath, bundlePath, ageKeyPath, ope
if err != nil {
return nil, fmt.Errorf("create OpenBao Kubernetes token reviewer token: %w", err)
}
fmt.Fprintln(os.Stderr, "OpenBao: configure Kubernetes auth")
if err := configureKubernetesAuth(kubeconfig, material.RootToken, string(bytes.TrimSpace(reviewerToken))); err != nil {
return nil, err
}
return seedOperationalSecrets(kubeconfig, material.RootToken, ageKeyPath, operationalSecretsPath)
fmt.Fprintln(os.Stderr, "OpenBao: seed operational secrets")
secrets, err := seedOperationalSecrets(kubeconfig, material.RootToken, ageKeyPath, operationalSecretsPath)
if err != nil {
return nil, err
}
fmt.Fprintln(os.Stderr, "OpenBao: refresh External Secrets")
if err := refreshExternalSecrets(kubeconfig); err != nil {
return nil, err
}
return secrets, nil
}
func seedOperationalSecrets(kubeconfig, rootToken, ageKeyPath, path string) (map[string]map[string]string, error) {
cmd := exec.Command("sops", "--decrypt", "--output-type", "yaml", path)
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "sops", "--decrypt", "--output-type", "yaml", path)
cmd.Env = append(os.Environ(), "SOPS_AGE_KEY_FILE="+ageKeyPath)
plaintext, err := cmd.Output()
if err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return nil, fmt.Errorf("decrypt operational SOPS secrets timed out after %s", commandTimeout)
}
return nil, fmt.Errorf("decrypt operational SOPS secrets: %w", err)
}
var document operationalSecrets
@ -148,8 +188,14 @@ func validateRecoveryRecipient(recipient, bundlePath string) error {
return err
}
defer os.Remove(probePath)
cmd := exec.Command("age", "-r", recipient, "-o", probePath)
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "age", "-r", recipient, "-o", probePath)
cmd.Stdin = bytes.NewReader(nil)
if output, err := cmd.CombinedOutput(); err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return fmt.Errorf("validate OpenBao recovery recipient timed out after %s", commandTimeout)
}
return fmt.Errorf("validate OpenBao recovery recipient: %w: %s", err, bytes.TrimSpace(output))
}
return nil
@ -195,7 +241,7 @@ func parseRecoveryMaterial(plaintext []byte) (RecoveryMaterial, error) {
return material, nil
}
func writeSecret(kubeconfig, rootToken, secretPath string, values map[string]string) error {
func writeSecret(kubeconfig, token, secretPath string, values map[string]string) error {
if !regexp.MustCompile(`^[a-z0-9][a-z0-9/_-]*$`).MatchString(secretPath) || len(values) == 0 {
return fmt.Errorf("invalid OpenBao secret path %q", secretPath)
}
@ -209,36 +255,52 @@ func writeSecret(kubeconfig, rootToken, secretPath string, values map[string]str
sort.Strings(keys)
arguments := make([]string, 0, len(keys))
input := strings.Builder{}
input.WriteString(rootToken)
input.WriteString(token)
input.WriteByte('\n')
for _, key := range keys {
arguments = append(arguments, fmt.Sprintf("%s=\"$value%d\"", key, len(arguments)))
input.WriteString(values[key])
input.WriteString(base64.StdEncoding.EncodeToString([]byte(values[key])))
input.WriteByte('\n')
}
reads := make([]string, 0, len(keys))
decodes := make([]string, 0, len(keys))
for index := range keys {
reads = append(reads, fmt.Sprintf("read -r value%d", index))
reads = append(reads, fmt.Sprintf("read -r value%d_b64", index))
decodes = append(decodes, fmt.Sprintf("value%d=$(printf '%%s' \"$value%d_b64\" | base64 -d; printf x)\nvalue%d=${value%d%%x}", index, index, index, index))
}
script := "read -r root_token\n" + strings.Join(reads, "\n") + "\nexport BAO_TOKEN=\"$root_token\"\nbao kv put secret/" + secretPath + " " + strings.Join(arguments, " ") + " >/dev/null"
_, err := execInPod(kubeconfig, []byte(input.String()), "sh", "-ec", script)
return err
script := "read -r token\n" + strings.Join(reads, "\n") + "\n" + strings.Join(decodes, "\n") + "\nexport BAO_TOKEN=\"$token\"\nbao kv put secret/" + secretPath + " " + strings.Join(arguments, " ") + " >/dev/null"
_, err := execInPodMutation(kubeconfig, []byte(input.String()), "sh", "-ec", script)
if err != nil {
return fmt.Errorf("write OpenBao secret %q", secretPath)
}
return nil
}
func waitForPod(kubeconfig string) error {
deadline := time.Now().Add(10 * time.Minute)
var lastErr error
for time.Now().Before(deadline) {
if _, err := getStatus(kubeconfig); err == nil {
return nil
} else {
lastErr = err
}
time.Sleep(2 * time.Second)
}
if lastErr != nil {
return fmt.Errorf("OpenBao pod did not become ready: %w", lastErr)
}
return fmt.Errorf("OpenBao pod did not become ready")
}
func getStatus(kubeconfig string) (status, error) {
command := []string{"--kubeconfig", kubeconfig, "-n", "openbao", "exec", "openbao-0", "--", "bao", "status", "-format=json"}
output, err := exec.Command("kubectl", command...).Output()
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
defer cancel()
output, err := exec.CommandContext(ctx, "kubectl", command...).Output()
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return status{}, fmt.Errorf("get OpenBao status timed out after %s", commandTimeout)
}
if err != nil && !json.Valid(output) {
return status{}, fmt.Errorf("get OpenBao status: %w", err)
}
@ -253,35 +315,200 @@ func unseal(kubeconfig string, material RecoveryMaterial) error {
if material.UnsealThreshold < 1 || len(material.UnsealKeysB64) < material.UnsealThreshold {
return fmt.Errorf("OpenBao recovery material has insufficient unseal keys")
}
for _, key := range material.UnsealKeysB64[:material.UnsealThreshold] {
if _, err := execInPod(kubeconfig, []byte(key+"\n"), "sh", "-ec", "read -r key; bao operator unseal \"$key\" >/dev/null"); err != nil {
return fmt.Errorf("unseal OpenBao: %w", err)
for _, key := range material.UnsealKeysB64 {
_, _ = execInPod(kubeconfig, []byte(key+"\n"), "sh", "-ec", "read -r key; bao operator unseal \"$key\" >/dev/null")
}
current, err := openBaoStatus(kubeconfig)
if err != nil {
return fmt.Errorf("verify OpenBao unseal: %w", err)
}
if current.Sealed {
return unsealWithControllerSecret(kubeconfig)
}
return nil
}
func unsealWithControllerSecret(kubeconfig string) error {
const script = `bao operator unseal -reset >/dev/null 2>&1 || true
for share in /unseal/unseal-*; do
[ -f "$share" ] || continue
cat "$share" | bao operator unseal >/dev/null 2>&1 || true
done`
if _, err := execInUnsealController(kubeconfig, script); err != nil {
return fmt.Errorf("unseal OpenBao with controller secret: %w", err)
}
current, err := openBaoStatus(kubeconfig)
if err != nil {
return fmt.Errorf("verify OpenBao controller unseal: %w", err)
}
if current.Sealed {
return errors.New("OpenBao remains sealed after controller unseal")
}
return nil
}
func configureKubernetesAuth(kubeconfig, rootToken, reviewerToken string) error {
const script = `read -r root_token
const script = `fail() { printf '%s\n' "$1" >&2; exit 1; }
read -r root_token
read -r reviewer_token
export BAO_TOKEN="$root_token"
bao secrets enable -path=secret kv-v2 >/dev/null 2>&1 || true
bao auth enable kubernetes >/dev/null 2>&1 || true
bao write auth/kubernetes/config token_reviewer_jwt="$reviewer_token" kubernetes_host="https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT}" kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt >/dev/null
bao write auth/kubernetes/config token_reviewer_jwt="$reviewer_token" kubernetes_host="https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT}" kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt >/dev/null || fail kubernetes-auth-config
cat >/tmp/external-secrets.hcl <<'EOF'
path "secret/data/*" {
path "secret/data/platform/*" {
capabilities = ["read"]
}
path "secret/metadata/*" {
path "secret/data/cicd/*" {
capabilities = ["read"]
}
path "secret/metadata/platform/*" {
capabilities = ["list", "read"]
}
path "secret/metadata/cicd/*" {
capabilities = ["list", "read"]
}
EOF
bao policy write external-secrets /tmp/external-secrets.hcl >/dev/null
rm -f /tmp/external-secrets.hcl
bao write auth/kubernetes/role/external-secrets bound_service_account_names=external-secrets bound_service_account_namespaces=external-secrets policies=external-secrets ttl=1h >/dev/null`
bao policy write external-secrets /tmp/external-secrets.hcl >/dev/null || fail platform-external-secrets-policy
rm -f /tmp/external-secrets.hcl
bao write auth/kubernetes/role/external-secrets bound_service_account_names=external-secrets bound_service_account_namespaces=external-secrets policies=external-secrets ttl=1h >/dev/null 2>&1 || fail platform-external-secrets-role`
input := []byte(rootToken + "\n" + reviewerToken + "\n")
_, err := execInPod(kubeconfig, input, "sh", "-ec", script)
output, err := execInPodMutation(kubeconfig, input, "sh", "-ec", script)
if err != nil {
return openBaoMutationError("configure Kubernetes auth", err, output, rootToken, reviewerToken)
}
return nil
}
// ConfigureSecretGrants creates only OpenBao policies and Kubernetes auth
// roles. GitOps manifests create the matching SecretStores and ExternalSecrets.
func ConfigureSecretGrants(kubeconfig, identityPath, bundlePath string, grants []config.SecretGrant) error {
if err := config.ValidateSecretGrants(grants); err != nil {
return err
}
if len(grants) == 0 {
return nil
}
material, err := ReadRecoveryMaterial(identityPath, bundlePath)
if err != nil {
return err
}
var script strings.Builder
script.WriteString("read -r root_token\nexport BAO_TOKEN=\"$root_token\"\n")
for _, grant := range grants {
name := "maidn-" + grant.Application + "-" + grant.Consumer
namespace := "tekton-pipelines"
if grant.Consumer == "runtime" {
name += "-" + grant.Environment
namespace = grant.Environment
}
script.WriteString("cat >/tmp/" + name + ".hcl <<'EOF'\n")
for _, secret := range grant.Secrets {
script.WriteString("path \"secret/data/apps/" + grant.Application + "/" + secret + "\" {\n capabilities = [\"read\"]\n}\n")
}
for _, shared := range grant.Shared {
script.WriteString("path \"secret/data/shared/" + shared + "/*\" {\n capabilities = [\"read\"]\n}\n")
}
script.WriteString("EOF\n")
script.WriteString("bao policy write " + name + " /tmp/" + name + ".hcl >/dev/null\n")
script.WriteString("rm -f /tmp/" + name + ".hcl\n")
script.WriteString("bao write auth/kubernetes/role/" + name + " bound_service_account_names=" + name + " bound_service_account_namespaces=" + namespace + " policies=" + name + " ttl=1h >/dev/null\n")
}
output, err := execInPodMutation(kubeconfig, []byte(material.RootToken+"\n"), "sh", "-ec", script.String())
if err != nil {
return openBaoMutationError("configure OpenBao secret grants", err, output, material.RootToken)
}
return nil
}
// ProvisionAppSecretIdentities creates short-lived non-root tokens for secret
// administration and one fixture probe. Tokens are returned only to be placed
// into encrypted operational state by the caller.
func ProvisionAppSecretIdentities(kubeconfig, identityPath, bundlePath, app string) (AppSecretIdentityTokens, error) {
if !managedSecretPart.MatchString(app) {
return AppSecretIdentityTokens{}, errors.New("E2E application must be a lowercase DNS label")
}
material, err := ReadRecoveryMaterial(identityPath, bundlePath)
if err != nil {
return AppSecretIdentityTokens{}, err
}
script := `read -r root_token
export BAO_TOKEN="$root_token"
cat >/tmp/maidn-app-secret-admin.hcl <<'EOF'
path "secret/data/apps/*" { capabilities = ["create", "update"] }
path "secret/metadata/apps/*" { capabilities = ["list", "read", "delete"] }
path "secret/data/shared/*" { capabilities = ["create", "update"] }
path "secret/metadata/shared/*" { capabilities = ["list", "read", "delete"] }
EOF
cat >/tmp/maidn-e2e.hcl <<'EOF'
path "secret/data/apps/` + app + `/e2e-probe" { capabilities = ["create", "update"] }
path "secret/metadata/apps/` + app + `" { capabilities = ["list"] }
path "secret/metadata/apps/` + app + `/e2e-probe" { capabilities = ["read", "delete"] }
EOF
bao policy write maidn-app-secret-admin /tmp/maidn-app-secret-admin.hcl >/dev/null
bao policy write maidn-e2e-` + app + ` /tmp/maidn-e2e.hcl >/dev/null
bao token create -orphan -policy=maidn-app-secret-admin -ttl=1h -explicit-max-ttl=1h -format=json | base64 | tr -d '\n'; printf '\n'
bao token create -orphan -policy=maidn-e2e-` + app + ` -ttl=1h -explicit-max-ttl=1h -format=json | base64 | tr -d '\n'; printf '\n'
rm -f /tmp/maidn-app-secret-admin.hcl /tmp/maidn-e2e.hcl`
output, err := execInPodMutation(kubeconfig, []byte(material.RootToken+"\n"), "sh", "-ec", script)
if err != nil {
return AppSecretIdentityTokens{}, openBaoMutationError("provision app-secret identities", err, output, material.RootToken)
}
var responses []struct {
Auth struct {
ClientToken string `json:"client_token"`
} `json:"auth"`
}
for _, line := range bytes.Split(bytes.TrimSpace(output), []byte("\n")) {
var response struct {
Auth struct {
ClientToken string `json:"client_token"`
} `json:"auth"`
}
decoded, err := base64.StdEncoding.DecodeString(string(line))
if err != nil || json.Unmarshal(decoded, &response) != nil || response.Auth.ClientToken == "" {
return AppSecretIdentityTokens{}, errors.New("parse provisioned app-secret identity")
}
responses = append(responses, response)
}
if len(responses) != 2 {
return AppSecretIdentityTokens{}, errors.New("provision app-secret identities returned an incomplete result")
}
return AppSecretIdentityTokens{Admin: responses[0].Auth.ClientToken, E2E: responses[1].Auth.ClientToken}, nil
}
func openBaoMutationError(action string, err error, output []byte, sensitive ...string) error {
diagnostic := redactOpenBaoDiagnostic(strings.TrimSpace(string(output)), sensitive...)
if diagnostic == "" && err != nil {
diagnostic = redactOpenBaoDiagnostic(err.Error(), sensitive...)
}
if diagnostic == "" {
return errors.New(action)
}
return fmt.Errorf("%s: %s", action, diagnostic)
}
func redactOpenBaoDiagnostic(diagnostic string, sensitive ...string) string {
for _, value := range sensitive {
if value != "" {
diagnostic = strings.ReplaceAll(diagnostic, value, "[REDACTED]")
}
}
return diagnostic
}
func refreshExternalSecrets(kubeconfig string) error {
available, err := kubectlOutput(kubeconfig, "--request-timeout=30s", "-n", "external-secrets", "get", "deployment/external-secrets", "-o=jsonpath={.status.conditions[?(@.type==\"Available\")].status}")
if err != nil || strings.TrimSpace(string(available)) != "True" {
return nil
}
timestamp := time.Now().UnixNano()
fmt.Fprintln(os.Stderr, "OpenBao: refresh OpenBao secret store")
_, err = kubectlOutput(kubeconfig, "--request-timeout=30s", "annotate", "clustersecretstore", "openbao", fmt.Sprintf("force-sync=%d", timestamp), "--overwrite")
if err != nil {
return fmt.Errorf("refresh OpenBao secret store after seed: %w", err)
}
return nil
}
func encryptRecovery(recipient, bundlePath string, plaintext []byte) error {
@ -296,14 +523,38 @@ func encryptRecovery(recipient, bundlePath string, plaintext []byte) error {
return os.Chmod(bundlePath, 0600)
}
func execInPod(kubeconfig string, input []byte, args ...string) ([]byte, error) {
var execInPod = func(kubeconfig string, input []byte, args ...string) ([]byte, error) {
command := append([]string{"--kubeconfig", kubeconfig, "-n", "openbao", "exec", "-i", "openbao-0", "--"}, args...)
cmd := exec.Command("kubectl", command...)
cmd.Stdin = bytes.NewReader(input)
return cmd.CombinedOutput()
return commandOutput(input, "kubectl", command...)
}
func kubectlOutput(kubeconfig string, args ...string) ([]byte, error) {
command := append([]string{"--kubeconfig", kubeconfig}, args...)
return exec.Command("kubectl", command...).Output()
var execInPodMutation = func(kubeconfig string, input []byte, args ...string) ([]byte, error) {
command := append([]string{"--kubeconfig", kubeconfig, "-n", "openbao", "exec", "-i", "openbao-0", "--"}, args...)
return commandOutputWithTimeout(input, openBaoMutationTimeout, "kubectl", command...)
}
var execInUnsealController = func(kubeconfig, script string) ([]byte, error) {
command := []string{"--kubeconfig", kubeconfig, "-n", "openbao", "exec", "deployment/openbao-unseal", "--", "sh", "-ec", script}
return commandOutput(nil, "kubectl", command...)
}
var kubectlOutput = func(kubeconfig string, args ...string) ([]byte, error) {
command := append([]string{"--kubeconfig", kubeconfig}, args...)
return commandOutput(nil, "kubectl", command...)
}
func commandOutput(input []byte, name string, args ...string) ([]byte, error) {
return commandOutputWithTimeout(input, commandTimeout, name, args...)
}
func commandOutputWithTimeout(input []byte, timeout time.Duration, name string, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
cmd.Stdin = bytes.NewReader(input)
output, err := cmd.CombinedOutput()
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return output, fmt.Errorf("%s timed out after %s", name, timeout)
}
return output, err
}

View file

@ -1,11 +1,15 @@
package openbao
import (
"encoding/base64"
"errors"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
)
func TestEnsureRecoveryIdentity(t *testing.T) {
@ -21,6 +25,191 @@ func TestEnsureRecoveryIdentity(t *testing.T) {
}
}
func TestUnsealUsesPromptedStdinOnly(t *testing.T) {
originalExec, originalStatus := execInPod, openBaoStatus
t.Cleanup(func() { execInPod, openBaoStatus = originalExec, originalStatus })
calls := 0
execInPod = func(_ string, input []byte, args ...string) ([]byte, error) {
if len(input) == 0 || len(args) != 3 || args[0] != "sh" || args[1] != "-ec" || args[2] != "read -r key; bao operator unseal \"$key\" >/dev/null" {
t.Fatal("unseal share was not submitted through prompted stdin")
}
calls++
return nil, nil
}
openBaoStatus = func(string) (status, error) { return status{Initialized: true}, nil }
if err := unseal("kubeconfig", RecoveryMaterial{UnsealKeysB64: []string{"share-1", "share-2", "share-3"}, UnsealThreshold: 2}); err != nil || calls != 3 {
t.Fatalf("unseal calls:%d err:%v", calls, err)
}
}
func TestUnsealFallsBackToControllerSecret(t *testing.T) {
originalExec, originalStatus, originalController := execInPod, openBaoStatus, execInUnsealController
t.Cleanup(func() {
execInPod, openBaoStatus, execInUnsealController = originalExec, originalStatus, originalController
})
shares := 0
execInPod = func(_ string, _ []byte, _ ...string) ([]byte, error) { shares++; return nil, nil }
statusChecks := 0
openBaoStatus = func(string) (status, error) {
statusChecks++
return status{Initialized: true, Sealed: statusChecks == 1}, nil
}
controllerCalled := false
execInUnsealController = func(_ string, script string) ([]byte, error) {
controllerCalled = strings.Contains(script, "/unseal/unseal-*")
return nil, nil
}
if err := unseal("kubeconfig", RecoveryMaterial{UnsealKeysB64: []string{"share-1", "share-2", "share-3"}, UnsealThreshold: 2}); err != nil || shares != 3 || !controllerCalled {
t.Fatalf("unseal fallback = shares:%d controller:%t err:%v", shares, controllerCalled, err)
}
}
func TestWriteSecretFramesMultilineValues(t *testing.T) {
original := execInPodMutation
t.Cleanup(func() { execInPodMutation = original })
var input, script string
execInPodMutation = func(_ string, contents []byte, args ...string) ([]byte, error) {
input, script = string(contents), args[len(args)-1]
return nil, nil
}
value := "tunnel: tunnel\ningress:\n - service: http_status:404\n"
if err := writeSecret("kubeconfig", "root", "platform/cloudflare-tunnel", map[string]string{"config": value}); err != nil || input != "root\n"+base64.StdEncoding.EncodeToString([]byte(value))+"\n" || !strings.Contains(script, "base64 -d") {
t.Fatalf("multiline secret boundary was not framed safely: %v", err)
}
}
func TestWriteSecretRedactsMutationFailure(t *testing.T) {
original := execInPodMutation
t.Cleanup(func() { execInPodMutation = original })
const value = "must-not-leak"
execInPodMutation = func(_ string, _ []byte, _ ...string) ([]byte, error) {
return []byte(value), errors.New(value)
}
err := writeSecret("kubeconfig", "root-token", "cicd/demo", map[string]string{"password": value})
if err == nil || err.Error() != `write OpenBao secret "cicd/demo"` || strings.Contains(err.Error(), value) {
t.Fatalf("secret write error leaked a value: %v", err)
}
}
func TestRefreshExternalSecretsIsReadyGatedAndScoped(t *testing.T) {
original := kubectlOutput
t.Cleanup(func() { kubectlOutput = original })
var calls []string
kubectlOutput = func(_ string, args ...string) ([]byte, error) {
calls = append(calls, strings.Join(args, " "))
if len(calls) == 1 {
return []byte("True"), nil
}
return nil, nil
}
if err := refreshExternalSecrets("kubeconfig"); err != nil || len(calls) != 2 || !strings.Contains(calls[0], "get deployment/external-secrets") || !strings.Contains(calls[1], "annotate clustersecretstore openbao") || strings.Contains(calls[1], "--all") {
t.Fatalf("ExternalSecret refresh was not readiness-gated and scoped: %q, %v", calls, err)
}
}
func TestConfigureSecretGrantsScopesApplicationAndSharedPaths(t *testing.T) {
originalDecrypt, originalExec := decryptRecovery, execInPodMutation
t.Cleanup(func() { decryptRecovery, execInPodMutation = originalDecrypt, originalExec })
decryptRecovery = func(_, _ string) ([]byte, error) {
return []byte(`{"unseal_keys_b64":["share"],"unseal_threshold":1,"root_token":"root"}`), nil
}
var script string
execInPodMutation = func(_ string, input []byte, args ...string) ([]byte, error) {
if string(input) != "root\n" || len(args) != 3 || args[0] != "sh" || args[1] != "-ec" {
t.Fatal("secret grant did not use root token through stdin")
}
script = args[2]
return nil, nil
}
grants := []config.SecretGrant{
{Application: "orders-api", Consumer: "publish", Secrets: []string{"registry"}, Shared: []string{"artifact-cache"}},
{Application: "orders-api", Consumer: "runtime", Environment: "production", Secrets: []string{"database"}, Shared: []string{"rabbitmq"}},
}
if err := ConfigureSecretGrants("kubeconfig", "identity", "bundle", grants); err != nil {
t.Fatal(err)
}
for _, want := range []string{
`secret/data/apps/orders-api/registry`,
`secret/data/apps/orders-api/database`,
`secret/data/shared/artifact-cache/*`,
`secret/data/shared/rabbitmq/*`,
`bound_service_account_names=maidn-orders-api-publish`,
`bound_service_account_namespaces=production`,
} {
if !strings.Contains(script, want) {
t.Fatalf("secret grant script missing %q: %s", want, script)
}
}
if strings.Contains(script, `secret/data/*`) {
t.Fatal("secret grant widened access to every OpenBao secret")
}
if strings.Contains(script, `secret/data/apps/orders-api/registry/*`) || strings.Contains(script, `secret/data/apps/orders-api/database/*`) {
t.Fatal("secret grant widened access beyond declared application secrets")
}
}
func TestConfigureSecretGrantsPreservesRedactedPolicyDiagnostics(t *testing.T) {
originalDecrypt, originalExec := decryptRecovery, execInPodMutation
t.Cleanup(func() { decryptRecovery, execInPodMutation = originalDecrypt, originalExec })
const rootToken = "must-not-leak"
decryptRecovery = func(_, _ string) ([]byte, error) {
return []byte(`{"unseal_keys_b64":["share"],"unseal_threshold":1,"root_token":"must-not-leak"}`), nil
}
execInPodMutation = func(_ string, _ []byte, _ ...string) ([]byte, error) {
return []byte("policy write denied for " + rootToken), errors.New("exit status 1")
}
err := ConfigureSecretGrants("kubeconfig", "identity", "bundle", []config.SecretGrant{{Application: "orders-api", Consumer: "publish", Secrets: []string{"registry"}}})
if err == nil || !strings.Contains(err.Error(), "policy write denied") || strings.Contains(err.Error(), rootToken) {
t.Fatalf("policy diagnostics were not useful and redacted: %v", err)
}
}
func TestProvisionAppSecretIdentitiesScopesFixtureWithoutRootLeak(t *testing.T) {
originalDecrypt, originalMutation := decryptRecovery, execInPodMutation
t.Cleanup(func() { decryptRecovery, execInPodMutation = originalDecrypt, originalMutation })
decryptRecovery = func(string, string) ([]byte, error) {
return []byte(`{"root_token":"root-token","unseal_keys_b64":["share"],"unseal_threshold":1}`), nil
}
execInPodMutation = func(_ string, input []byte, args ...string) ([]byte, error) {
command := strings.Join(args, " ")
if string(input) != "root-token\n" || !strings.Contains(command, `secret/data/apps/maidn-e2e-web/e2e-probe`) || strings.Contains(command, `secret/data/apps/maidn-e2e-web/*`) {
t.Fatal("fixture identity policy scope is incorrect")
}
return []byte("eyJhdXRoIjp7ImNsaWVudF90b2tlbiI6ImFkbWluLXRva2VuIn19\neyJhdXRoIjp7ImNsaWVudF90b2tlbiI6ImUyZS10b2tlbiJ9fQ==\n"), nil
}
tokens, err := ProvisionAppSecretIdentities("kubeconfig", "identity", "bundle", "maidn-e2e-web")
if err != nil || tokens.Admin != "admin-token" || tokens.E2E != "e2e-token" {
t.Fatalf("ProvisionAppSecretIdentities() = %#v, %v", tokens, err)
}
}
func TestConfigureKubernetesAuthLimitsPlatformStore(t *testing.T) {
original := execInPodMutation
t.Cleanup(func() { execInPodMutation = original })
var script string
execInPodMutation = func(_ string, _ []byte, args ...string) ([]byte, error) {
script = args[len(args)-1]
return nil, nil
}
if err := configureKubernetesAuth("kubeconfig", "root", "reviewer"); err != nil {
t.Fatal(err)
}
for _, want := range []string{`secret/data/platform/*`, `secret/data/cicd/*`} {
if !strings.Contains(script, want) {
t.Fatalf("platform policy missing %q", want)
}
}
if strings.Contains(script, `secret/data/*`) {
t.Fatal("platform External Secrets role can read every secret")
}
}
func TestOpenBaoMutationTimeoutIsSeparateFromProbeTimeout(t *testing.T) {
if commandTimeout != time.Minute || openBaoMutationTimeout != 5*time.Minute {
t.Fatalf("probe timeout %s, mutation timeout %s", commandTimeout, openBaoMutationTimeout)
}
}
func TestReadRecoveryMaterialDecryptsAndValidatesBundle(t *testing.T) {
original := decryptRecovery
t.Cleanup(func() { decryptRecovery = original })

View file

@ -91,6 +91,11 @@ type StorageInfo struct {
SupportsDisk bool
}
type VMInfo struct {
Name string
Description string
}
func New(baseURL, tokenID, tokenSecret string, insecure bool) *Client {
transport := &http.Transport{}
if insecure {
@ -131,6 +136,20 @@ func (c *Client) Discover() ([]NodeInfo, error) {
return result, nil
}
// GetVM returns the identity fields used to verify a configured VM before import.
func (c *Client) GetVM(node string, vmid int) (VMInfo, error) {
var response struct {
Data struct {
Name string `json:"name"`
Description string `json:"description"`
} `json:"data"`
}
if err := c.getJSON("/nodes/"+url.PathEscape(node)+"/qemu/"+fmt.Sprint(vmid)+"/config", &response); err != nil {
return VMInfo{}, err
}
return VMInfo{Name: response.Data.Name, Description: response.Data.Description}, nil
}
func (c *Client) describeNode(node string) (NodeInfo, error) {
var status nodeStatusResponse
if err := c.getJSON("/nodes/"+node+"/status", &status); err != nil {

View file

@ -0,0 +1,22 @@
package proxmox
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestGetVMReadsIdentity(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.URL.EscapedPath() != "/api2/json/nodes/node%201/qemu/100/config" {
t.Fatalf("path = %q", request.URL.EscapedPath())
}
_, _ = writer.Write([]byte(`{"data":{"name":"cp-01","description":"Talos Control Plane Node - Managed by Terraform"}}`))
}))
defer server.Close()
vm, err := New(server.URL, "token-id", "token-secret", false).GetVM("node 1", 100)
if err != nil || vm.Name != "cp-01" || vm.Description != "Talos Control Plane Node - Managed by Terraform" {
t.Fatalf("GetVM() = %#v, %v", vm, err)
}
}

View file

@ -98,6 +98,17 @@ func PromptForgejoRegistryToken() (password, otp, name string, err error) {
return password, otp, name, nil
}
// PromptForgejoDeliveryStatusToken collects credentials used only to create
// the fixed-purpose delivery-status token.
func PromptForgejoDeliveryStatusToken() (password, otp string, err error) {
password, err = promptHiddenRequired("Forgejo account password")
if err != nil {
return "", "", err
}
otp, err = promptHiddenOptional("Forgejo OTP (optional)")
return password, otp, err
}
func promptForToken(reader *bufio.Reader, prompt string) (string, error) {
token := promptSecret(reader, prompt, "")
if token == "" {
@ -156,6 +167,13 @@ func promptHiddenRequired(prompt string) (string, error) {
func promptHiddenOptional(prompt string) (string, error) {
fmt.Printf("%s: ", prompt)
if !term.IsTerminal(int(syscall.Stdin)) {
value, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil && len(value) == 0 {
return "", nil
}
return strings.TrimSpace(value), nil
}
value, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println()
if err != nil {

View file

@ -33,6 +33,13 @@ func RunBootstrapWizard(initial config.Config) (config.Config, error) {
cfg.Flux.ManifestsRepo = prompt(reader, "Manifests repo name", fallback(cfg.Flux.ManifestsRepo, "cicd-deployment-manifests"))
cfg.Flux.Branch = "main"
cfg.Flux.ClusterPath = "./clusters/maidn-cd-0"
cfg.Delivery.AppName = prompt(reader, "Application name", fallback(cfg.Delivery.AppName, "app"))
cfg.Delivery.AppRepoURL = prompt(reader, "Application repository URL", fallback(cfg.Delivery.AppRepoURL, strings.TrimRight(cfg.Git.BaseURL, "/")+"/"+cfg.Git.Owner+"/"+cfg.Delivery.AppName+".git"))
cfg.Delivery.AppRepoRef = prompt(reader, "Application base branch", fallback(cfg.Delivery.AppRepoRef, cfg.Flux.Branch))
cfg.Delivery.ProductionBranch = prompt(reader, "Application production branch", fallback(cfg.Delivery.ProductionBranch, "production"))
cfg.Delivery.ImageRepository = prompt(reader, "Application image repository", fallback(cfg.Delivery.ImageRepository, strings.TrimPrefix(strings.TrimPrefix(cfg.Git.BaseURL, "https://"), "http://")+"/"+strings.ToLower(cfg.Git.Owner)+"/"+cfg.Delivery.AppName))
cfg.Delivery.BuildOutputDirectory = prompt(reader, "Application build output directory", fallback(cfg.Delivery.BuildOutputDirectory, "dist"))
cfg.Delivery.BuildConfiguration = prompt(reader, "Application build configuration", fallback(cfg.Delivery.BuildConfiguration, "production"))
cfg.Templates.TalosRepoURL = prompt(reader, "Talos template repo URL", fallback(cfg.Templates.TalosRepoURL, "https://git.pingu.pw/Maidn/maidn-talos-proxmox.git"))
cfg.Templates.CICDRepoURL = prompt(reader, "CI/CD template repo URL", fallback(cfg.Templates.CICDRepoURL, "https://git.pingu.pw/Maidn/maidn-cicd-cluster-template.git"))
cfg.Templates.ManifestsRepoURL = prompt(reader, "Manifests template repo URL", fallback(cfg.Templates.ManifestsRepoURL, "https://git.pingu.pw/Maidn/cicd-deployment-manifests-template.git"))
@ -132,10 +139,9 @@ func PromptOperationalSecrets(cfg config.Config) (map[string]map[string]string,
reader := bufio.NewReader(os.Stdin)
piholeServer := prompt(reader, "Pi-hole server", "")
piholePassword := promptSecret(reader, "Pi-hole password", "")
cloudflareAPIToken := promptSecret(reader, "Cloudflare DNS-01 API token", "")
cloudflareTunnelToken := promptSecret(reader, "Cloudflare Tunnel token", "")
if piholeServer == "" || piholePassword == "" || cloudflareAPIToken == "" || cloudflareTunnelToken == "" {
return nil, errors.New("Pi-hole server, password, Cloudflare DNS-01 API token, and Cloudflare Tunnel token are required")
cloudflareAPIToken := promptSecret(reader, "Cloudflare API token", "")
if piholeServer == "" || piholePassword == "" || cloudflareAPIToken == "" {
return nil, errors.New("Pi-hole server, password, and Cloudflare API token are required")
}
registryHost := strings.Split(cfg.Delivery.ImageRepository, "/")[0]
dockerConfig, err := json.Marshal(map[string]map[string]map[string]string{
@ -151,7 +157,6 @@ func PromptOperationalSecrets(cfg config.Config) (map[string]map[string]string,
"cicd/forgejo-registry": {"dockerconfigjson": string(dockerConfig)},
"platform/pihole": {"server": piholeServer, "password": piholePassword},
"platform/cloudflare": {"api-token": cloudflareAPIToken},
"platform/cloudflare-tunnel": {"token": cloudflareTunnelToken},
}, nil
}