Compare commits

...

58 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
41 changed files with 3237 additions and 513 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,25 +1,15 @@
## Commands ## Commands
go mod init github.com/Pingu-Studio/MaidnCLI - `cicd-tool repo init` is a legacy GitHub workflow and is not used for new Forgejo/GitOps platform onboarding.
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 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` 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 --config maidn-bootstrap.yaml` skips the wizard and uses the saved config
- `maidn bootstrap init --config <private-config> --organization <new-org> --create-organization --enable-delivery` locks an isolated workspace, initializes the Forgejo repositories, then runs the non-destructive bootstrap reconcile lifecycle; use `--mode=rebuild --yes` for an authorized rebuild. Delivery scaffolding requires `--enable-delivery`. - `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.
- `maidn app onboard --config <private-config> --from <app-checkout>` validates a clean configured checkout and adds its `.tekton` delivery contract. - `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/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. App authors: see `docs/delivery-feedback.md` for preview feedback and the scoped Forgejo token contract.
## Forgejo setup ## 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,7 +1,10 @@
package cmd package cmd
import ( import (
"errors"
"fmt" "fmt"
"os"
"strings"
"github.com/Pingu-Studio/MaidnCLI/internal/bootstrap" "github.com/Pingu-Studio/MaidnCLI/internal/bootstrap"
"github.com/Pingu-Studio/MaidnCLI/internal/config" "github.com/Pingu-Studio/MaidnCLI/internal/config"
@ -21,14 +24,23 @@ var bootstrapPromptOperationalSecrets bool
var bootstrapInitializeOpenBaoRecovery bool var bootstrapInitializeOpenBaoRecovery bool
var bootstrapInitializeOpenBao bool var bootstrapInitializeOpenBao bool
var bootstrapCreateForgejoRegistryToken bool var bootstrapCreateForgejoRegistryToken bool
var bootstrapForgejoPasswordFile string
var bootstrapCreateForgejoDeliveryStatusToken bool
var bootstrapProvisionAppSecretIdentities bool
var bootstrapE2EApp string
var bootstrapRegisterWebhook bool var bootstrapRegisterWebhook bool
var bootstrapRotateWebhookAuthorization bool var bootstrapRotateWebhookAuthorization bool
var bootstrapMergeBootstrapPR bool var bootstrapMergeBootstrapPR bool
var bootstrapManageNetworkBridges bool var bootstrapManageNetworkBridges bool
var bootstrapEnableDelivery bool var bootstrapEnableDelivery bool
var bootstrapDestroyDemocraticCSIStorage bool var bootstrapDestroyDemocraticCSIStorage bool
var bootstrapRefreshTemplateRevisions bool
var upsertOperationalSecret = bootstrap.UpsertOperationalSecret var upsertOperationalSecret = bootstrap.UpsertOperationalSecret
var readOperationalSecrets = bootstrap.ReadOperationalSecrets
var initializeOpenBao = bootstrap.InitializeOpenBao
var createForgejoDeliveryStatusToken = forgejo.CreateDeliveryStatusToken
var promptForgejoDeliveryStatusToken = ui.PromptForgejoDeliveryStatusToken
var bootstrapCmd = &cobra.Command{ var bootstrapCmd = &cobra.Command{
Use: "bootstrap", Use: "bootstrap",
Short: "Bootstrap Talos and Flux from config or an interactive wizard.", Short: "Bootstrap Talos and Flux from config or an interactive wizard.",
@ -47,17 +59,41 @@ func init() {
bootstrapCmd.Flags().BoolVar(&bootstrapInitializeOpenBaoRecovery, "initialize-openbao-recovery", false, "Create and save a separate OpenBao recovery age identity for --config") 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(&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().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(&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().BoolVar(&bootstrapRotateWebhookAuthorization, "rotate-webhook-authorization", false, "Replace the Forgejo webhook authorization and reconcile it through OpenBao")
bootstrapCmd.Flags().BoolVar(&bootstrapMergeBootstrapPR, "merge-bootstrap-pr", false, "Merge the generated Flux repository migration PR before bootstrapping") 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(&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(&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(&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 { 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 cfg config.Config
var err error var err error
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 {
return err
}
if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil {
return err
}
return bootstrap.ProvisionAppSecretIdentities(cfg, bootstrapE2EApp)
}
if bootstrapCreateForgejoRegistryToken { if bootstrapCreateForgejoRegistryToken {
if bootstrapConfigPath == "" { if bootstrapConfigPath == "" {
return fmt.Errorf("--create-forgejo-registry-token requires --config") return fmt.Errorf("--create-forgejo-registry-token requires --config")
@ -75,6 +111,23 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
} }
return createForgejoRegistryToken(cfg) 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 bootstrapRotateWebhookAuthorization {
if bootstrapConfigPath == "" { if bootstrapConfigPath == "" {
return fmt.Errorf("--rotate-webhook-authorization requires --config") return fmt.Errorf("--rotate-webhook-authorization requires --config")
@ -94,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 { 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 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 bootstrapInitializeOpenBao {
if bootstrapConfigPath == "" { if bootstrapConfigPath == "" {
@ -201,10 +254,17 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
} }
} }
runner := bootstrap.Runner{Config: cfg, Mode: bootstrap.Mode(bootstrapMode), ConfirmRebuild: bootstrapYes, RegisterWebhook: bootstrapRegisterWebhook, EnableDelivery: bootstrapEnableDelivery, DestroyDemocraticCSIStorage: bootstrapDestroyDemocraticCSIStorage} runner := bootstrap.Runner{Config: cfg, Mode: bootstrap.Mode(bootstrapMode), ConfirmRebuild: bootstrapYes, RegisterWebhook: bootstrapRegisterWebhook, EnableDelivery: bootstrapEnableDelivery, DestroyDemocraticCSIStorage: bootstrapDestroyDemocraticCSIStorage, RefreshTemplateRevisions: bootstrapRefreshTemplateRevisions}
return runner.Run() 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 { func seedForgejoOperationalCredentials(cfg config.Config) error {
if err := upsertOperationalSecret(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, "cicd/forgejo", "username", cfg.Git.Username); err != nil { 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) return fmt.Errorf("save Forgejo username for webhook registration: %w", err)
@ -219,7 +279,7 @@ func createForgejoRegistryToken(cfg config.Config) error {
if _, err := bootstrap.ReadOperationalSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath); err != nil { if _, err := bootstrap.ReadOperationalSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath); err != nil {
return err return err
} }
password, otp, name, err := ui.PromptForgejoRegistryToken() password, otp, name, err := forgejoRegistryTokenCredentials()
if err != nil { if err != nil {
return err return err
} }
@ -239,3 +299,59 @@ func createForgejoRegistryToken(cfg config.Config) error {
} }
return nil 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" "strings"
"testing" "testing"
"github.com/Pingu-Studio/MaidnCLI/internal/bootstrap"
"github.com/Pingu-Studio/MaidnCLI/internal/config" "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) { func TestCreateForgejoRegistryTokenRequiresConfig(t *testing.T) {
originalConfigPath, originalCreate := bootstrapConfigPath, bootstrapCreateForgejoRegistryToken originalConfigPath, originalCreate := bootstrapConfigPath, bootstrapCreateForgejoRegistryToken
defer func() { defer func() {
@ -62,3 +84,72 @@ func TestSeedForgejoOperationalCredentialsHidesTokenOnUpsertFailure(t *testing.T
t.Fatal("credential upsert failure was not clear and token-safe") 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)
}
}

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)
}
}

View file

@ -9,6 +9,7 @@ import (
) )
var freshConfigPath, freshOrganization, onboardConfigPath, onboardFrom string var freshConfigPath, freshOrganization, onboardConfigPath, onboardFrom string
var onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy, onboardBuildOutputDirectory, onboardBuildConfiguration string
var freshCreateOrganization, freshEnableDelivery, freshYes bool var freshCreateOrganization, freshEnableDelivery, freshYes bool
var freshMode string var freshMode string
@ -31,7 +32,7 @@ var appCmd = &cobra.Command{
var appOnboardCmd = &cobra.Command{ var appOnboardCmd = &cobra.Command{
Use: "onboard", Use: "onboard",
Short: "Validate an application checkout and add its source-owned delivery contract.", Short: "Validate an application checkout and add its centrally-owned delivery contract.",
RunE: runAppOnboard, RunE: runAppOnboard,
} }
@ -49,7 +50,13 @@ func init() {
rootCmd.AddCommand(appCmd) rootCmd.AddCommand(appCmd)
appCmd.AddCommand(appOnboardCmd) appCmd.AddCommand(appOnboardCmd)
appOnboardCmd.Flags().StringVar(&onboardConfigPath, "config", "", "Path to private bootstrap config YAML") appOnboardCmd.Flags().StringVar(&onboardConfigPath, "config", "", "Path to private bootstrap config YAML")
appOnboardCmd.Flags().StringVar(&onboardFrom, "from", "", "Clean application checkout to scaffold") 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("config")
_ = appOnboardCmd.MarkFlagRequired("from") _ = appOnboardCmd.MarkFlagRequired("from")
} }
@ -74,6 +81,24 @@ func runAppOnboard(_ *cobra.Command, _ []string) error {
if err != nil { if err != nil {
return err 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) cfg, err = resolveAppOnboarding(cfg)
if err != nil { if err != nil {
return err return err

View file

@ -65,9 +65,11 @@ func TestBootstrapInitAppliesFluxDefaultsBeforeFreshValidation(t *testing.T) {
func TestAppOnboardValidatesConfigBeforeExternalWork(t *testing.T) { func TestAppOnboardValidatesConfigBeforeExternalWork(t *testing.T) {
originalConfig, originalResolve, originalOnboard := loadAppOnboardConfig, resolveAppOnboarding, onboardApp originalConfig, originalResolve, originalOnboard := loadAppOnboardConfig, resolveAppOnboarding, onboardApp
originalConfigPath, originalFrom := onboardConfigPath, onboardFrom originalConfigPath, originalFrom := onboardConfigPath, onboardFrom
originalName, originalRepo, originalImage, originalBuildStrategy, originalBuildOutput, originalBuildConfiguration := onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy, onboardBuildOutputDirectory, onboardBuildConfiguration
t.Cleanup(func() { t.Cleanup(func() {
loadAppOnboardConfig, resolveAppOnboarding, onboardApp = originalConfig, originalResolve, originalOnboard loadAppOnboardConfig, resolveAppOnboarding, onboardApp = originalConfig, originalResolve, originalOnboard
onboardConfigPath, onboardFrom = originalConfigPath, originalFrom 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 } loadAppOnboardConfig = func(string) (config.Config, error) { return config.Config{}, nil }
resolveAppOnboarding = func(config.Config) (config.Config, error) { return config.Config{}, errors.New("incomplete delivery") } resolveAppOnboarding = func(config.Config) (config.Config, error) { return config.Config{}, errors.New("incomplete delivery") }
@ -76,6 +78,7 @@ func TestAppOnboardValidatesConfigBeforeExternalWork(t *testing.T) {
return nil return nil
} }
onboardConfigPath, onboardFrom = "private.yaml", "app-checkout" onboardConfigPath, onboardFrom = "private.yaml", "app-checkout"
onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy, onboardBuildOutputDirectory, onboardBuildConfiguration = "", "", "", "", "", ""
if err := runAppOnboard(nil, nil); err == nil { if err := runAppOnboard(nil, nil); err == nil {
t.Fatal("onboarding accepted invalid configuration") t.Fatal("onboarding accepted invalid configuration")
} }
@ -84,22 +87,25 @@ func TestAppOnboardValidatesConfigBeforeExternalWork(t *testing.T) {
func TestAppOnboardPassesOnlyValidatedConfigAndCheckout(t *testing.T) { func TestAppOnboardPassesOnlyValidatedConfigAndCheckout(t *testing.T) {
originalConfig, originalResolve, originalOnboard := loadAppOnboardConfig, resolveAppOnboarding, onboardApp originalConfig, originalResolve, originalOnboard := loadAppOnboardConfig, resolveAppOnboarding, onboardApp
originalConfigPath, originalFrom := onboardConfigPath, onboardFrom originalConfigPath, originalFrom := onboardConfigPath, onboardFrom
originalName, originalRepo, originalImage, originalBuildStrategy, originalBuildOutput, originalBuildConfiguration := onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy, onboardBuildOutputDirectory, onboardBuildConfiguration
t.Cleanup(func() { t.Cleanup(func() {
loadAppOnboardConfig, resolveAppOnboarding, onboardApp = originalConfig, originalResolve, originalOnboard loadAppOnboardConfig, resolveAppOnboarding, onboardApp = originalConfig, originalResolve, originalOnboard
onboardConfigPath, onboardFrom = originalConfigPath, originalFrom 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"}} 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 } loadAppOnboardConfig = func(string) (config.Config, error) { return cfg, nil }
resolveAppOnboarding = func(config.Config) (config.Config, error) { return cfg, nil } resolveAppOnboarding = func(got config.Config) (config.Config, error) { return got, nil }
calls := 0 calls := 0
onboardApp = func(got config.Config, checkout string) error { onboardApp = func(got config.Config, checkout string) error {
if checkout != "app-checkout" || got.Delivery.AppRepoURL != cfg.Delivery.AppRepoURL { 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") t.Fatal("onboarding used the wrong checkout or config")
} }
calls++ calls++
return nil return nil
} }
onboardConfigPath, onboardFrom = "private.yaml", "app-checkout" 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 { if err := runAppOnboard(nil, nil); err != nil || calls != 1 {
t.Fatalf("runAppOnboard() = %v, calls = %d", err, calls) t.Fatalf("runAppOnboard() = %v, calls = %d", err, calls)
} }

View file

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

View file

@ -1,19 +1,23 @@
# Delivery feedback # Delivery feedback
`maidn app onboard` adds source-owned Tekton tasks that update one marked The central cluster registration owns the Tekton tasks that update one marked
Forgejo pull-request comment. The comment contains only the verified preview Forgejo pull-request comment. Application repositories do not carry active
URL, a redacted task-status summary, and the PipelineRun name. Set the optional delivery Tasks. The comment contains only the verified preview URL, a redacted
`delivery.tektonDashboardUrl` to a credential-free HTTPS Tekton Dashboard task-status summary, and the PipelineRun name. Set the optional
origin to add a PipelineRun link. `delivery.tektonDashboardUrl` to a credential-free HTTPS Tekton Dashboard origin
to add a PipelineRun link.
Before enabling delivery feedback, store a separate Forgejo token at Before enabling delivery feedback, create the separate Forgejo token with
`cicd/forgejo-delivery-status.token` in encrypted operational secrets. Scope it `bootstrap --config <private-bootstrap-config> --create-forgejo-delivery-status-token`.
only to the onboarded application repositories and to creating/updating issue It creates or reuses `maidn-delivery-status` at
comments; do not reuse the Git clone/push token. The generated task never `cicd/forgejo-delivery-status.token`, with only `write:issue` and
prints the token or Forgejo API responses. `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 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 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 manifest-repository promotion PR; it does not claim a production deploy.
The chart must name both resources after `delivery.appName`; the HTTPRoute's The protected `maidn/platform-<app>` chart must name both resources after
first hostname must be the public HTTPS preview/staging URL. `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,31 +23,80 @@ new template revisions intentionally, use a new empty `workspaceDir` (and a
fresh `cloneParent` when it is configured separately) and keep the prior fresh `cloneParent` when it is configured separately) and keep the prior
secret-bearing workspace intact for recovery. secret-bearing workspace intact for recovery.
## Platform Initialization And App Onboarding ## Standard Delivery Workflow
Initialize the shared delivery platform before onboarding any application. This 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 creates the Gateway, Tekton, and Tekton Triggers platform resources using
`tekton.<cluster-domain>` and the configured Forgejo owner; it does not render `tekton.<cluster-domain>` and the configured Forgejo owner:
an application Pipeline or register an application webhook:
```powershell ```powershell
bootstrap init --config <private-bootstrap-config> --organization <owner> --create-organization go run . bootstrap init --config <private-bootstrap-config> --organization <owner> --create-organization
``` ```
Import a clean checkout into the configured owner with a separate command. The The source-owned onboarding implementation is retired. Central onboarding
checkout may originate in another Forgejo organization, but `delivery.appRepoUrl` creates a reviewed cluster registration only; it never writes `.tekton` or
must target `<owner>/<app>.git` and its current branch must be `.maidn` resources to an application repository. Before merging that
`delivery.appRepoRef`: 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 ```powershell
app onboard --config <private-bootstrap-config> --from <clean-checkout> 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>
``` ```
Onboarding copies the current branch to the target repository, replaces only For a runtime build, set `--build-strategy runtime`; static-only output options
the two Maidn-generated `.tekton` files, protects the production branch, and remain harmless. Central onboarding creates the reviewed cluster-registration
auto-merges the app delivery and cluster registration PRs. The registration is change after the platform package exists. The cluster repository owns
stored in `base/tekton/apps/<app>.yaml` in the configured cluster repository; `base/tekton/apps/<app>.yaml`; the application repository remains a build input
the generic EventListener dispatches by the Forgejo repository name. 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.productionBranch` is the application production branch. A production
delivery PR targets the configured generic delivery PR targets the configured generic

View file

@ -21,6 +21,21 @@ Include Forgejo API access, Flux source authentication, webhook authorization, T
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. 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. 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 ## 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. 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.

View file

@ -8,9 +8,9 @@ gets one reviewed grant.
| Consumer | OpenBao path | Kubernetes namespace | Intended use | | Consumer | OpenBao path | Kubernetes namespace | Intended use |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `build` | `apps/<app>/build/*` | `tekton-pipelines` | Read-only dependency credentials | | `build` | declared `apps/<app>/<secret>` entries | `tekton-pipelines` | Read-only dependency credentials |
| `publish` | `apps/<app>/publish/*` | `tekton-pipelines` | One app's artifact repository credential | | `publish` | declared `apps/<app>/<secret>` entries | `tekton-pipelines` | One app's artifact repository credential |
| `runtime` | `apps/<app>/runtime/<environment>/*` | `<app>-<environment>` | Service runtime credentials | | `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 | | 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 `build` code is repository-controlled. Anything granted to it is readable by a
@ -24,23 +24,30 @@ secret values and is reviewed with the platform configuration:
```yaml ```yaml
secretGrants: secretGrants:
- application: orders-api
consumer: publish
secrets:
- registry
shared:
- internal-npm
- application: orders-api - application: orders-api
consumer: publish consumer: runtime
shared: environment: staging
- internal-npm secrets:
- application: orders-api - database-staging
consumer: runtime
environment: staging
shared: shared:
- rabbitmq - rabbitmq
- application: orders-api - application: orders-api
consumer: runtime consumer: runtime
environment: production environment: production
secrets:
- database-production
shared: shared:
- rabbitmq - rabbitmq
``` ```
MaidnCLI validates application, consumer, environment, and shared-grant names. MaidnCLI validates application, consumer, environment, application-secret, and
shared-grant names.
It creates one OpenBao policy and Kubernetes-auth role for every declaration. It creates one OpenBao policy and Kubernetes-auth role for every declaration.
The role names are deterministic: The role names are deterministic:
@ -50,10 +57,12 @@ maidn-<app>-publish
maidn-<app>-runtime-<environment> maidn-<app>-runtime-<environment>
``` ```
The policy permits only the consumer's own path and the exact `shared/<name>` The policy permits only the exact `apps/<app>/<secret>` paths and
paths listed in its declaration. A shared value is stored once, for example at `shared/<name>/*` paths listed in its declaration. The CLI stores one property
`shared/rabbitmq`, and each service requiring it declares that same shared named `value` at each `apps/<app>/<secret>` or `shared/<group>/<secret>` path.
grant. Do not copy it into application paths. 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 ## GitOps resources
@ -102,15 +111,15 @@ spec:
name: orders-api-rabbitmq name: orders-api-rabbitmq
creationPolicy: Owner creationPolicy: Owner
data: data:
- secretKey: password - secretKey: value
remoteRef: remoteRef:
key: shared/rabbitmq key: shared/rabbitmq/password
property: password property: value
``` ```
The workload references only `orders-api-rabbitmq` in its own namespace. Each The workload references only `orders-api-rabbitmq` in its own namespace. Each
application/environment needs a separate namespace; do not put runtime application gets a dedicated ServiceAccount, SecretStore, and target Secret in
secrets in shared `staging` or `production` namespaces. the shared environment namespace; do not use another application's identity.
## Artifact repositories ## Artifact repositories
@ -127,8 +136,10 @@ code and therefore requires a narrowly scoped, disposable credential.
## Operations ## Operations
1. Create the least-privilege upstream credential. 1. Create the least-privilege upstream credential.
2. Write its value to the declared OpenBao path through a secure stdin-based 2. Write its value with `cicd-tool app secret set` using stdin, `--file`, or
operator workflow. Never put it in YAML, a URL, a command argument, or Git. `--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. 3. Add the reviewed grant and GitOps resources.
4. Bootstrap or reconcile to create the OpenBao role and policy. 4. Bootstrap or reconcile to create the OpenBao role and policy.
5. Verify the target ExternalSecret becomes Ready without printing its Secret. 5. Verify the target ExternalSecret becomes Ready without printing its Secret.

View file

@ -12,7 +12,7 @@
`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. `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.
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 and webhook-authorization flows also require an explicit configuration; they are live credential operations and are not offline-safe. 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.
## Rules ## Rules
@ -21,3 +21,14 @@ The webhook-only path requires a complete delivery contract, an approved configu
- Do not revoke a previous credential until OpenBao, External Secrets, and every listed consumer have passed validation. - 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. - 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. - Application, artifact, and shared-secret access is documented in [secret-grants.md](secret-grants.md). Secret values remain outside that declaration.
## 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

@ -27,10 +27,6 @@ spec:
- name: environment - name: environment
- name: pr-number - name: pr-number
default: "" default: ""
- name: app-url
default: ""
- name: app-revision
default: ""
- name: forgejo-base-url - name: forgejo-base-url
default: "" default: ""
- name: forgejo-owner - name: forgejo-owner
@ -57,10 +53,6 @@ spec:
value: $(params.environment) value: $(params.environment)
- name: PR_NUMBER - name: PR_NUMBER
value: $(params.pr-number) value: $(params.pr-number)
- name: APP_URL
value: $(params.app-url)
- name: APP_REVISION
value: $(params.app-revision)
- name: FORGEJO_BASE_URL - name: FORGEJO_BASE_URL
value: $(params.forgejo-base-url) value: $(params.forgejo-base-url)
- name: FORGEJO_OWNER - name: FORGEJO_OWNER
@ -102,8 +94,6 @@ spec:
cd /tmp/manifests cd /tmp/manifests
if [ "$ENVIRONMENT" = preview ]; then if [ "$ENVIRONMENT" = preview ]; then
valid_pr_number "$PR_NUMBER" valid_pr_number "$PR_NUMBER"
valid_url "$APP_URL"
valid_revision "$APP_REVISION"
app_dir="apps/previews/$APP_NAME-pr-$PR_NUMBER" app_dir="apps/previews/$APP_NAME-pr-$PR_NUMBER"
marker="$app_dir/ownership.yaml" marker="$app_dir/ownership.yaml"
if [ -e "$app_dir" ]; then if [ -e "$app_dir" ]; then
@ -129,12 +119,7 @@ spec:
EOF EOF
cmp -s "$expected_marker" "$marker" || fail cmp -s "$expected_marker" "$marker" || fail
fi fi
git clone "$APP_URL" /tmp/app
git -C /tmp/app checkout "$APP_REVISION"
[ -f /tmp/app/preview/values.yaml ] || fail
mkdir -p "$app_dir" mkdir -p "$app_dir"
cp /tmp/app/preview/values.yaml "$app_dir/values.yaml"
sed -i "s/PLACEHOLDER_PR/$PR_NUMBER/g" "$app_dir/values.yaml"
cat > "$marker" <<EOF cat > "$marker" <<EOF
apiVersion: v1 apiVersion: v1
kind: ConfigMap kind: ConfigMap
@ -169,14 +154,20 @@ spec:
chart: chart:
spec: spec:
chart: ./charts/$APP_NAME chart: ./charts/$APP_NAME
reconcileStrategy: Revision
sourceRef: sourceRef:
kind: GitRepository kind: GitRepository
name: $APP_NAME name: $APP_NAME
namespace: flux-system namespace: flux-system
valuesFiles:
- ./charts/$APP_NAME/values.yaml
- ./preview/values.yaml
values: values:
image: image:
repository: $IMAGE repository: $IMAGE
tag: $TAG tag: $TAG
gateway:
hostname: $APP_NAME-pr-$PR_NUMBER.{{ .ClusterDomain }}
EOF EOF
cat > "$app_dir/kustomization.yaml" <<EOF cat > "$app_dir/kustomization.yaml" <<EOF
apiVersion: kustomize.config.k8s.io/v1beta1 apiVersion: kustomize.config.k8s.io/v1beta1
@ -212,10 +203,14 @@ spec:
chart: chart:
spec: spec:
chart: ./charts/$APP_NAME chart: ./charts/$APP_NAME
reconcileStrategy: Revision
sourceRef: sourceRef:
kind: GitRepository kind: GitRepository
name: $APP_NAME name: $APP_NAME
namespace: flux-system namespace: flux-system
valuesFiles:
- ./charts/$APP_NAME/values.yaml
- ./staging/values.yaml
values: values:
image: image:
repository: $IMAGE repository: $IMAGE
@ -259,10 +254,14 @@ spec:
chart: chart:
spec: spec:
chart: ./charts/$APP_NAME chart: ./charts/$APP_NAME
reconcileStrategy: Revision
sourceRef: sourceRef:
kind: GitRepository kind: GitRepository
name: $APP_NAME name: $APP_NAME
namespace: flux-system namespace: flux-system
valuesFiles:
- ./charts/$APP_NAME/values.yaml
- ./production/values.yaml
values: values:
image: image:
repository: $IMAGE repository: $IMAGE
@ -628,58 +627,29 @@ spec:
default: {{ quote .ForgejoOwner }} default: {{ quote .ForgejoOwner }}
- name: manifests-repo - name: manifests-repo
default: {{ quote .ManifestsRepo }} default: {{ quote .ManifestsRepo }}
workspaces:
- name: source
tasks: tasks:
- name: clone - name: build-layer
when: when:
- input: $(params.event-action) - input: $(params.event-action)
operator: notin operator: notin
values: [closed] values: [closed]
taskRef: taskRef:
name: maidn-git-clone name: {{ if eq .BuildStrategy "static" }}maidn-node-static-image{{ else }}maidn-node-runtime-image{{ end }}
params: params:
- name: url - name: url
value: {{ quote .AppRepoURL }} value: {{ quote .AppRepoURL }}
- name: revision - name: revision
value: $(params.git-revision) value: $(params.git-revision)
workspaces: - name: image
- name: source value: $(params.image)
workspace: source {{ if eq .BuildStrategy "static" }}
- name: build-layer
runAfter: [clone]
when:
- input: $(params.event-action)
operator: notin
values: [closed]
taskRef:
name: maidn-node-static-build
params:
- name: output-directory - name: output-directory
value: {{ quote .BuildOutputDirectory }} value: {{ quote .BuildOutputDirectory }}
- name: build-configuration - name: build-configuration
value: {{ quote .BuildConfiguration }} value: {{ quote .BuildConfiguration }}
workspaces: {{ end }}
- name: source
workspace: source
- name: push
runAfter: [build-layer]
when:
- input: $(params.event-action)
operator: notin
values: [closed]
taskRef:
name: maidn-node-static-push
params:
- name: image
value: $(params.image)
- name: revision
value: $(params.git-revision)
workspaces:
- name: source
workspace: source
- name: update-preview - name: update-preview
runAfter: [push] runAfter: [build-layer]
when: when:
- input: $(params.event-type) - input: $(params.event-type)
operator: in operator: in
@ -706,12 +676,8 @@ spec:
value: preview value: preview
- name: pr-number - name: pr-number
value: $(params.pr-number) value: $(params.pr-number)
- name: app-url
value: {{ quote .AppRepoURL }}
- name: app-revision
value: $(params.git-revision)
- name: update-staging - name: update-staging
runAfter: [push] runAfter: [build-layer]
when: when:
- input: $(params.event-type) - input: $(params.event-type)
operator: in operator: in
@ -779,7 +745,7 @@ spec:
- name: tag - name: tag
value: $(params.git-revision) value: $(params.git-revision)
- name: promote-production - name: promote-production
runAfter: [push] runAfter: [build-layer]
when: when:
- input: $(params.event-type) - input: $(params.event-type)
operator: in operator: in
@ -847,11 +813,11 @@ spec:
- name: pipeline-run - name: pipeline-run
value: $(context.pipelineRun.name) value: $(context.pipelineRun.name)
- name: clone-status - name: clone-status
value: $(tasks.clone.status) value: $(tasks.build-layer.status)
- name: build-status - name: build-status
value: $(tasks.build-layer.status) value: $(tasks.build-layer.status)
- name: push-status - name: push-status
value: $(tasks.push.status) value: $(tasks.build-layer.status)
- name: update-status - name: update-status
value: $(tasks.update-preview.status) value: $(tasks.update-preview.status)
- name: readiness-status - name: readiness-status

View file

@ -44,10 +44,12 @@ type Runner struct {
Mode Mode Mode Mode
ConfirmRebuild bool ConfirmRebuild bool
RegisterWebhook bool RegisterWebhook bool
RefreshWebhookSecret bool
EnableDelivery bool EnableDelivery bool
SkipDeliveryScaffolding bool SkipDeliveryScaffolding bool
AutoMergeBootstrapMigration bool AutoMergeBootstrapMigration bool
DestroyDemocraticCSIStorage bool DestroyDemocraticCSIStorage bool
RefreshTemplateRevisions bool
} }
type operationalSecrets struct { type operationalSecrets struct {
@ -73,6 +75,8 @@ var ensureForgejoWebhook = func(cfg config.Config, repo, webhookURL, authorizati
return manager.EnsureWebhook(repo, webhookURL, authorization) return manager.EnsureWebhook(repo, webhookURL, authorization)
} }
var readOperationalSecrets = ReadOperationalSecrets
var runWebhookCommand = utils.RunCommandQuietOutputInDir var runWebhookCommand = utils.RunCommandQuietOutputInDir
var preflight = config.Preflight var preflight = config.Preflight
@ -170,6 +174,15 @@ func (r Runner) Run() error {
} }
r.Config = resolvedDelivery r.Config = resolvedDelivery
} }
if r.Mode, err = resolveLifecycleMode(r.Mode, r.ConfirmRebuild); err != nil {
return err
}
if r.RefreshTemplateRevisions && r.Mode != Reconcile {
return errors.New("--refresh-template-revisions requires --mode=reconcile")
}
if r.DestroyDemocraticCSIStorage && r.Mode != Rebuild {
return errors.New("--destroy-democratic-csi-storage requires --mode=rebuild --yes")
}
if err := preflight(r.Config); err != nil { if err := preflight(r.Config); err != nil {
return fmt.Errorf("preflight: %w", err) return fmt.Errorf("preflight: %w", err)
} }
@ -184,13 +197,12 @@ func (r Runner) Run() error {
return errors.New("SOPS recoveryRecipient is required") return errors.New("SOPS recoveryRecipient is required")
} }
} }
if r.Mode, err = resolveLifecycleMode(r.Mode, r.ConfirmRebuild); err != nil {
return err
}
if r.DestroyDemocraticCSIStorage && r.Mode != Rebuild {
return errors.New("--destroy-democratic-csi-storage requires --mode=rebuild --yes")
}
if r.RegisterWebhook { if r.RegisterWebhook {
if r.RefreshTemplateRevisions {
if err := RefreshTemplateRevisions(r.Config); err != nil {
return err
}
}
return r.reconcileWebhook(filepath.Join(r.Config.Git.CloneParent, r.Config.Talos.RepoDirName, r.Config.Talos.GeneratedDir)) return r.reconcileWebhook(filepath.Join(r.Config.Git.CloneParent, r.Config.Talos.RepoDirName, r.Config.Talos.GeneratedDir))
} }
if r.Config.SOPS.RecoveryIdentityPath == "" || r.Config.SOPS.RecoveryBundlePath == "" { if r.Config.SOPS.RecoveryIdentityPath == "" || r.Config.SOPS.RecoveryBundlePath == "" {
@ -203,8 +215,14 @@ func (r Runner) Run() error {
return fmt.Errorf("preflight OpenBao recovery bundle: %w", err) return fmt.Errorf("preflight OpenBao recovery bundle: %w", err)
} }
workspace := r.Config.WorkspaceDir workspace := r.Config.WorkspaceDir
if err := EnsureTemplateRevisions(r.Config); err != nil { if r.RefreshTemplateRevisions {
return err if err := RefreshTemplateRevisions(r.Config); err != nil {
return err
}
} else {
if err := EnsureTemplateRevisions(r.Config); err != nil {
return err
}
} }
catalogManager := forgejo.NewRepoManager(r.Config.Git.BaseURL, r.Config.Git.Token, r.Config.Git.Owner, r.Config.Git.Username, "", "", r.Config.Templates.TektonCatalogRepoRef, "") catalogManager := forgejo.NewRepoManager(r.Config.Git.BaseURL, r.Config.Git.Token, r.Config.Git.Owner, r.Config.Git.Username, "", "", r.Config.Templates.TektonCatalogRepoRef, "")
if _, err := catalogManager.EnsureRepositoryCopy(r.Config.Flux.TektonCatalogRepo, "User-managed Tekton pipeline catalog", r.Config.Templates.TektonCatalogRepoURL); err != nil { if _, err := catalogManager.EnsureRepositoryCopy(r.Config.Flux.TektonCatalogRepo, "User-managed Tekton pipeline catalog", r.Config.Templates.TektonCatalogRepoURL); err != nil {
@ -398,15 +416,26 @@ func (r Runner) reconcileCloudflareTunnel() error {
} }
func (r Runner) reconcileWebhook(generatedDir string) error { func (r Runner) reconcileWebhook(generatedDir string) error {
operationalSecrets, err := r.initializeOpenBaoForCluster(generatedDir) var (
if err != nil { operationalSecrets map[string]map[string]string
return fmt.Errorf("initialize OpenBao: %w", err) err error
)
if r.RefreshWebhookSecret {
operationalSecrets, err = r.initializeOpenBaoForCluster(generatedDir)
if err != nil {
return fmt.Errorf("initialize OpenBao: %w", err)
}
} else {
operationalSecrets, err = readOperationalSecrets(r.Config.SOPS.OperationalSecretsPath, r.Config.SOPS.AgeKeyPath)
if err != nil {
return fmt.Errorf("read encrypted webhook authorization: %w", err)
}
} }
authorization := operationalSecrets["cicd/forgejo-webhook"]["authorization"] authorization := operationalSecrets["cicd/forgejo-webhook"]["authorization"]
if authorization == "" { if authorization == "" {
return errors.New("operational SOPS secrets requires cicd/forgejo-webhook.authorization") return errors.New("operational SOPS secrets requires cicd/forgejo-webhook.authorization")
} }
if err := waitForWebhookTargets(generatedDir, r.Config, authorization); err != nil { if err := waitForWebhookTargets(generatedDir, r.Config); err != nil {
return err return err
} }
if err := ensureForgejoWebhook(r.Config, r.Config.Delivery.AppName, r.Config.Delivery.WebhookURL(), authorization); err != nil { if err := ensureForgejoWebhook(r.Config, r.Config.Delivery.AppName, r.Config.Delivery.WebhookURL(), authorization); err != nil {
@ -479,12 +508,34 @@ func renderPlatformDeliveryConfig(dir string, cfg config.Config) error {
func copyAndRenderPlatformDeliveryBases(templateDir, repoDir string, cfg config.Config) error { func copyAndRenderPlatformDeliveryBases(templateDir, repoDir string, cfg config.Config) error {
bases := []string{"gateway", "tekton", "tekton-triggers"} bases := []string{"gateway", "tekton", "tekton-triggers"}
apps := filepath.Join(repoDir, "base", "tekton", "apps")
backup := filepath.Join(repoDir, ".maidn-preserve-tekton-apps")
if err := os.RemoveAll(backup); err != nil {
return err
}
if _, err := os.Stat(apps); err == nil {
if err := os.Rename(apps, backup); err != nil {
return err
}
} else if !os.IsNotExist(err) {
return err
}
for _, base := range bases { for _, base := range bases {
baseDir := filepath.Join(repoDir, "base", base) baseDir := filepath.Join(repoDir, "base", base)
if err := copyDir(filepath.Join(templateDir, "base", base), baseDir, true); err != nil { if err := copyDir(filepath.Join(templateDir, "base", base), baseDir, true); err != nil {
return err return err
} }
} }
if _, err := os.Stat(backup); err == nil {
if err := os.RemoveAll(apps); err != nil {
return err
}
if err := os.Rename(backup, apps); err != nil {
return err
}
} else if !os.IsNotExist(err) {
return err
}
for _, base := range bases { for _, base := range bases {
baseDir := filepath.Join(repoDir, "base", base) baseDir := filepath.Join(repoDir, "base", base)
if err := renderPlatformDeliveryConfig(baseDir, cfg); err != nil { if err := renderPlatformDeliveryConfig(baseDir, cfg); err != nil {
@ -510,9 +561,9 @@ func writePreviewDeliveryConfig(dir string, cfg config.Config) error {
Kind: "ConfigMap", Kind: "ConfigMap",
Metadata: map[string]string{"name": "maidn-preview-delivery-config", "namespace": "tekton-pipelines"}, Metadata: map[string]string{"name": "maidn-preview-delivery-config", "namespace": "tekton-pipelines"},
Data: map[string]string{ Data: map[string]string{
"forgejo-origin": origin, "forgejo-origin": origin,
"manifests-url": manifestsURL, "manifests-url": manifestsURL,
"manifests-branch": cfg.Flux.Branch, "manifests-branch": cfg.Flux.Branch,
"tekton-dashboard-url": cfg.Delivery.TektonDashboardURL, "tekton-dashboard-url": cfg.Delivery.TektonDashboardURL,
}, },
}) })
@ -573,6 +624,7 @@ type appDeliveryTemplateConfig struct {
AppRepoRef string AppRepoRef string
ProductionBranch string ProductionBranch string
ImageRepository string ImageRepository string
BuildStrategy string
BuildOutputDirectory string BuildOutputDirectory string
BuildConfiguration string BuildConfiguration string
ForgejoBaseURL string ForgejoBaseURL string
@ -580,68 +632,18 @@ type appDeliveryTemplateConfig struct {
ManifestsURL string ManifestsURL string
ManifestsRepo string ManifestsRepo string
ManifestsBranch string ManifestsBranch string
ClusterDomain string
} }
var deliveryAppName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,45}[a-z0-9])?$`) var deliveryAppName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,45}[a-z0-9])?$`)
// GenerateAppDelivery writes the source-owned Tekton delivery contract for an app checkout.
func GenerateAppDelivery(dir string, cfg config.Config) error {
if err := config.ValidateDelivery(cfg); err != nil {
return err
}
content, err := renderAppDelivery(cfg)
if err != nil {
return err
}
files := map[string][]byte{
"kustomization.yaml": []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n - pipeline.yaml\n"),
"pipeline.yaml": content,
}
target := filepath.Join(dir, ".tekton")
info, err := os.Lstat(target)
if err == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return errors.New("app delivery .tekton path must be a directory")
}
entries, err := os.ReadDir(target)
if err != nil {
return err
}
if len(entries) != len(files) {
return errors.New("app delivery .tekton contains unmanaged files")
}
for name, content := range files {
file := filepath.Join(target, name)
fileInfo, err := os.Lstat(file)
if err != nil || fileInfo.Mode()&os.ModeSymlink != 0 || !fileInfo.Mode().IsRegular() {
return errors.New("app delivery .tekton contains unmanaged files")
}
if err := os.WriteFile(file, content, 0644); err != nil {
return err
}
}
return nil
}
if !os.IsNotExist(err) {
return err
}
temporary, err := os.MkdirTemp(dir, ".maidn-tekton-")
if err != nil {
return err
}
defer os.RemoveAll(temporary)
for name, content := range files {
if err := os.WriteFile(filepath.Join(temporary, name), content, 0644); err != nil {
return err
}
}
return os.Rename(temporary, target)
}
func renderAppDelivery(cfg config.Config) ([]byte, error) { func renderAppDelivery(cfg config.Config) ([]byte, error) {
if !deliveryAppName.MatchString(cfg.Delivery.AppName) { if !deliveryAppName.MatchString(cfg.Delivery.AppName) {
return nil, errors.New("delivery appName must be a lowercase DNS label") return nil, errors.New("delivery appName must be a lowercase DNS label")
} }
if cfg.Delivery.BuildStrategy != "static" && cfg.Delivery.BuildStrategy != "runtime" {
return nil, errors.New("delivery buildStrategy must be static or runtime")
}
appRepository, err := deliveryRepository(cfg.Git.BaseURL, cfg.Delivery.AppRepoURL) appRepository, err := deliveryRepository(cfg.Git.BaseURL, cfg.Delivery.AppRepoURL)
if err != nil { if err != nil {
return nil, err return nil, err
@ -653,10 +655,10 @@ func renderAppDelivery(cfg config.Config) ([]byte, error) {
values := appDeliveryTemplateConfig{ values := appDeliveryTemplateConfig{
AppName: cfg.Delivery.AppName, AppRepository: appRepository, AppRepoURL: cfg.Delivery.AppRepoURL, AppName: cfg.Delivery.AppName, AppRepository: appRepository, AppRepoURL: cfg.Delivery.AppRepoURL,
AppRepoRef: cfg.Delivery.AppRepoRef, ProductionBranch: cfg.Delivery.ProductionBranch, ImageRepository: cfg.Delivery.ImageRepository, AppRepoRef: cfg.Delivery.AppRepoRef, ProductionBranch: cfg.Delivery.ProductionBranch, ImageRepository: cfg.Delivery.ImageRepository,
BuildOutputDirectory: cfg.Delivery.BuildOutputDirectory, BuildConfiguration: cfg.Delivery.BuildConfiguration, BuildStrategy: cfg.Delivery.BuildStrategy, BuildOutputDirectory: cfg.Delivery.BuildOutputDirectory, BuildConfiguration: cfg.Delivery.BuildConfiguration,
ForgejoBaseURL: origin, ForgejoOwner: cfg.Git.Owner, ManifestsURL: forgejo.CloneURL(origin, cfg.Git.Owner, cfg.Flux.ManifestsRepo), ManifestsRepo: cfg.Flux.ManifestsRepo, ManifestsBranch: cfg.Flux.Branch, ForgejoBaseURL: origin, ForgejoOwner: cfg.Git.Owner, ManifestsURL: forgejo.CloneURL(origin, cfg.Git.Owner, cfg.Flux.ManifestsRepo), ManifestsRepo: cfg.Flux.ManifestsRepo, ManifestsBranch: cfg.Flux.Branch, ClusterDomain: cfg.Flux.ClusterDomain,
} }
for name, value := range map[string]string{"appRepository": values.AppRepository, "appRepoUrl": values.AppRepoURL, "appRepoRef": values.AppRepoRef, "productionBranch": values.ProductionBranch, "imageRepository": values.ImageRepository, "buildOutputDirectory": values.BuildOutputDirectory, "buildConfiguration": values.BuildConfiguration, "forgejoBaseUrl": values.ForgejoBaseURL, "forgejoOwner": values.ForgejoOwner, "manifestsUrl": values.ManifestsURL, "manifestsRepo": values.ManifestsRepo, "manifestsBranch": values.ManifestsBranch} { for name, value := range map[string]string{"appRepository": values.AppRepository, "appRepoUrl": values.AppRepoURL, "appRepoRef": values.AppRepoRef, "productionBranch": values.ProductionBranch, "imageRepository": values.ImageRepository, "buildOutputDirectory": values.BuildOutputDirectory, "buildConfiguration": values.BuildConfiguration, "forgejoBaseUrl": values.ForgejoBaseURL, "forgejoOwner": values.ForgejoOwner, "manifestsUrl": values.ManifestsURL, "manifestsRepo": values.ManifestsRepo, "manifestsBranch": values.ManifestsBranch, "clusterDomain": values.ClusterDomain} {
if value == "" || strings.ContainsAny(value, "\r\n") || config.RedactURL(value) != value { if value == "" || strings.ContainsAny(value, "\r\n") || config.RedactURL(value) != value {
return nil, fmt.Errorf("delivery %s cannot be empty or contain credentials", name) return nil, fmt.Errorf("delivery %s cannot be empty or contain credentials", name)
} }
@ -669,7 +671,7 @@ func renderAppDelivery(cfg config.Config) ([]byte, error) {
if err := tmpl.Execute(&rendered, values); err != nil { if err := tmpl.Execute(&rendered, values); err != nil {
return nil, err return nil, err
} }
return rendered.Bytes(), nil return bytes.ReplaceAll(rendered.Bytes(), []byte("\r\n"), []byte("\n")), nil
} }
func deliveryRepository(baseURL, repositoryURL string) (string, error) { func deliveryRepository(baseURL, repositoryURL string) (string, error) {
@ -985,6 +987,23 @@ func InitializeOpenBao(cfg config.Config) error {
return nil return nil
} }
// ProvisionAppSecretIdentities rotates short-lived restricted tokens and
// immediately reseeds encrypted operational state through OpenBao.
func ProvisionAppSecretIdentities(cfg config.Config, app string) error {
kubeconfig := filepath.Join(cfg.Git.CloneParent, cfg.Talos.RepoDirName, cfg.Talos.GeneratedDir, "kubeconfig")
tokens, err := openbao.ProvisionAppSecretIdentities(kubeconfig, cfg.SOPS.RecoveryIdentityPath, cfg.SOPS.RecoveryBundlePath, app)
if err != nil {
return err
}
if err := UpsertOperationalSecret(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, "cicd/app-secret-admin", "token", tokens.Admin); err != nil {
return errors.New("save app-secret admin token")
}
if err := UpsertOperationalSecret(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, "cicd/e2e-"+app, "token", tokens.E2E); err != nil {
return errors.New("save E2E app-secret token")
}
return InitializeOpenBao(cfg)
}
func NewWebhookAuthorization() (string, error) { func NewWebhookAuthorization() (string, error) {
value := make([]byte, 32) value := make([]byte, 32)
if _, err := rand.Read(value); err != nil { if _, err := rand.Read(value); err != nil {
@ -1059,38 +1078,22 @@ func renderDemocraticCSISecret(csi config.DemocraticCSIConfig) ([]byte, error) {
}) })
} }
func waitForWebhookTargets(dir string, cfg config.Config, authorization string) error { func waitForWebhookTargets(dir string, _ config.Config) error {
if err := waitForWebhookAuthorization(dir, authorization); err != nil { if err := waitForWebhookExternalSecret(dir); err != nil {
return err return err
} }
resources := []string{"pipeline/" + cfg.Delivery.AppName}
for _, resource := range resources {
deadline := time.Now().Add(webhookTargetTimeout)
for time.Now().Before(deadline) {
if _, err := runWebhookCommand(dir, "kubectl", "--kubeconfig=kubeconfig", "-n", "tekton-pipelines", "get", resource); err == nil {
break
}
time.Sleep(webhookTargetPollInterval)
}
if _, err := runWebhookCommand(dir, "kubectl", "--kubeconfig=kubeconfig", "-n", "tekton-pipelines", "get", resource); err != nil {
return fmt.Errorf("wait for %s before registering Forgejo webhook", resource)
}
}
return nil return nil
} }
func waitForWebhookAuthorization(dir, authorization string) error { func waitForWebhookExternalSecret(dir string) error {
deadline := time.Now().Add(webhookTargetTimeout) deadline := time.Now().Add(webhookTargetTimeout)
for { for {
output, err := runWebhookCommand(dir, "kubectl", "--kubeconfig=kubeconfig", "-n", "tekton-pipelines", "get", "secret/forgejo-webhook", "-o=jsonpath={.data.authorization}") output, err := runWebhookCommand(dir, "kubectl", "--kubeconfig=kubeconfig", "-n", "tekton-pipelines", "get", "externalsecret/forgejo-webhook", "-o=jsonpath={.status.conditions[0].status}")
if err == nil { if err == nil && strings.TrimSpace(string(output)) == "True" {
observed, decodeErr := base64.StdEncoding.DecodeString(strings.TrimSpace(string(output))) return nil
if decodeErr == nil && string(observed) == authorization {
return nil
}
} }
if !time.Now().Before(deadline) { if !time.Now().Before(deadline) {
return errors.New("ExternalSecret target Secret forgejo-webhook did not refresh within the timeout; Forgejo webhook was not updated. Wait for External Secrets to recover, then safely rerun cicd-tool bootstrap --config <config> --register-webhook") return errors.New("ExternalSecret forgejo-webhook did not become ready within the timeout; Forgejo webhook was not updated. Wait for External Secrets to recover, then safely rerun cicd-tool bootstrap --config <config> --register-webhook")
} }
time.Sleep(webhookTargetPollInterval) time.Sleep(webhookTargetPollInterval)
} }
@ -1122,7 +1125,15 @@ func ensureClusterKustomizations(clusterDir string, includeDelivery bool) error
func copyTemplateBaseComponents(templateDir, repoDir string, includeDelivery bool) error { func copyTemplateBaseComponents(templateDir, repoDir string, includeDelivery bool) error {
for _, component := range templateBaseComponents { for _, component := range templateBaseComponents {
if err := copyDirExcept(filepath.Join(templateDir, "base", component), filepath.Join(repoDir, "base", component), true, generatedTemplateFiles[component]); err != nil { excluded := generatedTemplateFiles[component]
if component == "tekton" {
excluded = make(map[string]bool, len(excluded)+1)
for path, skip := range generatedTemplateFiles[component] {
excluded[path] = skip
}
excluded["apps"] = true
}
if err := copyDirExcept(filepath.Join(templateDir, "base", component), filepath.Join(repoDir, "base", component), true, excluded); err != nil {
return err return err
} }
} }
@ -1674,6 +1685,42 @@ func EnsureTemplateRevisions(cfg config.Config) error {
return ensureTemplateRevisions(cfg.WorkspaceDir, cfg) return ensureTemplateRevisions(cfg.WorkspaceDir, cfg)
} }
// RefreshTemplateRevisions replaces an existing template lock with configured ref heads.
func RefreshTemplateRevisions(cfg config.Config) error {
return refreshTemplateRevisions(cfg.WorkspaceDir, cfg)
}
func refreshTemplateRevisions(workspace string, cfg config.Config) error {
lockPath := filepath.Join(workspace, "maidn-template-revisions.yaml")
lock, err := readTemplateRevisionLock(lockPath)
if err != nil {
if os.IsNotExist(err) {
return errors.New("template revision lock does not exist; run bootstrap without --refresh-template-revisions first")
}
return errors.New("template revision lock is invalid")
}
checkouts := []templateCheckout{
{Dir: filepath.Join(workspace, "maidn-cicd-cluster-template"), Repository: cfg.Templates.CICDRepoURL, Ref: cfg.Templates.CICDRepoRef},
{Dir: filepath.Join(workspace, "cicd-deployment-manifests-template"), Repository: cfg.Templates.ManifestsRepoURL, Ref: cfg.Templates.ManifestsRepoRef},
{Dir: filepath.Join(cfg.Git.CloneParent, cfg.Talos.RepoDirName), Repository: cfg.Templates.TalosRepoURL, Ref: cfg.Templates.TalosRepoRef, GeneratedDir: cfg.Talos.GeneratedDir},
}
revisions := []templateRevision{lock.CICD, lock.Manifests, lock.Talos}
for index, checkout := range checkouts {
if !sameTemplateSource(revisions[index], checkout) {
return errors.New("configured template source or ref differs from its workspace revision lock")
}
commit, err := checkoutTemplateRevision(cfg, checkout, "")
if err != nil {
return errors.New("configured template revision cannot be resolved; correct the template source or ref, then rerun bootstrap")
}
revisions[index].Commit = commit
}
if err := replaceTemplateRevisionLock(lockPath, templateRevisionLock{Version: 1, CICD: revisions[0], Manifests: revisions[1], Talos: revisions[2]}); err != nil {
return errors.New("cannot update template revision lock")
}
return nil
}
func readTemplateRevisionLock(path string) (templateRevisionLock, error) { func readTemplateRevisionLock(path string) (templateRevisionLock, error) {
var lock templateRevisionLock var lock templateRevisionLock
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
@ -1710,6 +1757,27 @@ func writeTemplateRevisionLock(path string, lock templateRevisionLock) error {
return file.Close() return file.Close()
} }
func replaceTemplateRevisionLock(path string, lock templateRevisionLock) error {
data, err := yaml.Marshal(lock)
if err != nil {
return err
}
file, err := os.CreateTemp(filepath.Dir(path), ".maidn-template-revisions-")
if err != nil {
return err
}
temporaryPath := file.Name()
defer os.Remove(temporaryPath)
if _, err := file.Write(data); err != nil {
_ = file.Close()
return err
}
if err := file.Close(); err != nil {
return err
}
return os.Rename(temporaryPath, path)
}
func sameTemplateSource(revision templateRevision, checkout templateCheckout) bool { func sameTemplateSource(revision templateRevision, checkout templateCheckout) bool {
return validTemplateRevision(revision) && revision.Repository == config.RedactURL(checkout.Repository) && revision.Ref == checkout.Ref return validTemplateRevision(revision) && revision.Repository == config.RedactURL(checkout.Repository) && revision.Ref == checkout.Ref
} }

View file

@ -52,7 +52,7 @@ func TestInitializeOpenBaoConfiguresDeclaredSecretGrants(t *testing.T) {
called = kubeconfig == filepath.Join("generated", "kubeconfig") && identity == "identity" && bundle == "bundle" && len(grants) == 1 && grants[0].Application == "orders-api" called = kubeconfig == filepath.Join("generated", "kubeconfig") && identity == "identity" && bundle == "bundle" && len(grants) == 1 && grants[0].Application == "orders-api"
return nil return nil
} }
r := Runner{Config: config.Config{SOPS: config.SOPSConfig{RecoveryIdentityPath: "identity", RecoveryBundlePath: "bundle", RecoveryRecipient: "recipient", AgeKeyPath: "age", OperationalSecretsPath: "secrets"}, SecretGrants: []config.SecretGrant{{Application: "orders-api", Consumer: "runtime", Environment: "staging"}}}} r := Runner{Config: config.Config{SOPS: config.SOPSConfig{RecoveryIdentityPath: "identity", RecoveryBundlePath: "bundle", RecoveryRecipient: "recipient", AgeKeyPath: "age", OperationalSecretsPath: "secrets"}, SecretGrants: []config.SecretGrant{{Application: "orders-api", Consumer: "runtime", Environment: "staging", Secrets: []string{"database"}}}}}
if _, err := r.initializeOpenBaoForCluster("generated"); err != nil || !called { if _, err := r.initializeOpenBaoForCluster("generated"); err != nil || !called {
t.Fatalf("secret grant configuration was not invoked: called=%t err=%v", called, err) t.Fatalf("secret grant configuration was not invoked: called=%t err=%v", called, err)
} }
@ -108,31 +108,60 @@ func TestRemoveDuplicateAppDeliverySource(t *testing.T) {
} }
} }
func TestGeneratedDeliveryIsGenericAndUsesSafePreviewCleanupContract(t *testing.T) { func TestGeneratedDeliveryUsesCombinedStaticBuildArtifactContract(t *testing.T) {
cfg := config.Config{ cfg := config.Config{
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform"}, Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform"},
Flux: config.FluxConfig{Branch: "main", ManifestsRepo: "manifests"}, Flux: config.FluxConfig{Branch: "main", ClusterDomain: "example.test", ManifestsRepo: "manifests"},
Delivery: config.DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/apps/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/apps/web-ui", BuildOutputDirectory: "dist/web-ui", BuildConfiguration: "production"}, Delivery: config.DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/apps/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/apps/web-ui", BuildStrategy: "static", BuildOutputDirectory: "dist/web-ui", BuildConfiguration: "production"},
} }
content, err := renderAppDelivery(cfg) content, err := renderAppDelivery(cfg)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
for _, expected := range []string{"maidn-git-clone", "maidn-node-static-build", "maidn-node-static-push", "runAfter: [clone]", "runAfter: [build-layer]", "maidn-preview-orphan-reconciler", "valid_pr_number()", "valid_commit()", "values: [promotion]", "values: [\"production\"]", "cmp -s \"$expected_marker\" \"$marker\"", "values: [closed]"} { for _, expected := range []string{"name: build-layer", "maidn-node-static-image", "name: url\n value: \"https://git.example.test/apps/web-ui.git\"", "name: revision\n value: $(params.git-revision)", "name: image\n value: $(params.image)", "name: output-directory\n value: \"dist/web-ui\"", "name: build-configuration\n value: \"production\"", "runAfter: [build-layer]", "maidn-preview-orphan-reconciler", "valid_pr_number()", "valid_commit()", "values: [promotion]", "values: [\"production\"]", "cmp -s \"$expected_marker\" \"$marker\"", "values: [closed]"} {
if !strings.Contains(string(content), expected) { if !strings.Contains(string(content), expected) {
t.Fatalf("generated delivery does not contain %q", expected) t.Fatalf("generated delivery does not contain %q", expected)
} }
} }
if strings.Contains(string(content), "easycsr") || strings.Contains(string(content), "test-org") || strings.Contains(string(content), "git rm -r") { for _, expected := range []string{"reconcileStrategy: Revision", "valuesFiles:\n - ./charts/$APP_NAME/values.yaml\n - ./preview/values.yaml", "valuesFiles:\n - ./charts/$APP_NAME/values.yaml\n - ./staging/values.yaml", "valuesFiles:\n - ./charts/$APP_NAME/values.yaml\n - ./production/values.yaml", "hostname: $APP_NAME-pr-$PR_NUMBER.example.test"} {
t.Fatal("generated delivery contains a non-generic or unsafe literal") if !strings.Contains(string(content), expected) {
t.Fatalf("generated delivery does not contain protected platform values %q", expected)
}
}
for _, unexpected := range []string{"maidn-git-clone", "maidn-node-static-build", "maidn-node-static-push", "runAfter: [clone]", "name: source", "workspace: source", "workspace: artifact", "easycsr", "test-org", "git rm -r", "$APP_URL", "$APP_REVISION", "platform-url", "platform-revision", "/tmp/platform", "/tmp/app/preview/values.yaml"} {
if strings.Contains(string(content), unexpected) {
t.Fatalf("generated delivery contains unexpected %q", unexpected)
}
}
}
func TestGeneratedDeliveryUsesRuntimeImageWithoutStaticParameters(t *testing.T) {
cfg := config.Config{
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform"},
Flux: config.FluxConfig{Branch: "main", ClusterDomain: "example.test", ManifestsRepo: "manifests"},
Delivery: config.DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/apps/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/apps/web-ui", BuildStrategy: "runtime", BuildOutputDirectory: "dist/web-ui", BuildConfiguration: "production"},
}
content, err := renderAppDelivery(cfg)
if err != nil {
t.Fatal(err)
}
for _, expected := range []string{"maidn-node-runtime-image", "name: url\n value: \"https://git.example.test/apps/web-ui.git\"", "name: revision\n value: $(params.git-revision)", "name: image\n value: $(params.image)"} {
if !strings.Contains(string(content), expected) {
t.Fatalf("generated runtime delivery does not contain %q", expected)
}
}
for _, unexpected := range []string{"maidn-node-static-image", "name: output-directory", "name: build-configuration"} {
if strings.Contains(string(content), unexpected) {
t.Fatalf("generated runtime delivery contains static-only %q", unexpected)
}
} }
} }
func TestGeneratedDeliveryInitializesStagingAndPromotesByPullRequest(t *testing.T) { func TestGeneratedDeliveryInitializesStagingAndPromotesByPullRequest(t *testing.T) {
cfg := config.Config{ cfg := config.Config{
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform"}, Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform"},
Flux: config.FluxConfig{Branch: "main", ManifestsRepo: "manifests"}, Flux: config.FluxConfig{Branch: "main", ClusterDomain: "example.test", ManifestsRepo: "manifests"},
Delivery: config.DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/apps/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/apps/web-ui", BuildOutputDirectory: "dist", BuildConfiguration: "production"}, Delivery: config.DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/apps/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/apps/web-ui", BuildStrategy: "static", BuildOutputDirectory: "dist", BuildConfiguration: "production"},
} }
content, err := renderAppDelivery(cfg) content, err := renderAppDelivery(cfg)
if err != nil { if err != nil {
@ -148,21 +177,23 @@ func TestGeneratedDeliveryInitializesStagingAndPromotesByPullRequest(t *testing.
t.Fatalf("generated delivery does not contain %q", expected) t.Fatalf("generated delivery does not contain %q", expected)
} }
} }
production := rendered[strings.Index(rendered, `if [ "$ENVIRONMENT" = production ]; then`):] start := strings.Index(rendered, `if [ "$ENVIRONMENT" = production ]; then`)
production = production[:strings.Index(production, "\n else\n")] if start < 0 {
t.Fatal("generated delivery does not contain a production manifest path")
}
production := rendered[start:]
if end := strings.Index(production, "\n else\n"); end >= 0 {
production = production[:end]
}
if strings.Contains(production, `git push origin "$MANIFESTS_BRANCH"`) { if strings.Contains(production, `git push origin "$MANIFESTS_BRANCH"`) {
t.Fatal("production path pushes directly to manifests main") t.Fatal("production path pushes directly to manifests main")
} }
} }
func TestGenerateAppDeliveryRequiresCompleteConfig(t *testing.T) { func TestRenderAppDeliveryRequiresCompleteConfig(t *testing.T) {
dir := t.TempDir() _, err := renderAppDelivery(config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test"}, Delivery: config.DeliveryConfig{AppName: "legacy-app"}})
err := GenerateAppDelivery(dir, config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test"}, Delivery: config.DeliveryConfig{AppName: "legacy-app"}}) if err == nil || !strings.Contains(err.Error(), "delivery buildStrategy") {
if err == nil || !strings.Contains(err.Error(), "delivery appName") { t.Fatalf("renderAppDelivery() error = %v, want incomplete delivery error", err)
t.Fatalf("GenerateAppDelivery() error = %v, want incomplete delivery error", err)
}
if _, statErr := os.Stat(filepath.Join(dir, ".tekton")); !os.IsNotExist(statErr) {
t.Fatal("GenerateAppDelivery() wrote delivery files before rejecting incomplete config")
} }
} }
@ -228,10 +259,23 @@ func TestCopyAndRenderPlatformDeliveryBasesOverwritesExistingMigrationOutput(t *
if err := os.WriteFile(filepath.Join(templateDir, "base", "tekton", "kustomization.yaml"), []byte("resources:\n"), 0644); err != nil { if err := os.WriteFile(filepath.Join(templateDir, "base", "tekton", "kustomization.yaml"), []byte("resources:\n"), 0644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.MkdirAll(filepath.Join(templateDir, "base", "tekton", "apps"), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(templateDir, "base", "tekton", "apps", "kustomization.yaml"), []byte("resources: []\n"), 0644); err != nil {
t.Fatal(err)
}
customGatewayFile := filepath.Join(repoDir, "base", "gateway", "custom.yaml") customGatewayFile := filepath.Join(repoDir, "base", "gateway", "custom.yaml")
if err := os.WriteFile(customGatewayFile, []byte("custom: route\n"), 0644); err != nil { if err := os.WriteFile(customGatewayFile, []byte("custom: route\n"), 0644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
appRegistration := filepath.Join(repoDir, "base", "tekton", "apps", "fixture.yaml")
if err := os.MkdirAll(filepath.Dir(appRegistration), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(appRegistration, []byte("registered: fixture\n"), 0644); err != nil {
t.Fatal(err)
}
cfg := config.Config{ cfg := config.Config{
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "user-org"}, Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "user-org"},
@ -251,6 +295,10 @@ func TestCopyAndRenderPlatformDeliveryBasesOverwritesExistingMigrationOutput(t *
if err != nil || string(content) != "custom: route\n" { if err != nil || string(content) != "custom: route\n" {
t.Fatalf("custom gateway file was not preserved: %q, %v", content, err) t.Fatalf("custom gateway file was not preserved: %q, %v", content, err)
} }
content, err = os.ReadFile(appRegistration)
if err != nil || string(content) != "registered: fixture\n" {
t.Fatalf("generated app registration was not preserved: %q, %v", content, err)
}
} }
func TestCopyTemplateBaseComponentsRefreshesCNPGAndPreservesGeneratedSecrets(t *testing.T) { func TestCopyTemplateBaseComponentsRefreshesCNPGAndPreservesGeneratedSecrets(t *testing.T) {
@ -957,12 +1005,14 @@ func TestRunnerRegisterWebhookSkipsTemplateRevisions(t *testing.T) {
originalPreflight := preflight originalPreflight := preflight
originalGit := runGit originalGit := runGit
originalInitialize := initializeOpenBao originalInitialize := initializeOpenBao
originalRead := readOperationalSecrets
originalCommand := runWebhookCommand originalCommand := runWebhookCommand
originalWebhook := ensureForgejoWebhook originalWebhook := ensureForgejoWebhook
t.Cleanup(func() { t.Cleanup(func() {
preflight = originalPreflight preflight = originalPreflight
runGit = originalGit runGit = originalGit
initializeOpenBao = originalInitialize initializeOpenBao = originalInitialize
readOperationalSecrets = originalRead
runWebhookCommand = originalCommand runWebhookCommand = originalCommand
ensureForgejoWebhook = originalWebhook ensureForgejoWebhook = originalWebhook
}) })
@ -981,9 +1031,12 @@ func TestRunnerRegisterWebhookSkipsTemplateRevisions(t *testing.T) {
initializeOpenBao = func(string, string, string, string, string, string) (map[string]map[string]string, error) { initializeOpenBao = func(string, string, string, string, string, string) (map[string]map[string]string, error) {
return map[string]map[string]string{"cicd/forgejo-webhook": {"authorization": authorization}}, nil return map[string]map[string]string{"cicd/forgejo-webhook": {"authorization": authorization}}, nil
} }
readOperationalSecrets = func(string, string) (map[string]map[string]string, error) {
return map[string]map[string]string{"cicd/forgejo-webhook": {"authorization": authorization}}, nil
}
runWebhookCommand = func(_ string, _ string, args ...string) ([]byte, error) { runWebhookCommand = func(_ string, _ string, args ...string) ([]byte, error) {
if strings.Contains(strings.Join(args, " "), "secret/forgejo-webhook") { if strings.Contains(strings.Join(args, " "), "externalsecret/forgejo-webhook") {
return []byte(base64.StdEncoding.EncodeToString([]byte(authorization))), nil return []byte("True"), nil
} }
return nil, nil return nil, nil
} }
@ -1013,14 +1066,16 @@ func TestRunnerRegisterWebhookSkipsTemplateRevisions(t *testing.T) {
} }
} }
func TestReconcileWebhookWaitsForRefreshedTargetSecret(t *testing.T) { func TestReconcileWebhookWaitsForReadyExternalSecret(t *testing.T) {
originalInitialize := initializeOpenBao originalInitialize := initializeOpenBao
originalRead := readOperationalSecrets
originalCommand := runWebhookCommand originalCommand := runWebhookCommand
originalWebhook := ensureForgejoWebhook originalWebhook := ensureForgejoWebhook
originalTimeout := webhookTargetTimeout originalTimeout := webhookTargetTimeout
originalInterval := webhookTargetPollInterval originalInterval := webhookTargetPollInterval
t.Cleanup(func() { t.Cleanup(func() {
initializeOpenBao = originalInitialize initializeOpenBao = originalInitialize
readOperationalSecrets = originalRead
runWebhookCommand = originalCommand runWebhookCommand = originalCommand
ensureForgejoWebhook = originalWebhook ensureForgejoWebhook = originalWebhook
webhookTargetTimeout = originalTimeout webhookTargetTimeout = originalTimeout
@ -1028,21 +1083,19 @@ func TestReconcileWebhookWaitsForRefreshedTargetSecret(t *testing.T) {
}) })
authorization := "Bearer test-webhook-authorization" authorization := "Bearer test-webhook-authorization"
staleTarget := base64.StdEncoding.EncodeToString([]byte("Bearer stale-webhook-authorization"))
refreshedTarget := base64.StdEncoding.EncodeToString([]byte(authorization))
targetChecks := 0 targetChecks := 0
refreshedObserved := false refreshedObserved := false
runWebhookCommand = func(_ string, name string, args ...string) ([]byte, error) { runWebhookCommand = func(_ string, name string, args ...string) ([]byte, error) {
if name != "kubectl" || strings.Contains(strings.Join(args, " "), authorization) { if name != "kubectl" || strings.Contains(strings.Join(args, " "), authorization) {
t.Fatal("webhook target probe used an unexpected command") t.Fatal("webhook target probe used an unexpected command")
} }
if strings.Contains(strings.Join(args, " "), "secret/forgejo-webhook") { if strings.Contains(strings.Join(args, " "), "externalsecret/forgejo-webhook") {
targetChecks++ targetChecks++
if targetChecks == 1 { if targetChecks == 1 {
return []byte(staleTarget), nil return []byte("False"), nil
} }
refreshedObserved = true refreshedObserved = true
return []byte(refreshedTarget), nil return []byte("True"), nil
} }
return nil, nil return nil, nil
} }
@ -1051,7 +1104,7 @@ func TestReconcileWebhookWaitsForRefreshedTargetSecret(t *testing.T) {
patches := 0 patches := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if !refreshedObserved { if !refreshedObserved {
t.Error("Forgejo was called before the refreshed webhook Secret was observed") t.Error("Forgejo was called before the refreshed webhook ExternalSecret was ready")
writer.WriteHeader(http.StatusInternalServerError) writer.WriteHeader(http.StatusInternalServerError)
return return
} }
@ -1083,24 +1136,29 @@ func TestReconcileWebhookWaitsForRefreshedTargetSecret(t *testing.T) {
initializeOpenBao = func(string, string, string, string, string, string) (map[string]map[string]string, error) { initializeOpenBao = func(string, string, string, string, string, string) (map[string]map[string]string, error) {
return map[string]map[string]string{"cicd/forgejo-webhook": {"authorization": authorization}}, nil return map[string]map[string]string{"cicd/forgejo-webhook": {"authorization": authorization}}, nil
} }
readOperationalSecrets = func(string, string) (map[string]map[string]string, error) {
return map[string]map[string]string{"cicd/forgejo-webhook": {"authorization": authorization}}, nil
}
cfg := config.Config{Delivery: config.DeliveryConfig{AppName: "app", WebhookHostname: "tekton.example.test", WebhookPath: "/"}} cfg := config.Config{Delivery: config.DeliveryConfig{AppName: "app", WebhookHostname: "tekton.example.test", WebhookPath: "/"}}
if err := (Runner{Config: cfg}).reconcileWebhook(t.TempDir()); err != nil { if err := (Runner{Config: cfg}).reconcileWebhook(t.TempDir()); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if targetChecks != 2 || patches != 1 { if targetChecks != 2 || patches != 1 {
t.Fatal("Forgejo webhook was not updated after the target Secret refreshed") t.Fatal("Forgejo webhook was not updated after the ExternalSecret became ready")
} }
} }
func TestReconcileWebhookTimeoutDoesNotUpdateForgejo(t *testing.T) { func TestReconcileWebhookTimeoutDoesNotUpdateForgejo(t *testing.T) {
originalInitialize := initializeOpenBao originalInitialize := initializeOpenBao
originalRead := readOperationalSecrets
originalCommand := runWebhookCommand originalCommand := runWebhookCommand
originalWebhook := ensureForgejoWebhook originalWebhook := ensureForgejoWebhook
originalTimeout := webhookTargetTimeout originalTimeout := webhookTargetTimeout
originalInterval := webhookTargetPollInterval originalInterval := webhookTargetPollInterval
t.Cleanup(func() { t.Cleanup(func() {
initializeOpenBao = originalInitialize initializeOpenBao = originalInitialize
readOperationalSecrets = originalRead
runWebhookCommand = originalCommand runWebhookCommand = originalCommand
ensureForgejoWebhook = originalWebhook ensureForgejoWebhook = originalWebhook
webhookTargetTimeout = originalTimeout webhookTargetTimeout = originalTimeout
@ -1111,8 +1169,11 @@ func TestReconcileWebhookTimeoutDoesNotUpdateForgejo(t *testing.T) {
initializeOpenBao = func(string, string, string, string, string, string) (map[string]map[string]string, error) { initializeOpenBao = func(string, string, string, string, string, string) (map[string]map[string]string, error) {
return map[string]map[string]string{"cicd/forgejo-webhook": {"authorization": authorization}}, nil return map[string]map[string]string{"cicd/forgejo-webhook": {"authorization": authorization}}, nil
} }
readOperationalSecrets = func(string, string) (map[string]map[string]string, error) {
return map[string]map[string]string{"cicd/forgejo-webhook": {"authorization": authorization}}, nil
}
runWebhookCommand = func(_ string, _ string, _ ...string) ([]byte, error) { runWebhookCommand = func(_ string, _ string, _ ...string) ([]byte, error) {
return []byte(base64.StdEncoding.EncodeToString([]byte("Bearer stale-webhook-authorization"))), nil return []byte("False"), nil
} }
webhookTargetTimeout = -time.Nanosecond webhookTargetTimeout = -time.Nanosecond
webhookTargetPollInterval = 0 webhookTargetPollInterval = 0
@ -1127,7 +1188,7 @@ func TestReconcileWebhookTimeoutDoesNotUpdateForgejo(t *testing.T) {
t.Fatal("webhook refresh timeout did not return a safe rerun error") t.Fatal("webhook refresh timeout did not return a safe rerun error")
} }
if webhookUpdated { if webhookUpdated {
t.Fatal("Forgejo webhook update was attempted before the target Secret refreshed") t.Fatal("Forgejo webhook update was attempted before the ExternalSecret became ready")
} }
} }

View file

@ -7,14 +7,27 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"github.com/Pingu-Studio/MaidnCLI/internal/config" "github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo" "github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
// OnboardApp imports one clean checkout into the configured Forgejo owner, type onboardingRepoManager interface {
// publishes its delivery branch, registers it with Flux, then adds its webhook. 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 { func OnboardApp(cfg config.Config, sourceDir string) error {
resolved, err := config.ResolveAppOnboarding(cfg) resolved, err := config.ResolveAppOnboarding(cfg)
if err != nil { if err != nil {
@ -30,53 +43,35 @@ func OnboardApp(cfg config.Config, sourceDir string) error {
if sourceBranch != resolved.Delivery.AppRepoRef { if sourceBranch != resolved.Delivery.AppRepoRef {
return errors.New("--from branch must match 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) owner, repository, err := forgejo.RepositoryFromURL(resolved.Delivery.AppRepoURL)
if err != nil { if err != nil {
return err return err
} }
if owner != resolved.Git.Owner { sourceManager := newOnboardingRepoManager(resolved.Git.BaseURL, resolved.Git.Token, owner, resolved.Git.Username, "", "", resolved.Delivery.AppRepoRef, "")
return errors.New("delivery appRepoUrl owner must match git owner for app onboarding") if _, err := sourceManager.EnsureRepository(repository, "Application build input for Maidn CI/CD"); err != nil {
}
deliveryBranch, err := forgejo.DeliveryBranch(resolved.Delivery.AppName, resolved.Delivery.AppRepoRef)
if err != nil {
return err return err
} }
manager := forgejo.NewRepoManager(resolved.Git.BaseURL, resolved.Git.Token, resolved.Git.Owner, resolved.Git.Username, "", "", resolved.Delivery.AppRepoRef, "") if err := publishInitialAppBranches(sourceManager, sourceDir, resolved.Delivery.AppRepoURL, sourceRevision, resolved.Delivery.AppRepoRef, resolved.Delivery.ProductionBranch); err != nil {
if _, err := manager.EnsureRepository(repository, "Application source for Maidn CI/CD delivery"); err != nil {
return err return err
} }
if err := publishInitialAppBranches(manager, sourceDir, resolved.Delivery.AppRepoURL, sourceBranch, resolved.Delivery.AppRepoRef, resolved.Delivery.ProductionBranch); err != nil { if err := sourceManager.EnsureProtectedBranch(repository, resolved.Delivery.ProductionBranch); err != nil {
return err
}
if err := manager.EnsureProtectedBranch(repository, resolved.Delivery.ProductionBranch); err != nil {
return fmt.Errorf("protect Forgejo production branch: %w", err) return fmt.Errorf("protect Forgejo production branch: %w", err)
} }
changed, err := manager.PublishDeliveryBranch(sourceDir, sourceBranch, resolved.Delivery.AppRepoURL, deliveryBranch, func(dir string) error { if err := ensurePlatformBranch(sourceManager, repository, resolved.Delivery.AppRepoURL, resolved.Delivery.AppName); err != nil {
return GenerateAppDelivery(dir, resolved)
})
if err != nil {
return err return err
} }
if changed { registrationBranch := registrationBranch(resolved.Delivery.AppName, sourceRevision)
if err := manager.EnsurePullRequest(repository, "feat: migrate delivery to Tekton", deliveryBranch, resolved.Delivery.AppRepoRef); err != nil { clusterManager := newOnboardingRepoManager(resolved.Git.BaseURL, resolved.Git.Token, resolved.Git.Owner, resolved.Git.Username, "", "", resolved.Flux.Branch, "")
return err 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)
}
if open, err := manager.HasOpenPullRequest(repository, deliveryBranch); err != nil {
return err
} else if open {
if err := manager.MergePullRequest(repository, deliveryBranch); err != nil {
return err
}
}
registrationBranch := "maidn/register-" + resolved.Delivery.AppName
if _, err := manager.PublishRepositoryPullRequest(resolved.Flux.RepoName, "feat: register "+resolved.Delivery.AppName+" delivery", registrationBranch, resolved.Flux.Branch, func(dir string) error {
return RegisterAppInCluster(dir, resolved)
}); err != nil { }); err != nil {
return fmt.Errorf("register app in cluster repository: %w", err) return fmt.Errorf("register app in cluster repository: %w", err)
} }
secrets, err := ReadOperationalSecrets(resolved.SOPS.OperationalSecretsPath, resolved.SOPS.AgeKeyPath) secrets, err := readOperationalSecrets(resolved.SOPS.OperationalSecretsPath, resolved.SOPS.AgeKeyPath)
if err != nil { if err != nil {
return fmt.Errorf("read encrypted webhook authorization: %w", err) return fmt.Errorf("read encrypted webhook authorization: %w", err)
} }
@ -85,25 +80,41 @@ func OnboardApp(cfg config.Config, sourceDir string) error {
return errors.New("operational SOPS secrets requires cicd/forgejo-webhook.authorization") return errors.New("operational SOPS secrets requires cicd/forgejo-webhook.authorization")
} }
generatedDir := filepath.Join(resolved.Git.CloneParent, resolved.Talos.RepoDirName, resolved.Talos.GeneratedDir) generatedDir := filepath.Join(resolved.Git.CloneParent, resolved.Talos.RepoDirName, resolved.Talos.GeneratedDir)
if err := waitForWebhookTargets(generatedDir, resolved, authorization); err != nil { if err := waitForWebhookTargets(generatedDir, resolved); err != nil {
return err return err
} }
webhookURL := "https://tekton." + resolved.Flux.ClusterDomain + "/" webhookURL := "https://tekton." + resolved.Flux.ClusterDomain + "/"
if err := manager.EnsureWebhook(repository, webhookURL, authorization); err != nil { if err := sourceManager.EnsureWebhook(repository, webhookURL, authorization); err != nil {
return fmt.Errorf("register Forgejo webhook: %w", err) return fmt.Errorf("register Forgejo webhook: %w", err)
} }
if err := manager.TriggerWebhookTest(repository, webhookURL, resolved.Delivery.AppRepoRef); err != nil {
return fmt.Errorf("trigger Forgejo webhook test: %w", err)
}
return nil return nil
} }
// publishInitialAppBranches establishes the immutable source baseline before delivery setup. func registrationBranch(appName, revision string) string {
func publishInitialAppBranches(manager *forgejo.RepoManager, sourceDir, targetURL, sourceBranch, targetBranch, productionBranch string) error { return "maidn/register-" + appName + "-" + revision[:12]
sourceRevision, err := forgejo.BranchRevision(sourceDir, sourceBranch) }
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 { if err != nil {
return err 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) mainRevision, err := manager.RemoteBranchRevision(targetURL, targetBranch)
if err != nil { if err != nil {
return fmt.Errorf("read target base branch: %w", err) return fmt.Errorf("read target base branch: %w", err)
@ -140,6 +151,10 @@ func publishInitialAppBranches(manager *forgejo.RepoManager, sourceDir, targetUR
// RegisterAppInCluster writes only the managed Flux registration for one app. // RegisterAppInCluster writes only the managed Flux registration for one app.
func RegisterAppInCluster(dir string, cfg config.Config) error { func RegisterAppInCluster(dir string, cfg config.Config) error {
return registerAppInCluster(dir, cfg)
}
func registerAppInCluster(dir string, cfg config.Config) error {
content, err := renderAppRegistration(cfg) content, err := renderAppRegistration(cfg)
if err != nil { if err != nil {
return err return err
@ -201,11 +216,15 @@ func renderAppRegistration(cfg config.Config) ([]byte, error) {
if err := config.ValidateDelivery(cfg); err != nil { if err := config.ValidateDelivery(cfg); err != nil {
return nil, err return nil, err
} }
branch, err := forgejo.DeliveryBranch(cfg.Delivery.AppName, cfg.Delivery.AppRepoRef) secretAccess, err := renderAppSecretAccess(cfg)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return []byte(fmt.Sprintf(`apiVersion: source.toolkit.fluxcd.io/v1 delivery, err := renderAppDelivery(cfg)
if err != nil {
return nil, err
}
content := fmt.Sprintf(`apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository kind: GitRepository
metadata: metadata:
name: %s name: %s
@ -217,24 +236,69 @@ spec:
name: forgejo-flux-credentials name: forgejo-flux-credentials
ref: ref:
branch: %s 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: kustomize.toolkit.fluxcd.io/v1 apiVersion: external-secrets.io/v1
kind: Kustomization 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: metadata:
name: %s name: %s
namespace: flux-system namespace: %s
spec: spec:
interval: 5m refreshInterval: 1h
path: ./.tekton secretStoreRef:
prune: true kind: SecretStore
wait: true name: openbao-%s
targetNamespace: tekton-pipelines target:
dependsOn:
- name: tekton-catalog
sourceRef:
kind: GitRepository
name: %s name: %s
`, cfg.Delivery.AppName, cfg.Delivery.AppRepoURL, branch, cfg.Delivery.AppName, cfg.Delivery.AppName)), nil 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) { func readRegularFile(path string) ([]byte, error) {

View file

@ -2,6 +2,7 @@ package bootstrap
import ( import (
"bytes" "bytes"
"errors"
"io" "io"
"os" "os"
"os/exec" "os/exec"
@ -31,51 +32,13 @@ func onboardingConfig() config.Config {
Flux: config.FluxConfig{Branch: "main", RepoName: "cluster", ManifestsRepo: "manifests", ClusterDomain: "example.test"}, Flux: config.FluxConfig{Branch: "main", RepoName: "cluster", ManifestsRepo: "manifests", ClusterDomain: "example.test"},
Delivery: config.DeliveryConfig{ Delivery: config.DeliveryConfig{
AppName: "web-ui", AppRepoURL: "https://git.example.test/test-org-2/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", 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", BuildOutputDirectory: "dist", BuildConfiguration: "production", ImageRepository: "registry.example.test/test-org-2/web-ui", BuildStrategy: "static", BuildOutputDirectory: "dist", BuildConfiguration: "production",
WebhookHostname: "tekton.example.test", WebhookPath: "/", WebhookHostname: "tekton.example.test", WebhookPath: "/",
}, },
} }
} }
func TestGenerateAppDeliveryReplacesOnlyKnownGeneratedFiles(t *testing.T) { func TestRegisterAppInClusterRendersCentralDeliveryResources(t *testing.T) {
dir := t.TempDir()
tektonDir := filepath.Join(dir, ".tekton")
if err := os.Mkdir(tektonDir, 0755); err != nil {
t.Fatal(err)
}
for _, name := range []string{"kustomization.yaml", "pipeline.yaml"} {
if err := os.WriteFile(filepath.Join(tektonDir, name), []byte("old generated content\n"), 0644); err != nil {
t.Fatal(err)
}
}
if err := GenerateAppDelivery(dir, onboardingConfig()); err != nil {
t.Fatal(err)
}
pipeline, err := os.ReadFile(filepath.Join(tektonDir, "pipeline.yaml"))
normalized := strings.ReplaceAll(string(pipeline), "\r\n", "\n")
if err != nil || !strings.Contains(normalized, "https://git.example.test/test-org-2/web-ui.git") || !strings.Contains(normalized, "name: HOME\n value: /tekton/home") || !strings.Contains(normalized, "grep -qxF \" namespace: staging\"") || !strings.Contains(normalized, "namespace: production") || !strings.Contains(normalized, "name: web-ui-wait-delivery") || !strings.Contains(normalized, "name: web-ui-report-delivery") || !strings.Contains(normalized, "forgejo-delivery-status") || !strings.Contains(normalized, "$(context.pipelineRun.name)") || !strings.Contains(normalized, "Promotion PR opened or updated") || strings.Contains(normalized, "taskRunTemplate:") {
t.Fatalf("target-specific pipeline = %q, %v", pipeline, err)
}
decoder := yaml.NewDecoder(bytes.NewReader(pipeline))
for {
var document yaml.Node
err := decoder.Decode(&document)
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("generated pipeline YAML: %v", err)
}
}
if err := os.WriteFile(filepath.Join(tektonDir, "custom.yaml"), []byte("custom: true\n"), 0644); err != nil {
t.Fatal(err)
}
if err := GenerateAppDelivery(dir, onboardingConfig()); err == nil || !strings.Contains(err.Error(), "unmanaged") {
t.Fatalf("custom .tekton content was accepted: %v", err)
}
}
func TestRegisterAppInClusterRendersManagedFluxSource(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
tektonDir := filepath.Join(dir, "base", "tekton") tektonDir := filepath.Join(dir, "base", "tekton")
if err := os.MkdirAll(tektonDir, 0755); err != nil { if err := os.MkdirAll(tektonDir, 0755); err != nil {
@ -88,7 +51,7 @@ func TestRegisterAppInClusterRendersManagedFluxSource(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
registration, err := os.ReadFile(filepath.Join(tektonDir, "apps", "web-ui.yaml")) registration, err := os.ReadFile(filepath.Join(tektonDir, "apps", "web-ui.yaml"))
if err != nil || !strings.Contains(string(registration), "branch: maidn/delivery-web-ui") || !strings.Contains(string(registration), "secretRef:\n name: forgejo-flux-credentials") || !strings.Contains(string(registration), "dependsOn:\n - name: tekton-catalog") || !strings.Contains(string(registration), "path: ./.tekton") { 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) 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"} { for path, resource := range map[string]string{filepath.Join(tektonDir, "kustomization.yaml"): "apps", filepath.Join(tektonDir, "apps", "kustomization.yaml"): "web-ui.yaml"} {
@ -97,6 +60,17 @@ func TestRegisterAppInClusterRendersManagedFluxSource(t *testing.T) {
t.Fatalf("Kustomization %s does not include %s: %q, %v", path, resource, content, err) 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 { 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) t.Fatal(err)
} }
@ -111,6 +85,15 @@ func TestRegisterAppInClusterRendersManagedFluxSource(t *testing.T) {
} }
} }
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) { func TestPublishInitialAppBranchesCreatesAndPreservesProduction(t *testing.T) {
source := filepath.Join(t.TempDir(), "source") source := filepath.Join(t.TempDir(), "source")
target := filepath.Join(t.TempDir(), "target.git") target := filepath.Join(t.TempDir(), "target.git")
@ -129,7 +112,7 @@ func TestPublishInitialAppBranchesCreatesAndPreservesProduction(t *testing.T) {
onboardingGit(t, "", "init", "--bare", target) onboardingGit(t, "", "init", "--bare", target)
manager := forgejo.NewRepoManager("https://git.example.test", "", "owner", "", "", "", "main", "") manager := forgejo.NewRepoManager("https://git.example.test", "", "owner", "", "", "", "main", "")
if err := publishInitialAppBranches(manager, source, target, "source", "main", "production"); err != nil { if err := publishInitialAppBranches(manager, source, target, sourceRevision, "main", "production"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
for _, branch := range []string{"main", "production"} { for _, branch := range []string{"main", "production"} {
@ -156,7 +139,7 @@ func TestPublishInitialAppBranchesCreatesAndPreservesProduction(t *testing.T) {
existingProduction := onboardingGit(t, production, "rev-parse", "production") existingProduction := onboardingGit(t, production, "rev-parse", "production")
onboardingGit(t, production, "push", preservedTarget, "production:production") onboardingGit(t, production, "push", preservedTarget, "production:production")
if err := publishInitialAppBranches(manager, source, preservedTarget, "source", "main", "production"); err != nil { if err := publishInitialAppBranches(manager, source, preservedTarget, sourceRevision, "main", "production"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := onboardingGit(t, "", "--git-dir", preservedTarget, "rev-parse", "refs/heads/production"); got != existingProduction { if got := onboardingGit(t, "", "--git-dir", preservedTarget, "rev-parse", "refs/heads/production"); got != existingProduction {
@ -166,10 +149,139 @@ func TestPublishInitialAppBranchesCreatesAndPreservesProduction(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
onboardingGit(t, source, "commit", "-am", "updated source") onboardingGit(t, source, "commit", "-am", "updated source")
if err := publishInitialAppBranches(manager, source, preservedTarget, "source", "main", "production"); err != nil { if err := publishInitialAppBranches(manager, source, preservedTarget, sourceRevision, "main", "production"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := onboardingGit(t, "", "--git-dir", preservedTarget, "rev-parse", "refs/heads/main"); got != sourceRevision { if got := onboardingGit(t, "", "--git-dir", preservedTarget, "rev-parse", "refs/heads/main"); got != sourceRevision {
t.Fatalf("main = %s, want existing %s", 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

@ -40,20 +40,61 @@ func TestEnsureTemplateRevisionsReusesLockedCommitAfterBranchDrift(t *testing.T)
if err := ensureTemplateRevisions(workspace, cfg); err != nil { if err := ensureTemplateRevisions(workspace, cfg); err != nil {
t.Fatal(err) t.Fatal(err)
} }
locked := git.commits["cicd"] locked := map[string]string{}
git.commits["cicd"] = strings.Repeat("d", 40) 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() git.resetCalls()
if err := ensureTemplateRevisions(workspace, cfg); err != nil { if err := ensureTemplateRevisions(workspace, cfg); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if git.fetched["cicd"] != locked || git.checkedOut["cicd"] != locked { lock, err := readTemplateRevisionLock(filepath.Join(workspace, "maidn-template-revisions.yaml"))
t.Fatalf("branch drift changed locked CICD revision: fetched %q, checked out %q", git.fetched["cicd"], git.checkedOut["cicd"]) 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") { if strings.Contains(git.commands(), "fetch origin main") {
t.Fatal("later run fetched a mutable branch instead of the lock commit") 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) { func TestEnsureTemplateRevisionsRejectsChangedRefWithoutGit(t *testing.T) {
workspace, cfg, git := templateRevisionTestConfig(t) workspace, cfg, git := templateRevisionTestConfig(t)
useTemplateRevisionGit(t, git) useTemplateRevisionGit(t, git)

View file

@ -164,12 +164,12 @@ func ResolveAppOnboarding(cfg Config) (Config, error) {
if err != nil { if err != nil {
return cfg, err return cfg, err
} }
owner, _, err := deliveryRepositoryOwner(resolved.Delivery.AppRepoURL) owner, repository, err := deliveryRepositoryOwner(resolved.Delivery.AppRepoURL)
if err != nil { if err != nil {
return cfg, err return cfg, err
} }
if owner != resolved.Git.Owner { if owner != resolved.Git.Owner && (owner != "Maidn" || !strings.HasPrefix(repository, "maidn-e2e-")) {
return cfg, errors.New("delivery appRepoUrl owner must match git owner for app onboarding") return cfg, errors.New("delivery appRepoUrl owner must match git owner or identify a canonical Maidn E2E fixture")
} }
if resolved.Delivery.ProductionBranch != "production" { if resolved.Delivery.ProductionBranch != "production" {
return cfg, errors.New("delivery productionBranch must be literal production for app onboarding") return cfg, errors.New("delivery productionBranch must be literal production for app onboarding")
@ -215,6 +215,9 @@ func validateRepositoryURL(value string) error {
} }
func applyDefaults(cfg *Config) { func applyDefaults(cfg *Config) {
if cfg.Delivery.BuildStrategy == "" {
cfg.Delivery.BuildStrategy = "static"
}
if cfg.ClusterID == "" { if cfg.ClusterID == "" {
cfg.ClusterID = cfg.Talos.Cluster.Name cfg.ClusterID = cfg.Talos.Cluster.Name
} }
@ -317,6 +320,9 @@ func applyDefaults(cfg *Config) {
} }
func applyDeliveryDefaults(cfg *Config) { func applyDeliveryDefaults(cfg *Config) {
if cfg.Delivery.BuildStrategy == "" {
cfg.Delivery.BuildStrategy = "static"
}
if cfg.Delivery.AppRepoRef == "" { if cfg.Delivery.AppRepoRef == "" {
cfg.Delivery.AppRepoRef = cfg.Flux.Branch cfg.Delivery.AppRepoRef = cfg.Flux.Branch
} }
@ -515,6 +521,16 @@ func ValidateSecretGrants(grants []SecretGrant) error {
return errors.New("duplicate secret grant consumer") return errors.New("duplicate secret grant consumer")
} }
seen[key] = true 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 { for _, shared := range grant.Shared {
if !name.MatchString(shared) { if !name.MatchString(shared) {
return errors.New("shared secret grant name must be a lowercase DNS label") return errors.New("shared secret grant name must be a lowercase DNS label")
@ -532,6 +548,9 @@ func ValidateDelivery(cfg Config) error {
if !regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,45}[a-z0-9])?$`).MatchString(cfg.Delivery.AppName) { 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") 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) { if cfg.Delivery.ProductionBranch == cfg.Delivery.AppRepoRef || !validDeliveryBranch(cfg.Delivery.ProductionBranch) {
return errors.New("delivery productionBranch must be a valid branch distinct from appRepoRef") return errors.New("delivery productionBranch must be a valid branch distinct from appRepoRef")
} }

View file

@ -17,7 +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"}, 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"}, 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"}, 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", BuildOutputDirectory: "dist", BuildConfiguration: "production", WebhookHostname: "tekton.example.test", WebhookPath: "/"}, 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{ Talos: TalosConfig{
RepoDirName: "talos", TerraformDir: "terraform", GeneratedDir: "generated", ConfigFileName: "terraform.tfvars", RepoDirName: "talos", TerraformDir: "terraform", GeneratedDir: "generated", ConfigFileName: "terraform.tfvars",
Proxmox: TalosProxmoxConfig{APIURL: "https://proxmox.example.test:8006", APITokenID: "id", APITokenSecret: "secret"}, Proxmox: TalosProxmoxConfig{APIURL: "https://proxmox.example.test:8006", APITokenID: "id", APITokenSecret: "secret"},
@ -64,6 +64,7 @@ func TestResolveDeliveryAppliesDefaultsOnlyForExplicitAppOperations(t *testing.T
cfg.Delivery.AppRepoRef = "" cfg.Delivery.AppRepoRef = ""
cfg.Delivery.BuildOutputDirectory = "" cfg.Delivery.BuildOutputDirectory = ""
cfg.Delivery.BuildConfiguration = "" cfg.Delivery.BuildConfiguration = ""
cfg.Delivery.BuildStrategy = ""
cfg.Delivery.WebhookHostname = "" cfg.Delivery.WebhookHostname = ""
cfg.Delivery.WebhookPath = "" cfg.Delivery.WebhookPath = ""
@ -78,11 +79,25 @@ func TestResolveDeliveryAppliesDefaultsOnlyForExplicitAppOperations(t *testing.T
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if !delivery.Delivery.Configured() || delivery.Delivery.AppRepoRef != "main" || delivery.Delivery.BuildOutputDirectory != "dist" || delivery.Delivery.BuildConfiguration != "production" || delivery.Delivery.WebhookURL() != "https://tekton.example.test/" { 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) 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) { func TestValidateDeliveryRequiresCompleteConfig(t *testing.T) {
cfg := validConfig(t) cfg := validConfig(t)
cfg.Delivery.ImageRepository = "" cfg.Delivery.ImageRepository = ""
@ -99,16 +114,26 @@ func TestValidateDeliveryRejectsUnsafeTektonDashboardURL(t *testing.T) {
} }
} }
func TestResolveAppOnboardingRequiresTargetOwner(t *testing.T) { func TestResolveAppOnboardingAllowsOnlyCanonicalCrossOwnerSource(t *testing.T) {
cfg := validConfig(t) cfg := validConfig(t)
cfg.Git.Owner = "test-org-2" cfg.Git.Owner = "test-org-2"
if _, err := ResolveAppOnboarding(cfg); err == nil || !strings.Contains(err.Error(), "owner must match") { cfg.Delivery.AppRepoURL = "https://git.example.test/Maidn/maidn-e2e-web.git"
t.Fatalf("ResolveAppOnboarding() accepted a source-owner target: %v", err) 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" 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/" { if resolved, err := ResolveAppOnboarding(cfg); err != nil || resolved.Delivery.WebhookURL() != "https://tekton.example.test/" {
t.Fatalf("ResolveAppOnboarding() = %#v, %v", resolved.Delivery, err) 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) { func TestResolveAppOnboardingRequiresProductionBranch(t *testing.T) {
@ -128,13 +153,15 @@ func TestValidateRejectsCredentialBearingDeliveryURLs(t *testing.T) {
} }
func TestValidateSecretGrants(t *testing.T) { func TestValidateSecretGrants(t *testing.T) {
if err := ValidateSecretGrants([]SecretGrant{{Application: "orders-api", Consumer: "runtime", Environment: "production", Shared: []string{"rabbitmq"}}}); err != nil { if err := ValidateSecretGrants([]SecretGrant{{Application: "orders-api", Consumer: "runtime", Environment: "production", Secrets: []string{"database"}, Shared: []string{"rabbitmq"}}}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
for _, grant := range []SecretGrant{ for _, grant := range []SecretGrant{
{Application: "orders-api", Consumer: "runtime", Environment: "preview"}, {Application: "orders-api", Consumer: "runtime", Environment: "preview"},
{Application: "orders-api", Consumer: "build", Environment: "staging"}, {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", Shared: []string{"../platform"}},
{Application: "orders-api", Consumer: "publish", Secrets: []string{"database", "database"}},
} { } {
if err := ValidateSecretGrants([]SecretGrant{grant}); err == nil { if err := ValidateSecretGrants([]SecretGrant{grant}); err == nil {
t.Fatalf("invalid secret grant accepted: %#v", grant) t.Fatalf("invalid secret grant accepted: %#v", grant)

View file

@ -16,12 +16,13 @@ type Config struct {
SOPS SOPSConfig `yaml:"sops"` SOPS SOPSConfig `yaml:"sops"`
} }
// SecretGrant gives one application consumer access to its own OpenBao path // SecretGrant gives one application consumer access to named application and
// and explicitly named shared paths. It contains references, never values. // shared OpenBao paths. It contains references, never values.
type SecretGrant struct { type SecretGrant struct {
Application string `yaml:"application"` Application string `yaml:"application"`
Consumer string `yaml:"consumer"` Consumer string `yaml:"consumer"`
Environment string `yaml:"environment,omitempty"` Environment string `yaml:"environment,omitempty"`
Secrets []string `yaml:"secrets"`
Shared []string `yaml:"shared,omitempty"` Shared []string `yaml:"shared,omitempty"`
} }
@ -44,6 +45,7 @@ type DeliveryConfig struct {
AppRepoRef string `yaml:"appRepoRef"` AppRepoRef string `yaml:"appRepoRef"`
ProductionBranch string `yaml:"productionBranch"` ProductionBranch string `yaml:"productionBranch"`
ImageRepository string `yaml:"imageRepository"` ImageRepository string `yaml:"imageRepository"`
BuildStrategy string `yaml:"buildStrategy"`
BuildOutputDirectory string `yaml:"buildOutputDirectory"` BuildOutputDirectory string `yaml:"buildOutputDirectory"`
BuildConfiguration string `yaml:"buildConfiguration"` BuildConfiguration string `yaml:"buildConfiguration"`
TektonDashboardURL string `yaml:"tektonDashboardUrl,omitempty"` TektonDashboardURL string `yaml:"tektonDashboardUrl,omitempty"`

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

@ -55,8 +55,9 @@ type mergePullRequestRequest struct {
} }
type hook struct { type hook struct {
ID int64 `json:"id"` ID int64 `json:"id"`
URL string `json:"url"` URL string `json:"url"`
Config map[string]string `json:"config"`
} }
type hookRequest struct { type hookRequest struct {
@ -96,6 +97,7 @@ type accessToken struct {
} }
var copyGit = runGit var copyGit = runGit
var hasRemoteBranch = (*RepoManager).HasRemoteBranch
func (e *APIError) Error() string { func (e *APIError) Error() string {
return fmt.Sprintf("forgejo returned %s", e.Status) return fmt.Sprintf("forgejo returned %s", e.Status)
@ -121,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) { 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) == "" { if strings.TrimSpace(baseURL) == "" || username == "" || password == "" || strings.TrimSpace(name) == "" {
return "", fmt.Errorf("Forgejo base URL, username, password, and token name are required") 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 { if err != nil {
return "", err return "", err
} }
@ -148,10 +164,10 @@ func createRegistryToken(client *http.Client, baseURL, username, password, otp,
} }
var token accessToken var token accessToken
if err := json.NewDecoder(response.Body).Decode(&token); err != nil { 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 == "" { 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 return token.SHA1, nil
} }
@ -404,7 +420,7 @@ func (rm *RepoManager) CreatePullRequest(repo, title, head, base string) error {
if err != nil { if err != nil {
return err 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 fmt.Errorf("unexpected Forgejo pull request status %d", status)
} }
return nil return nil
@ -493,70 +509,6 @@ func (rm *RepoManager) PushRef(dir, repoURL, sourceRef, targetBranch string) err
return runGit(dir, environment, "push", repoURL, sourceRef+":refs/heads/"+targetBranch) return runGit(dir, environment, "push", repoURL, sourceRef+":refs/heads/"+targetBranch)
} }
// DeliveryBranch returns the dedicated branch that carries generated delivery content.
func DeliveryBranch(appName, baseBranch string) (string, error) {
branch := "maidn/delivery-" + appName
if appName == "" || branch == baseBranch {
return "", fmt.Errorf("delivery branch and configured base branch must differ")
}
return branch, nil
}
// PublishDeliveryBranch generates and commits delivery content in a temporary clone.
func (rm *RepoManager) PublishDeliveryBranch(sourceDir, sourceBranch, repoURL, deliveryBranch string, generate func(string) error) (bool, error) {
if sourceDir == "" || sourceBranch == "" || deliveryBranch == "" || deliveryBranch == rm.Branch {
return false, fmt.Errorf("delivery source branch and dedicated delivery branch are required and must differ from the base branch")
}
hasBranch, err := rm.HasRemoteBranch(repoURL, deliveryBranch)
if err != nil {
return false, err
}
temporary, err := os.MkdirTemp("", "maidn-delivery-*")
if err != nil {
return false, err
}
defer os.RemoveAll(temporary)
cleanupAskPass, environment, err := rm.gitEnvironment()
if err != nil {
return false, err
}
defer cleanupAskPass()
if hasBranch {
if err := runGit("", environment, "clone", "--branch", deliveryBranch, repoURL, temporary); err != nil {
return false, err
}
} else if err := runGit("", os.Environ(), "clone", "--no-local", "--branch", sourceBranch, sourceDir, temporary); err != nil {
return false, err
}
if err := runGit(temporary, environment, "checkout", "-B", deliveryBranch); err != nil {
return false, err
}
if err := generate(temporary); err != nil {
return false, err
}
if err := runGit(temporary, environment, "add", ".tekton"); err != nil {
return false, err
}
changed, err := gitDiffQuiet(temporary, environment, "--cached")
if err != nil {
return false, err
}
if changed {
for _, args := range [][]string{{"config", "user.name", "Maidn"}, {"config", "user.email", "maidn@free-maidn.com"}, {"commit", "-m", "feat: add Maidn delivery pipeline"}} {
if err := runGit(temporary, environment, args...); err != nil {
return false, err
}
}
}
if !changed {
return false, nil
}
if err := rm.PushBranch(temporary, repoURL, deliveryBranch); err != nil {
return false, err
}
return true, nil
}
// PublishRepositoryPullRequest applies a managed change on a dedicated branch. // PublishRepositoryPullRequest applies a managed change on a dedicated branch.
// An existing branch is accepted only when it has exactly one open pull request. // 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) { func (rm *RepoManager) PublishRepositoryPullRequest(repo, title, branch, base string, change func(string) error) (bool, error) {
@ -564,10 +516,19 @@ func (rm *RepoManager) PublishRepositoryPullRequest(repo, title, branch, base st
return false, errors.New("repository pull request requires distinct non-empty branches") return false, errors.New("repository pull request requires distinct non-empty branches")
} }
repoURL := CloneURL(rm.BaseURL, rm.Owner, repo) repoURL := CloneURL(rm.BaseURL, rm.Owner, repo)
hasBranch, err := rm.HasRemoteBranch(repoURL, branch) hasBranch, err := hasRemoteBranch(rm, repoURL, branch)
if err != nil { if err != nil {
return false, err 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-*") temporary, err := os.MkdirTemp("", "maidn-registration-*")
if err != nil { if err != nil {
return false, err return false, err
@ -608,14 +569,11 @@ func (rm *RepoManager) PublishRepositoryPullRequest(repo, title, branch, base st
if !open { if !open {
return false, nil return false, nil
} }
return true, rm.MergePullRequest(repo, branch) return true, nil
} }
if err := rm.EnsurePullRequest(repo, title, branch, base); err != nil { if err := rm.EnsurePullRequest(repo, title, branch, base); err != nil {
return false, err return false, err
} }
if err := rm.MergePullRequest(repo, branch); err != nil {
return false, err
}
return true, nil return true, nil
} }
@ -761,7 +719,7 @@ func (rm *RepoManager) EnsureWebhook(repo, webhookURL, authorization string) err
Events: []string{"push", "pull_request"}, Events: []string{"push", "pull_request"},
} }
for _, existing := range hooks { for _, existing := range hooks {
if existing.URL != webhookURL { if hookURL(existing) != webhookURL {
continue continue
} }
request, err := json.Marshal(hookRequest{Active: createRequest.Active, AuthorizationHeader: createRequest.AuthorizationHeader, Config: createRequest.Config, Events: createRequest.Events}) request, err := json.Marshal(hookRequest{Active: createRequest.Active, AuthorizationHeader: createRequest.AuthorizationHeader, Config: createRequest.Config, Events: createRequest.Events})
@ -791,6 +749,13 @@ func (rm *RepoManager) EnsureWebhook(repo, webhookURL, authorization string) err
return nil 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. // TriggerWebhookTest asks Forgejo to deliver a test push for the managed hook.
func (rm *RepoManager) TriggerWebhookTest(repo, webhookURL, branch string) error { func (rm *RepoManager) TriggerWebhookTest(repo, webhookURL, branch string) error {
if repo == "" || webhookURL == "" || branch == "" { if repo == "" || webhookURL == "" || branch == "" {
@ -807,7 +772,7 @@ func (rm *RepoManager) TriggerWebhookTest(repo, webhookURL, branch string) error
} }
var matching []hook var matching []hook
for _, candidate := range hooks { for _, candidate := range hooks {
if candidate.URL == webhookURL { if hookURL(candidate) == webhookURL {
matching = append(matching, candidate) matching = append(matching, candidate)
} }
} }
@ -825,10 +790,10 @@ func (rm *RepoManager) TriggerWebhookTest(repo, webhookURL, branch string) error
return nil return nil
} }
// EnsureProtectedBranch disables direct pushes to the configured production branch. // EnsureProtectedBranch disables direct pushes to a managed branch.
func (rm *RepoManager) EnsureProtectedBranch(repo, branch string) error { func (rm *RepoManager) EnsureProtectedBranch(repo, branch string) error {
if repo == "" || branch == "" { if repo == "" || branch == "" {
return errors.New("Forgejo repository and production branch are required") 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) endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/branch_protections", rm.BaseURL, rm.Owner, repo)
var protections []branchProtection var protections []branchProtection
@ -846,11 +811,11 @@ func (rm *RepoManager) EnsureProtectedBranch(repo, branch string) error {
} }
} }
if len(matching) > 1 { if len(matching) > 1 {
return fmt.Errorf("multiple Forgejo branch protections match production branch %q", branch) return fmt.Errorf("multiple Forgejo branch protections match branch %q", branch)
} }
if len(matching) == 1 { if len(matching) == 1 {
if matching[0].EnablePush || matching[0].EnablePushWhitelist { if matching[0].EnablePush || matching[0].EnablePushWhitelist {
return fmt.Errorf("Forgejo production branch %q permits direct pushes", branch) return fmt.Errorf("Forgejo branch %q permits direct pushes", branch)
} }
return nil return nil
} }

View file

@ -41,6 +41,34 @@ 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) { func TestEnsureRepositoryCopyUsesAskPassAndCredentialFreeGitArguments(t *testing.T) {
original := copyGit original := copyGit
t.Cleanup(func() { copyGit = original }) t.Cleanup(func() { copyGit = original })
@ -184,7 +212,7 @@ func TestEnsureWebhookUpdatesMatchingURL(t *testing.T) {
if request.URL.Path != "/api/v1/repos/owner/app/hooks" { if request.URL.Path != "/api/v1/repos/owner/app/hooks" {
t.Fatalf("unexpected lookup path %q", request.URL.Path) 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: case http.MethodPatch:
if request.URL.Path != "/api/v1/repos/owner/app/hooks/7" { if request.URL.Path != "/api/v1/repos/owner/app/hooks/7" {
t.Fatalf("unexpected update path %q", request.URL.Path) t.Fatalf("unexpected update path %q", request.URL.Path)
@ -289,6 +317,28 @@ func TestEnsureProtectedBranchCreatesDirectPushProtection(t *testing.T) {
} }
} }
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) { func TestEnsureProtectedBranchRejectsExistingDirectPushRule(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodGet { if request.Method != http.MethodGet {
@ -350,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) { func TestMergePullRequest(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.Method { switch request.Method {
@ -376,6 +454,21 @@ 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) { func TestEnsurePullRequestChecksExactOpenBranchBeforeCreating(t *testing.T) {
requests := 0 requests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {

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

@ -20,6 +20,11 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
type AppSecretIdentityTokens struct {
Admin string
E2E string
}
type status struct { type status struct {
Initialized bool `json:"initialized"` Initialized bool `json:"initialized"`
Sealed bool `json:"sealed"` Sealed bool `json:"sealed"`
@ -236,7 +241,7 @@ func parseRecoveryMaterial(plaintext []byte) (RecoveryMaterial, error) {
return material, nil 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 { if !regexp.MustCompile(`^[a-z0-9][a-z0-9/_-]*$`).MatchString(secretPath) || len(values) == 0 {
return fmt.Errorf("invalid OpenBao secret path %q", secretPath) return fmt.Errorf("invalid OpenBao secret path %q", secretPath)
} }
@ -250,7 +255,7 @@ func writeSecret(kubeconfig, rootToken, secretPath string, values map[string]str
sort.Strings(keys) sort.Strings(keys)
arguments := make([]string, 0, len(keys)) arguments := make([]string, 0, len(keys))
input := strings.Builder{} input := strings.Builder{}
input.WriteString(rootToken) input.WriteString(token)
input.WriteByte('\n') input.WriteByte('\n')
for _, key := range keys { for _, key := range keys {
arguments = append(arguments, fmt.Sprintf("%s=\"$value%d\"", key, len(arguments))) arguments = append(arguments, fmt.Sprintf("%s=\"$value%d\"", key, len(arguments)))
@ -263,7 +268,7 @@ func writeSecret(kubeconfig, rootToken, secretPath string, values map[string]str
reads = append(reads, fmt.Sprintf("read -r value%d_b64", 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)) 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") + "\n" + strings.Join(decodes, "\n") + "\nexport BAO_TOKEN=\"$root_token\"\nbao kv put secret/" + secretPath + " " + strings.Join(arguments, " ") + " >/dev/null" 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) _, err := execInPodMutation(kubeconfig, []byte(input.String()), "sh", "-ec", script)
if err != nil { if err != nil {
return fmt.Errorf("write OpenBao secret %q", secretPath) return fmt.Errorf("write OpenBao secret %q", secretPath)
@ -393,14 +398,14 @@ func ConfigureSecretGrants(kubeconfig, identityPath, bundlePath string, grants [
for _, grant := range grants { for _, grant := range grants {
name := "maidn-" + grant.Application + "-" + grant.Consumer name := "maidn-" + grant.Application + "-" + grant.Consumer
namespace := "tekton-pipelines" namespace := "tekton-pipelines"
path := "apps/" + grant.Application + "/" + grant.Consumer
if grant.Consumer == "runtime" { if grant.Consumer == "runtime" {
name += "-" + grant.Environment name += "-" + grant.Environment
namespace = grant.Application + "-" + grant.Environment namespace = grant.Environment
path += "/" + grant.Environment
} }
script.WriteString("cat >/tmp/" + name + ".hcl <<'EOF'\n") script.WriteString("cat >/tmp/" + name + ".hcl <<'EOF'\n")
script.WriteString("path \"secret/data/" + path + "/*\" {\n capabilities = [\"read\"]\n}\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 { for _, shared := range grant.Shared {
script.WriteString("path \"secret/data/shared/" + shared + "/*\" {\n capabilities = [\"read\"]\n}\n") script.WriteString("path \"secret/data/shared/" + shared + "/*\" {\n capabilities = [\"read\"]\n}\n")
} }
@ -416,6 +421,62 @@ func ConfigureSecretGrants(kubeconfig, identityPath, bundlePath string, grants [
return nil 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 { func openBaoMutationError(action string, err error, output []byte, sensitive ...string) error {
diagnostic := redactOpenBaoDiagnostic(strings.TrimSpace(string(output)), sensitive...) diagnostic := redactOpenBaoDiagnostic(strings.TrimSpace(string(output)), sensitive...)
if diagnostic == "" && err != nil { if diagnostic == "" && err != nil {
@ -437,33 +498,19 @@ func redactOpenBaoDiagnostic(diagnostic string, sensitive ...string) string {
} }
func refreshExternalSecrets(kubeconfig string) error { func refreshExternalSecrets(kubeconfig string) error {
available, err := kubectlOutput(kubeconfig, "-n", "external-secrets", "get", "deployment/external-secrets", "-o=jsonpath={.status.conditions[?(@.type==\"Available\")].status}") 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" { if err != nil || strings.TrimSpace(string(available)) != "True" {
return nil return nil
} }
timestamp := time.Now().UnixNano() timestamp := time.Now().UnixNano()
_, err = kubectlOutput(kubeconfig, "annotate", "clustersecretstore", "openbao", fmt.Sprintf("force-sync=%d", timestamp), "--overwrite") 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 { if err != nil {
return fmt.Errorf("refresh OpenBao secret store after seed: %w", err) return fmt.Errorf("refresh OpenBao secret store after seed: %w", err)
} }
webhook, err := kubectlOutput(kubeconfig, "get", "externalsecret", "forgejo-webhook", "-n", "tekton-pipelines", "--ignore-not-found", "-o=name")
if err != nil {
return fmt.Errorf("check Forgejo webhook ExternalSecret after OpenBao seed: %w", err)
}
if strings.TrimSpace(string(webhook)) == "" {
return nil
}
_, err = kubectlOutput(kubeconfig, externalSecretRefreshArgs(timestamp)...)
if err != nil {
return fmt.Errorf("refresh ExternalSecrets after OpenBao seed: %w", err)
}
return nil return nil
} }
func externalSecretRefreshArgs(timestamp int64) []string {
return []string{"annotate", "externalsecret", "forgejo-webhook", "-n", "tekton-pipelines", fmt.Sprintf("force-sync=%d", timestamp), "--overwrite"}
}
func encryptRecovery(recipient, bundlePath string, plaintext []byte) error { func encryptRecovery(recipient, bundlePath string, plaintext []byte) error {
if err := os.MkdirAll(filepath.Dir(bundlePath), 0700); err != nil { if err := os.MkdirAll(filepath.Dir(bundlePath), 0700); err != nil {
return err return err

View file

@ -100,32 +100,13 @@ func TestRefreshExternalSecretsIsReadyGatedAndScoped(t *testing.T) {
if len(calls) == 1 { if len(calls) == 1 {
return []byte("True"), nil return []byte("True"), nil
} }
if len(calls) == 3 {
return []byte("externalsecret.external-secrets.io/forgejo-webhook"), nil
}
return nil, nil return nil, nil
} }
if err := refreshExternalSecrets("kubeconfig"); err != nil || len(calls) != 4 || !strings.Contains(calls[0], "get deployment/external-secrets") || !strings.Contains(calls[1], "annotate clustersecretstore openbao") || !strings.Contains(calls[2], "get externalsecret forgejo-webhook") || !strings.Contains(calls[3], "annotate externalsecret forgejo-webhook") || strings.Contains(calls[1], "--all") || strings.Contains(calls[3], "--all") { 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) t.Fatalf("ExternalSecret refresh was not readiness-gated and scoped: %q, %v", calls, err)
} }
} }
func TestRefreshExternalSecretsSkipsWebhookBeforeTekton(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) != 3 || !strings.Contains(calls[2], "--ignore-not-found") {
t.Fatalf("missing webhook ExternalSecret was not safely skipped: %q, %v", calls, err)
}
}
func TestConfigureSecretGrantsScopesApplicationAndSharedPaths(t *testing.T) { func TestConfigureSecretGrantsScopesApplicationAndSharedPaths(t *testing.T) {
originalDecrypt, originalExec := decryptRecovery, execInPodMutation originalDecrypt, originalExec := decryptRecovery, execInPodMutation
t.Cleanup(func() { decryptRecovery, execInPodMutation = originalDecrypt, originalExec }) t.Cleanup(func() { decryptRecovery, execInPodMutation = originalDecrypt, originalExec })
@ -141,19 +122,19 @@ func TestConfigureSecretGrantsScopesApplicationAndSharedPaths(t *testing.T) {
return nil, nil return nil, nil
} }
grants := []config.SecretGrant{ grants := []config.SecretGrant{
{Application: "orders-api", Consumer: "publish", Shared: []string{"artifact-cache"}}, {Application: "orders-api", Consumer: "publish", Secrets: []string{"registry"}, Shared: []string{"artifact-cache"}},
{Application: "orders-api", Consumer: "runtime", Environment: "production", Shared: []string{"rabbitmq"}}, {Application: "orders-api", Consumer: "runtime", Environment: "production", Secrets: []string{"database"}, Shared: []string{"rabbitmq"}},
} }
if err := ConfigureSecretGrants("kubeconfig", "identity", "bundle", grants); err != nil { if err := ConfigureSecretGrants("kubeconfig", "identity", "bundle", grants); err != nil {
t.Fatal(err) t.Fatal(err)
} }
for _, want := range []string{ for _, want := range []string{
`secret/data/apps/orders-api/publish/*`, `secret/data/apps/orders-api/registry`,
`secret/data/apps/orders-api/runtime/production/*`, `secret/data/apps/orders-api/database`,
`secret/data/shared/artifact-cache/*`, `secret/data/shared/artifact-cache/*`,
`secret/data/shared/rabbitmq/*`, `secret/data/shared/rabbitmq/*`,
`bound_service_account_names=maidn-orders-api-publish`, `bound_service_account_names=maidn-orders-api-publish`,
`bound_service_account_namespaces=orders-api-production`, `bound_service_account_namespaces=production`,
} { } {
if !strings.Contains(script, want) { if !strings.Contains(script, want) {
t.Fatalf("secret grant script missing %q: %s", want, script) t.Fatalf("secret grant script missing %q: %s", want, script)
@ -162,6 +143,9 @@ func TestConfigureSecretGrantsScopesApplicationAndSharedPaths(t *testing.T) {
if strings.Contains(script, `secret/data/*`) { if strings.Contains(script, `secret/data/*`) {
t.Fatal("secret grant widened access to every OpenBao secret") 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) { func TestConfigureSecretGrantsPreservesRedactedPolicyDiagnostics(t *testing.T) {
@ -174,12 +158,31 @@ func TestConfigureSecretGrantsPreservesRedactedPolicyDiagnostics(t *testing.T) {
execInPodMutation = func(_ string, _ []byte, _ ...string) ([]byte, error) { execInPodMutation = func(_ string, _ []byte, _ ...string) ([]byte, error) {
return []byte("policy write denied for " + rootToken), errors.New("exit status 1") return []byte("policy write denied for " + rootToken), errors.New("exit status 1")
} }
err := ConfigureSecretGrants("kubeconfig", "identity", "bundle", []config.SecretGrant{{Application: "orders-api", Consumer: "publish"}}) 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) { 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) 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) { func TestConfigureKubernetesAuthLimitsPlatformStore(t *testing.T) {
original := execInPodMutation original := execInPodMutation
t.Cleanup(func() { execInPodMutation = original }) t.Cleanup(func() { execInPodMutation = original })

View file

@ -98,6 +98,17 @@ func PromptForgejoRegistryToken() (password, otp, name string, err error) {
return password, otp, name, nil 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) { func promptForToken(reader *bufio.Reader, prompt string) (string, error) {
token := promptSecret(reader, prompt, "") token := promptSecret(reader, prompt, "")
if token == "" { if token == "" {
@ -156,6 +167,13 @@ func promptHiddenRequired(prompt string) (string, error) {
func promptHiddenOptional(prompt string) (string, error) { func promptHiddenOptional(prompt string) (string, error) {
fmt.Printf("%s: ", prompt) 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)) value, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println() fmt.Println()
if err != nil { if err != nil {