Compare commits

..

No commits in common. "main" and "fix/delivery-opt-in-reconcile" have entirely different histories.

46 changed files with 541 additions and 6711 deletions

View file

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

View file

@ -1,52 +0,0 @@
# 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.

View file

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

View file

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

View file

@ -1,295 +0,0 @@
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
}

View file

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

View file

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

View file

@ -1,77 +0,0 @@
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
}

View file

@ -1,56 +0,0 @@
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,
})
}

View file

@ -1,46 +0,0 @@
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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,34 +1,66 @@
# Secrets and recovery material
# Bootstrap Secret Inputs
## Storage contract
`bootstrap` uses the Flux age identity in ignored `.age/` storage to encrypt
the configured Democratic CSI Secret directly into the generated cluster
repository. Its TrueNAS API key is never printed or committed in plaintext.
- `sops.operationalSecretsPath` defaults to `<workspace>/operational-secrets.sops.yaml`. It is a SOPS-encrypted input containing operational secrets, keyed by logical OpenBao paths. It is never plaintext Git content.
- `sops.ageKeyPath` defaults to `<workspace>/.age/key.txt`. This Flux SOPS identity is private local material; do not commit, print, or copy it.
- `sops.recoveryIdentityPath` defaults to `<workspace>/.age/recovery-key.txt`. This separate OpenBao-recovery identity is private local material; do not commit, print, or copy it.
- `sops.recoveryBundlePath` defaults to `<workspace>/.recovery/openbao-recovery.age`. This bundle is encrypted to `sops.recoveryRecipient`; it contains OpenBao recovery material and is never plaintext Git content.
- During generated Flux repository reconciliation, recovery material is rendered only into `base/openbao/unseal.sops.yaml`, encrypted with the Flux SOPS identity. This is the narrowly scoped encrypted recovery path for the `openbao-unseal` controller; no plaintext recovery material belongs in a workload, config, log, or command argument.
`operational-secrets.sops.yaml` is decrypted only in MaidnCLI memory after
OpenBao is initialized. It is not copied to the cluster repository. Its schema
is:
## Bootstrap gates
```yaml
secrets:
cicd/forgejo:
username: encrypted-value
token: encrypted-value
cicd/forgejo-registry:
dockerconfigjson: encrypted-value
cicd/forgejo-webhook:
authorization: encrypted-value
platform/pihole:
server: encrypted-value
password: encrypted-value
platform/cloudflare:
api-token: encrypted-value
platform/cloudflare-tunnel:
credentials: encrypted-value
config: encrypted-value
```
`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.
Keys are written to OpenBao KV v2 under `secret/<path>`. Additional paths are
allowed when they use lowercase path characters and scalar property names.
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.
Set all `democraticCsi` settings in the bootstrap configuration or provide
them through the interactive wizard. The CLI writes those values only to
`base/democratic-csi/secret.sops.yaml` in the generated cluster repository.
For an existing configuration, run `bootstrap --config <path>
--prompt-democratic-csi` to enter the settings with the API key masked.
## Rules
For a new operational-secret input, run `bootstrap --config <path>
--prompt-operational-secrets`. It derives Forgejo Git and registry credentials
from the configured Forgejo account, prompts for the Pi-hole server and masked
password and Cloudflare API token, and generates the webhook authorization
value. The Cloudflare token issues the Gateway certificate and manages explicit
Tunnel CNAME records; it is not used by ExternalDNS.
- Credential values are accepted only at an approved secure input boundary and stored only in encrypted operational or recovery material.
- Never pass credential values in CLI arguments, URLs, logs, Git commits, generated config, tickets, or evidence.
- Do not revoke a previous credential until OpenBao, External Secrets, and every listed consumer have passed validation.
- Use the sanitized procedure in [runbooks/credential-rotation.md](runbooks/credential-rotation.md) for any live rotation.
- Application, artifact, and shared-secret access is documented in [secret-grants.md](secret-grants.md). Secret values remain outside that declaration.
Import the user-approved local credential file before bootstrap validates the
tunnel state:
## Automated app-secret identities
```powershell
go run . cloudflare-tunnel import --config <private-bootstrap-config> --credentials-file <local-credentials-json>
```
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.
The import command reads the file only in memory, validates its credential
shape, stores only the credentials JSON and terminal-404 local config shown
above, and seeds OpenBao. It never saves a Tunnel run token.
The template maps these simple OpenBao properties to the `credentials.json` and
`config.yml` Kubernetes filenames.
`cicd/forgejo-webhook.authorization` is required for delivery bootstrap. The
CLI supplies it as the Forgejo webhook Authorization header and Tekton compares
that header against the ExternalSecret-derived `forgejo-webhook` Secret.
Run `bootstrap --config <path> --initialize-openbao-recovery` to create and
save a separate recovery age identity for `openbao-recovery.age`. The Flux SOPS
age identity is installed in `flux-system`; it must not encrypt OpenBao
recovery material.

View file

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

View file

@ -44,12 +44,6 @@ type Runner struct {
Mode Mode
ConfirmRebuild bool
RegisterWebhook bool
RefreshWebhookSecret bool
EnableDelivery bool
SkipDeliveryScaffolding bool
AutoMergeBootstrapMigration bool
DestroyDemocraticCSIStorage bool
RefreshTemplateRevisions bool
}
type operationalSecrets struct {
@ -58,8 +52,6 @@ type operationalSecrets struct {
var initializeOpenBao = openbao.Initialize
var configureOpenBaoSecretGrants = openbao.ConfigureSecretGrants
var readOpenBaoRecovery = openbao.ReadRecoveryMaterial
var decryptGeneratedSOPS = decryptSOPSFile
@ -75,8 +67,6 @@ var ensureForgejoWebhook = func(cfg config.Config, repo, webhookURL, authorizati
return manager.EnsureWebhook(repo, webhookURL, authorization)
}
var readOperationalSecrets = ReadOperationalSecrets
var runWebhookCommand = utils.RunCommandQuietOutputInDir
var preflight = config.Preflight
@ -96,52 +86,20 @@ var runGitEnvironment = func(dir string, environment []string, args ...string) (
var verifyTalosVMs = verifyConfiguredTalosVMs
var terraformStateResources = listTerraformStateResources
var runTerraformStateList = func(terraformDir string, environment []string) ([]byte, []byte, error) {
var terraformStateResources = func(terraformDir string, environment []string) ([]string, error) {
command := exec.Command("terraform", "state", "list")
command.Dir = terraformDir
command.Env = append(os.Environ(), environment...)
var stdout, stderr bytes.Buffer
command.Stdout = &stdout
command.Stderr = &stderr
err := command.Run()
return stdout.Bytes(), stderr.Bytes(), err
}
func listTerraformStateResources(terraformDir string, environment []string) ([]string, error) {
state, stderr, err := runTerraformStateList(terraformDir, environment)
state, err := command.Output()
if err != nil {
if terraformNoStateFile(stderr) {
return nil, nil
}
return nil, fmt.Errorf("list Terraform state: %w", err)
}
return strings.Fields(string(state)), nil
}
var ansiEscapeSequence = regexp.MustCompile(`\x1b\[[0-?]*[ -/]*[@-~]`)
func terraformNoStateFile(stderr []byte) bool {
return strings.Contains(strings.ToLower(ansiEscapeSequence.ReplaceAllString(string(stderr), "")), "no state file")
}
var destroyTalosVMs = func(terraformDir string, environment []string) error {
command := exec.Command("terraform", "destroy", "-auto-approve", "-input=false", "-target=proxmox_virtual_environment_vm.vm")
command.Dir = terraformDir
command.Env = append(os.Environ(), environment...)
var stderr bytes.Buffer
command.Stdout = os.Stdout
command.Stderr = io.MultiWriter(os.Stderr, &stderr)
if err := command.Run(); err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
return utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "destroy", "-auto-approve", "-input=false", "-target=proxmox_virtual_environment_vm.vm")
}
return nil
}
var runTerraform = utils.RunCommandInDirEnv
var destroyTalosVMRetryDelay = 10 * time.Second
var webhookTargetTimeout = 70 * time.Minute
@ -159,30 +117,19 @@ var generatedTemplateFiles = map[string]map[string]bool{
var requiredClusterKustomizations = []string{"snapshot-crds-kustomization.yaml", "democratic-csi-kustomization.yaml", "cert-manager-kustomization.yaml", "cluster-issuers-kustomization.yaml", "gateway-api-kustomization.yaml", "cilium-kustomization.yaml", "cilium-config-kustomization.yaml", "openbao-kustomization.yaml", "external-secrets-kustomization.yaml", "cnpg-kustomization.yaml", "gateway-kustomization.yaml", "external-dns-kustomization.yaml", "cloudflare-tunnel-kustomization.yaml", "monitoring-kustomization.yaml", "tekton-kustomization.yaml", "tekton-triggers-kustomization.yaml", "cicd-manifests-repo.yaml"}
func (r Runner) Run() error {
if r.SkipDeliveryScaffolding {
r.Config.Delivery = config.DeliveryConfig{}
}
resolved, err := config.Resolve(r.Config)
if err != nil {
return err
}
r.Config = resolved
if r.RegisterWebhook || r.EnableDelivery {
if r.RegisterWebhook {
resolvedDelivery, err := config.ResolveDelivery(r.Config)
if err != nil {
return err
}
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")
}
deliveryConfigured := r.Config.Delivery.Configured()
if err := preflight(r.Config); err != nil {
return fmt.Errorf("preflight: %w", err)
}
@ -197,12 +144,16 @@ func (r Runner) Run() error {
return errors.New("SOPS recoveryRecipient is required")
}
}
if r.Mode == "" {
r.Mode = Reconcile
}
if r.Mode != Reconcile && r.Mode != Rebuild {
return fmt.Errorf("unsupported bootstrap mode %q", r.Mode)
}
if r.Mode == Rebuild && !r.ConfirmRebuild {
return fmt.Errorf("rebuild is destructive; rerun with --mode=rebuild --yes")
}
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))
}
if r.Config.SOPS.RecoveryIdentityPath == "" || r.Config.SOPS.RecoveryBundlePath == "" {
@ -215,15 +166,9 @@ func (r Runner) Run() error {
return fmt.Errorf("preflight OpenBao recovery bundle: %w", err)
}
workspace := r.Config.WorkspaceDir
if r.RefreshTemplateRevisions {
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, "")
if _, err := catalogManager.EnsureRepositoryCopy(r.Config.Flux.TektonCatalogRepo, "User-managed Tekton pipeline catalog", r.Config.Templates.TektonCatalogRepoURL); err != nil {
return fmt.Errorf("initialize Tekton catalog repository: %w", err)
@ -245,16 +190,23 @@ func (r Runner) Run() error {
},
func(dir string) error {
clusterDir := filepath.Join(dir, strings.TrimPrefix(r.Config.Flux.ClusterPath, "./"))
if deliveryConfigured {
if err := copyDir(filepath.Join(cicdTemplateDir, "base"), filepath.Join(dir, "base"), false); err != nil {
return err
}
if err := copyTemplateBaseComponents(cicdTemplateDir, dir, true); err != nil {
} else if err := copyDirExcept(filepath.Join(cicdTemplateDir, "base"), filepath.Join(dir, "base"), false, deliveryTemplateBaseComponents); err != nil {
return err
}
if err := copyTemplateBaseComponents(cicdTemplateDir, dir, deliveryConfigured); err != nil {
return err
}
if err := copyClusterTemplate(filepath.Join(cicdTemplateDir, "clusters", "template"), clusterDir); err != nil {
return err
}
for _, name := range []string{"external-secrets", "cnpg", "cloudflare-tunnel", "external-dns", "monitoring", "tekton"} {
if !deliveryConfigured && name == "tekton" {
continue
}
content, err := os.ReadFile(filepath.Join(cicdTemplateDir, "clusters", "template", name+"-kustomization.yaml"))
if err != nil {
return err
@ -273,9 +225,11 @@ func (r Runner) Run() error {
if err := copyAndRenderCiliumBases(cicdTemplateDir, dir, r.Config); err != nil {
return err
}
if err := copyAndRenderPlatformDeliveryBases(cicdTemplateDir, dir, r.Config); err != nil {
if deliveryConfigured {
if err := copyAndRenderDeliveryBases(cicdTemplateDir, dir, r.Config); err != nil {
return err
}
}
if err := writeDemocraticCSISecret(filepath.Join(dir, "base", "democratic-csi", "secret.sops.yaml"), r.Config.DemocraticCSI, r.Config.SOPS.AgeKeyPath); err != nil {
return err
}
@ -286,7 +240,7 @@ func (r Runner) Run() error {
if err := ensureOpenBaoUnsealKustomization(filepath.Join(openbaoDir, "kustomization.yaml")); err != nil {
return err
}
if err := ensureClusterKustomizations(clusterDir, true); err != nil {
if err := ensureClusterKustomizations(clusterDir, deliveryConfigured); err != nil {
return err
}
return ghrepo.WriteFluxStructure(dir, r.Config.Flux.RepoName, r.Config.Flux.ClusterPath, fluxConfig)
@ -294,8 +248,8 @@ func (r Runner) Run() error {
); err != nil {
return err
}
if err := r.reconcileBootstrapMigration(manager); err != nil {
return err
if manager.MigrationPending {
return errors.New("existing repository migration PR created; merge and rerun bootstrap before infrastructure changes")
}
if err := r.reconcileCloudflareTunnel(); err != nil {
return err
@ -316,11 +270,6 @@ func (r Runner) Run() error {
}
if r.Config.Talos.AutoRunTerraform {
if r.DestroyDemocraticCSIStorage {
if err := destroyDemocraticCSIStorage(r.Config.DemocraticCSI); err != nil {
return fmt.Errorf("destroy Democratic CSI storage: %w", err)
}
}
if err := r.reconcileTerraform(terraformDir); err != nil {
return err
}
@ -343,7 +292,7 @@ func (r Runner) Run() error {
}
}
if r.Config.Talos.AutoBootstrapFlux {
if err := installCilium(generatedDir, filepath.Join(cicdTemplateDir, "base", "cilium", "release.yaml"), r.Config); err != nil {
if err := installCilium(generatedDir, r.Config); err != nil {
return err
}
if err := utils.RunCommandInDirEnv(generatedDir, []string{"GIT_PASSWORD=" + r.Config.Git.Token}, "flux", "bootstrap", "git", "--url="+forgejo.CloneURL(r.Config.Git.BaseURL, r.Config.Git.Owner, r.Config.Flux.RepoName), "--branch="+r.Config.Flux.Branch, "--path="+r.Config.Flux.ClusterPath, "--cluster-domain="+r.Config.Flux.ClusterDomain, "--username="+r.Config.Git.Username, "--token-auth", "--kubeconfig=kubeconfig"); err != nil {
@ -355,39 +304,11 @@ func (r Runner) Run() error {
if err := configureFluxSOPS(generatedDir); err != nil {
return err
}
return r.completeFluxBootstrap(generatedDir)
return r.reconcileWebhook(generatedDir)
}
return nil
}
func (r Runner) reconcileBootstrapMigration(manager *forgejo.RepoManager) error {
if len(manager.MigrationRepositories) == 0 {
return nil
}
if !r.AutoMergeBootstrapMigration {
return fmt.Errorf("repository migration PRs created for %s; merge and rerun bootstrap before infrastructure changes", strings.Join(manager.MigrationRepositories, ", "))
}
for _, repository := range manager.MigrationRepositories {
if err := manager.MergePullRequest(repository, manager.MigrationBranch); err != nil {
return fmt.Errorf("merge bootstrap migration PR for %s: %w", repository, err)
}
}
return nil
}
func resolveLifecycleMode(mode Mode, confirmRebuild bool) (Mode, error) {
if mode == "" {
mode = Reconcile
}
if mode != Reconcile && mode != Rebuild {
return "", fmt.Errorf("unsupported bootstrap mode %q", mode)
}
if mode == Rebuild && !confirmRebuild {
return "", fmt.Errorf("rebuild is destructive; rerun with --mode=rebuild --yes")
}
return mode, nil
}
func (r Runner) reconcileCloudflareTunnel() error {
secrets, err := readCloudflareOperationalSecrets(r.Config.SOPS.OperationalSecretsPath, r.Config.SOPS.AgeKeyPath)
if err != nil {
@ -416,26 +337,15 @@ func (r Runner) reconcileCloudflareTunnel() error {
}
func (r Runner) reconcileWebhook(generatedDir string) error {
var (
operationalSecrets map[string]map[string]string
err error
)
if r.RefreshWebhookSecret {
operationalSecrets, err = r.initializeOpenBaoForCluster(generatedDir)
operationalSecrets, err := initializeOpenBao(filepath.Join(generatedDir, "kubeconfig"), r.Config.SOPS.RecoveryRecipient, r.Config.SOPS.RecoveryIdentityPath, r.Config.SOPS.RecoveryBundlePath, r.Config.SOPS.AgeKeyPath, r.Config.SOPS.OperationalSecretsPath)
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"]
if authorization == "" {
return errors.New("operational SOPS secrets requires cicd/forgejo-webhook.authorization")
}
if err := waitForWebhookTargets(generatedDir, r.Config); err != nil {
if err := waitForWebhookTargets(generatedDir, r.Config, authorization); err != nil {
return err
}
if err := ensureForgejoWebhook(r.Config, r.Config.Delivery.AppName, r.Config.Delivery.WebhookURL(), authorization); err != nil {
@ -444,26 +354,6 @@ func (r Runner) reconcileWebhook(generatedDir string) error {
return nil
}
func (r Runner) initializeOpenBaoForCluster(generatedDir string) (map[string]map[string]string, error) {
kubeconfig := filepath.Join(generatedDir, "kubeconfig")
secrets, err := initializeOpenBao(kubeconfig, r.Config.SOPS.RecoveryRecipient, r.Config.SOPS.RecoveryIdentityPath, r.Config.SOPS.RecoveryBundlePath, r.Config.SOPS.AgeKeyPath, r.Config.SOPS.OperationalSecretsPath)
if err != nil {
return nil, err
}
if err := configureOpenBaoSecretGrants(kubeconfig, r.Config.SOPS.RecoveryIdentityPath, r.Config.SOPS.RecoveryBundlePath, r.Config.SecretGrants); err != nil {
return nil, fmt.Errorf("configure OpenBao secret grants: %w", err)
}
return secrets, nil
}
// completeFluxBootstrap runs the post-Flux platform initialization only.
func (r Runner) completeFluxBootstrap(generatedDir string) error {
if _, err := r.initializeOpenBaoForCluster(generatedDir); err != nil {
return fmt.Errorf("initialize OpenBao: %w", err)
}
return nil
}
func renderCiliumConfig(dir string, cfg config.Config) error {
replacements := strings.NewReplacer(
"${CILIUM_K8S_SERVICE_HOST}", cfg.Talos.KubeconfigEndpoint,
@ -483,16 +373,19 @@ func renderCiliumConfig(dir string, cfg config.Config) error {
})
}
func renderPlatformDeliveryConfig(dir string, cfg config.Config) error {
func renderDeliveryConfig(dir string, cfg config.Config) error {
replacements := strings.NewReplacer(
"${APP_NAME}", cfg.Delivery.AppName,
"${APP_REPO_URL}", cfg.Delivery.AppRepoURL,
"${APP_REPO_REF}", cfg.Delivery.AppRepoRef,
"${PRODUCTION_BRANCH}", cfg.Delivery.ProductionBranch,
"${IMAGE_REPOSITORY}", cfg.Delivery.ImageRepository,
"${FORGEJO_BASE_URL}", cfg.Git.BaseURL,
"${CLUSTER_DOMAIN}", cfg.Flux.ClusterDomain,
"${GIT_OWNER}", cfg.Git.Owner,
"${FORGEJO_OWNER}", cfg.Git.Owner,
"${TEKTON_CATALOG_REPO_URL}", forgejo.CloneURL(cfg.Git.BaseURL, cfg.Git.Owner, cfg.Flux.TektonCatalogRepo),
"${TEKTON_CATALOG_REPO_REF}", cfg.Templates.TektonCatalogRepoRef,
"${WEBHOOK_HOSTNAME}", "tekton."+cfg.Flux.ClusterDomain,
"${WEBHOOK_PATH}", "/",
"${WEBHOOK_HOSTNAME}", cfg.Delivery.WebhookHostname,
"${WEBHOOK_PATH}", cfg.Delivery.WebhookPath,
)
return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
@ -506,39 +399,20 @@ func renderPlatformDeliveryConfig(dir string, cfg config.Config) error {
})
}
func copyAndRenderPlatformDeliveryBases(templateDir, repoDir string, cfg config.Config) error {
func copyAndRenderDeliveryBases(templateDir, repoDir string, cfg config.Config) error {
if err := config.ValidateDelivery(cfg); err != nil {
return err
}
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 {
baseDir := filepath.Join(repoDir, "base", base)
if err := copyDir(filepath.Join(templateDir, "base", base), baseDir, true); err != nil {
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 {
baseDir := filepath.Join(repoDir, "base", base)
if err := renderPlatformDeliveryConfig(baseDir, cfg); err != nil {
if err := renderDeliveryConfig(baseDir, cfg); err != nil {
return err
}
}
@ -564,7 +438,6 @@ func writePreviewDeliveryConfig(dir string, cfg config.Config) error {
"forgejo-origin": origin,
"manifests-url": manifestsURL,
"manifests-branch": cfg.Flux.Branch,
"tekton-dashboard-url": cfg.Delivery.TektonDashboardURL,
},
})
if err != nil {
@ -578,35 +451,13 @@ func writePreviewDeliveryConfig(dir string, cfg config.Config) error {
if !strings.Contains("\n"+string(data), "\nresources:") {
return errors.New("Tekton Kustomization must define resources before adding preview delivery configuration")
}
if err := os.WriteFile(filepath.Join(dir, "preview-delivery-config.yaml"), content, 0644); err != nil {
if err := os.WriteFile(filepath.Join(dir, "maidn-preview-delivery-config.yaml"), content, 0644); err != nil {
return err
}
legacyPath := filepath.Join(dir, "maidn-preview-delivery-config.yaml")
if err := os.Remove(legacyPath); err != nil && !os.IsNotExist(err) {
return err
}
updated := strings.ReplaceAll(string(data), " - maidn-preview-delivery-config.yaml\n", "")
if !strings.Contains(updated, "preview-delivery-config.yaml") {
updated += " - preview-delivery-config.yaml\n"
}
return os.WriteFile(path, []byte(updated), 0644)
}
func removeDuplicateAppDeliverySource(dir, appName string) error {
filename := appName + "-source.yaml"
if err := os.Remove(filepath.Join(dir, filename)); err != nil && !os.IsNotExist(err) {
return err
}
path := filepath.Join(dir, "kustomization.yaml")
data, err := os.ReadFile(path)
if err != nil {
return err
}
updated := strings.ReplaceAll(string(data), " - "+filename+"\n", "")
if updated == string(data) {
if strings.Contains(string(data), "maidn-preview-delivery-config.yaml") {
return nil
}
return os.WriteFile(path, []byte(updated), 0644)
return os.WriteFile(path, append(data, []byte(" - maidn-preview-delivery-config.yaml\n")...), 0644)
}
func canonicalForgejoOrigin(value string) (string, error) {
@ -624,26 +475,69 @@ type appDeliveryTemplateConfig struct {
AppRepoRef string
ProductionBranch string
ImageRepository string
BuildStrategy string
BuildOutputDirectory string
BuildConfiguration string
ForgejoBaseURL string
ForgejoOwner string
ManifestsURL string
ManifestsRepo 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,61}[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, want := range files {
got, err := os.ReadFile(filepath.Join(target, name))
if err != nil || !bytes.Equal(got, want) {
return errors.New("app delivery .tekton differs from Maidn generated content")
}
}
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) {
if !deliveryAppName.MatchString(cfg.Delivery.AppName) {
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)
if err != nil {
return nil, err
@ -655,10 +549,10 @@ func renderAppDelivery(cfg config.Config) ([]byte, error) {
values := appDeliveryTemplateConfig{
AppName: cfg.Delivery.AppName, AppRepository: appRepository, AppRepoURL: cfg.Delivery.AppRepoURL,
AppRepoRef: cfg.Delivery.AppRepoRef, ProductionBranch: cfg.Delivery.ProductionBranch, ImageRepository: cfg.Delivery.ImageRepository,
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, ClusterDomain: cfg.Flux.ClusterDomain,
BuildOutputDirectory: cfg.Delivery.BuildOutputDirectory, BuildConfiguration: cfg.Delivery.BuildConfiguration,
ForgejoBaseURL: origin, ManifestsURL: forgejo.CloneURL(origin, cfg.Git.Owner, cfg.Flux.ManifestsRepo), ManifestsBranch: cfg.Flux.Branch,
}
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} {
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, "manifestsUrl": values.ManifestsURL, "manifestsBranch": values.ManifestsBranch} {
if value == "" || strings.ContainsAny(value, "\r\n") || config.RedactURL(value) != value {
return nil, fmt.Errorf("delivery %s cannot be empty or contain credentials", name)
}
@ -671,7 +565,7 @@ func renderAppDelivery(cfg config.Config) ([]byte, error) {
if err := tmpl.Execute(&rendered, values); err != nil {
return nil, err
}
return bytes.ReplaceAll(rendered.Bytes(), []byte("\r\n"), []byte("\n")), nil
return rendered.Bytes(), nil
}
func deliveryRepository(baseURL, repositoryURL string) (string, error) {
@ -847,10 +741,10 @@ func writeOpenBaoUnsealSecret(path, recoveryIdentityPath, recoveryBundlePath, ag
}
func renderOpenBaoUnsealSecret(material openbao.RecoveryMaterial) ([]byte, error) {
if material.UnsealThreshold < 1 || len(material.UnsealKeysB64) < material.UnsealThreshold {
if material.RootToken == "" || material.UnsealThreshold < 1 || len(material.UnsealKeysB64) < material.UnsealThreshold {
return nil, errors.New("OpenBao recovery material is incomplete")
}
data := make(map[string]string, len(material.UnsealKeysB64))
data := map[string]string{"root-token": material.RootToken}
for index, share := range material.UnsealKeysB64 {
if share == "" {
return nil, errors.New("OpenBao recovery material contains an invalid unseal key")
@ -981,29 +875,9 @@ func InitializeOpenBao(cfg config.Config) error {
if _, err := initializeOpenBao(kubeconfig, cfg.SOPS.RecoveryRecipient, cfg.SOPS.RecoveryIdentityPath, cfg.SOPS.RecoveryBundlePath, cfg.SOPS.AgeKeyPath, cfg.SOPS.OperationalSecretsPath); err != nil {
return fmt.Errorf("initialize OpenBao: %w", err)
}
if err := configureOpenBaoSecretGrants(kubeconfig, cfg.SOPS.RecoveryIdentityPath, cfg.SOPS.RecoveryBundlePath, cfg.SecretGrants); err != nil {
return fmt.Errorf("configure OpenBao secret grants: %w", err)
}
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) {
value := make([]byte, 32)
if _, err := rand.Read(value); err != nil {
@ -1078,22 +952,38 @@ func renderDemocraticCSISecret(csi config.DemocraticCSIConfig) ([]byte, error) {
})
}
func waitForWebhookTargets(dir string, _ config.Config) error {
if err := waitForWebhookExternalSecret(dir); err != nil {
func waitForWebhookTargets(dir string, cfg config.Config, authorization string) error {
if err := waitForWebhookAuthorization(dir, authorization); err != nil {
return err
}
resources := []string{"deployment/el-" + cfg.Delivery.AppName, "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
}
func waitForWebhookExternalSecret(dir string) error {
func waitForWebhookAuthorization(dir, authorization string) error {
deadline := time.Now().Add(webhookTargetTimeout)
for {
output, err := runWebhookCommand(dir, "kubectl", "--kubeconfig=kubeconfig", "-n", "tekton-pipelines", "get", "externalsecret/forgejo-webhook", "-o=jsonpath={.status.conditions[0].status}")
if err == nil && strings.TrimSpace(string(output)) == "True" {
output, err := runWebhookCommand(dir, "kubectl", "--kubeconfig=kubeconfig", "-n", "tekton-pipelines", "get", "secret/forgejo-webhook", "-o=jsonpath={.data.authorization}")
if err == nil {
observed, decodeErr := base64.StdEncoding.DecodeString(strings.TrimSpace(string(output)))
if decodeErr == nil && string(observed) == authorization {
return nil
}
}
if !time.Now().Before(deadline) {
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")
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")
}
time.Sleep(webhookTargetPollInterval)
}
@ -1113,6 +1003,9 @@ func ensureClusterKustomizations(clusterDir string, includeDelivery bool) error
updated := string(content)
updated = strings.ReplaceAll(updated, " - bootstrap-secrets.sops.yaml\n", "")
for _, resource := range requiredClusterKustomizations {
if !includeDelivery && (resource == "gateway-kustomization.yaml" || resource == "tekton-kustomization.yaml" || resource == "tekton-triggers-kustomization.yaml") {
continue
}
if !strings.Contains(updated, resource) {
updated += " - " + resource + "\n"
}
@ -1125,15 +1018,10 @@ func ensureClusterKustomizations(clusterDir string, includeDelivery bool) error
func copyTemplateBaseComponents(templateDir, repoDir string, includeDelivery bool) error {
for _, component := range templateBaseComponents {
excluded := generatedTemplateFiles[component]
if component == "tekton" {
excluded = make(map[string]bool, len(excluded)+1)
for path, skip := range generatedTemplateFiles[component] {
excluded[path] = skip
if !includeDelivery && deliveryTemplateBaseComponents[component] {
continue
}
excluded["apps"] = true
}
if err := copyDirExcept(filepath.Join(templateDir, "base", component), filepath.Join(repoDir, "base", component), true, excluded); err != nil {
if err := copyDirExcept(filepath.Join(templateDir, "base", component), filepath.Join(repoDir, "base", component), true, generatedTemplateFiles[component]); err != nil {
return err
}
}
@ -1141,9 +1029,6 @@ func copyTemplateBaseComponents(templateDir, repoDir string, includeDelivery boo
}
func copyClusterTemplate(source, destination string) error {
if err := os.MkdirAll(destination, 0755); err != nil {
return err
}
entries, err := os.ReadDir(destination)
if err != nil && !os.IsNotExist(err) {
return err
@ -1181,40 +1066,12 @@ func ensureManifestsKustomizations(dir string) error {
return nil
}
func installCilium(dir, releasePath string, cfg config.Config) error {
func installCilium(dir string, cfg config.Config) error {
helmDir := filepath.Join(dir, ".helm")
if err := os.MkdirAll(helmDir, 0755); err != nil {
return err
}
// The generated HelmRelease is the sole Cilium chart-version authority.
version, err := ciliumChartVersion(releasePath)
if err != nil {
return err
}
return utils.RunCommandInDir(dir, "helm", "upgrade", "--install", "cilium", "cilium", "--repo=https://helm.cilium.io", "--version="+version, "--repository-config="+filepath.Join(helmDir, "repositories.yaml"), "--repository-cache="+helmDir, "--namespace=kube-system", "--create-namespace", "--kubeconfig=kubeconfig", "--wait", "--timeout=5m", "--set=kubeProxyReplacement=true", "--set=ipam.mode=kubernetes", "--set=k8sServiceHost=localhost", "--set=k8sServicePort=7445", "--set=cgroup.autoMount.enabled=false", "--set=cgroup.hostRoot=/sys/fs/cgroup", "--set=bpf.hostLegacyRouting=true", "--set=securityContext.capabilities.ciliumAgent={CHOWN,KILL,NET_ADMIN,NET_RAW,IPC_LOCK,SYS_ADMIN,SYS_RESOURCE,DAC_OVERRIDE,FOWNER,SETGID,SETUID}", "--set=securityContext.capabilities.cleanCiliumState={NET_ADMIN,SYS_ADMIN,SYS_RESOURCE}", "--set=envoy.enabled=true", "--set=gatewayAPI.enabled=true", "--set=hubble.enabled=true", "--set=hubble.relay.enabled=true", "--set=hubble.ui.enabled=true", "--set=l2announcements.enabled=true", "--set=rollOutCiliumPods=true", "--set=operator.replicas=1", "--set=operator.rollOutPods=true")
}
func ciliumChartVersion(path string) (string, error) {
content, err := os.ReadFile(path)
if err != nil {
return "", err
}
var release struct {
Spec struct {
Chart struct {
Spec struct {
Version string `yaml:"version"`
} `yaml:"spec"`
} `yaml:"chart"`
} `yaml:"spec"`
}
if err := yaml.Unmarshal(content, &release); err != nil {
return "", fmt.Errorf("parse Cilium HelmRelease: %w", err)
}
if release.Spec.Chart.Spec.Version == "" {
return "", errors.New("Cilium HelmRelease chart version is required")
}
return release.Spec.Chart.Spec.Version, nil
return utils.RunCommandInDir(dir, "helm", "upgrade", "--install", "cilium", "cilium", "--repo=https://helm.cilium.io", "--version=1.19.6", "--repository-config="+filepath.Join(helmDir, "repositories.yaml"), "--repository-cache="+helmDir, "--namespace=kube-system", "--create-namespace", "--kubeconfig=kubeconfig", "--wait", "--timeout=5m", "--set=kubeProxyReplacement=true", "--set=ipam.mode=kubernetes", "--set=k8sServiceHost=localhost", "--set=k8sServicePort=7445", "--set=cgroup.autoMount.enabled=false", "--set=cgroup.hostRoot=/sys/fs/cgroup", "--set=bpf.hostLegacyRouting=true", "--set=securityContext.capabilities.ciliumAgent={CHOWN,KILL,NET_ADMIN,NET_RAW,IPC_LOCK,SYS_ADMIN,SYS_RESOURCE,DAC_OVERRIDE,FOWNER,SETGID,SETUID}", "--set=securityContext.capabilities.cleanCiliumState={NET_ADMIN,SYS_ADMIN,SYS_RESOURCE}", "--set=envoy.enabled=true", "--set=gatewayAPI.enabled=true", "--set=l2announcements.enabled=true", "--set=rollOutCiliumPods=true", "--set=operator.replicas=1", "--set=operator.rollOutPods=true")
}
func copyDir(source, destination string, overwrite bool) error {
@ -1404,7 +1261,7 @@ func ensureLifecycleIdentity(terraformDir string, cfg config.Config) error {
func (r Runner) reconcileTerraform(terraformDir string) error {
environment := []string{"TF_VAR_proxmox_api_token=" + r.Config.Talos.Proxmox.APITokenID + "=" + r.Config.Talos.Proxmox.APITokenSecret}
if err := runTerraform(terraformDir, environment, "terraform", "init", "-input=false"); err != nil {
if err := utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "init", "-input=false"); err != nil {
return err
}
if err := importNetworkBridges(terraformDir, environment, r.Config); err != nil {
@ -1414,52 +1271,19 @@ func (r Runner) reconcileTerraform(terraformDir string) error {
if err := r.rebuildTalosVMs(terraformDir, environment); err != nil {
return err
}
} else if err := r.importConfiguredTalosVMs(terraformDir, environment); err != nil {
return err
}
planPath, err := terraformPlanPath(terraformDir, r.Config.ClusterID)
if err != nil {
return err
}
defer os.Remove(planPath)
planArgs := []string{"plan", "-input=false"}
if r.Mode == Reconcile {
planArgs = append(planArgs, terraformReconcileTargets(r.Config)...)
}
planArgs = append(planArgs, "-out="+planPath)
if err := runTerraform(terraformDir, environment, "terraform", planArgs...); err != nil {
if err := utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "plan", "-input=false", "-out="+planPath); err != nil {
return err
}
return runTerraform(terraformDir, environment, "terraform", "apply", "-input=false", "-auto-approve", planPath)
}
func terraformReconcileTargets(cfg config.Config) []string {
targets := []string{"-target=proxmox_virtual_environment_download_file.talos_iso", "-target=local_file.talconfig"}
if cfg.Talos.Cluster.ManageNetworkBridges {
targets = append(targets, "-target=proxmox_virtual_environment_network_linux_bridge.cluster_bridge")
}
return targets
return utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "apply", "-input=false", "-auto-approve", planPath)
}
func (r Runner) rebuildTalosVMs(terraformDir string, environment []string) error {
if err := r.importConfiguredTalosVMs(terraformDir, environment); err != nil {
var apiErr proxmox.APIError
if errors.As(err, &apiErr) && (apiErr.StatusCode == 404 || apiErr.StatusCode == 500 && apiErr.Message == `{"data":null}` && strings.HasSuffix(apiErr.Path, "/config")) {
return nil
}
return err
}
for attempt := 0; attempt < 3; attempt++ {
err := destroyTalosVMs(terraformDir, environment)
if err == nil || !strings.Contains(err.Error(), "context deadline exceeded") || attempt == 2 {
return err
}
time.Sleep(destroyTalosVMRetryDelay)
}
return nil
}
func (r Runner) importConfiguredTalosVMs(terraformDir string, environment []string) error {
if err := verifyTalosVMs(r.Config); err != nil {
return err
}
@ -1480,7 +1304,7 @@ func (r Runner) importConfiguredTalosVMs(terraformDir string, environment []stri
if err := verifyStateTalosVMs(resources, r.Config, true); err != nil {
return err
}
return nil
return destroyTalosVMs(terraformDir, environment)
}
type terraformImport struct {
@ -1557,7 +1381,7 @@ func importTerraformResources(terraformDir string, environment []string, imports
if resources[resource.Address] {
continue
}
if err := runTerraform(terraformDir, environment, "terraform", "import", "-input=false", resource.Address, resource.ID); err != nil {
if err := utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "import", "-input=false", resource.Address, resource.ID); err != nil {
return fmt.Errorf("import Terraform resource: %w", err)
}
}
@ -1685,42 +1509,6 @@ func EnsureTemplateRevisions(cfg config.Config) error {
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) {
var lock templateRevisionLock
data, err := os.ReadFile(path)
@ -1757,27 +1545,6 @@ func writeTemplateRevisionLock(path string, lock templateRevisionLock) error {
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 {
return validTemplateRevision(revision) && revision.Repository == config.RedactURL(checkout.Repository) && revision.Ref == checkout.Ref
}

View file

@ -4,7 +4,6 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
@ -18,7 +17,6 @@ import (
"github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
"github.com/Pingu-Studio/MaidnCLI/internal/openbao"
"github.com/Pingu-Studio/MaidnCLI/internal/proxmox"
"gopkg.in/yaml.v3"
)
@ -41,159 +39,53 @@ func TestRenderCiliumConfig(t *testing.T) {
}
}
func TestInitializeOpenBaoConfiguresDeclaredSecretGrants(t *testing.T) {
originalInitialize, originalGrants := initializeOpenBao, configureOpenBaoSecretGrants
t.Cleanup(func() { initializeOpenBao, configureOpenBaoSecretGrants = originalInitialize, originalGrants })
initializeOpenBao = func(_ string, _ string, _ string, _ string, _ string, _ string) (map[string]map[string]string, error) {
return map[string]map[string]string{"cicd/forgejo": {"token": "redacted"}}, nil
}
called := false
configureOpenBaoSecretGrants = func(kubeconfig, identity, bundle string, grants []config.SecretGrant) error {
called = kubeconfig == filepath.Join("generated", "kubeconfig") && identity == "identity" && bundle == "bundle" && len(grants) == 1 && grants[0].Application == "orders-api"
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", Secrets: []string{"database"}}}}}
if _, err := r.initializeOpenBaoForCluster("generated"); err != nil || !called {
t.Fatalf("secret grant configuration was not invoked: called=%t err=%v", called, err)
}
}
func TestCiliumChartVersion(t *testing.T) {
path := filepath.Join(t.TempDir(), "release.yaml")
if err := os.WriteFile(path, []byte("spec:\n chart:\n spec:\n version: test-version\n"), 0644); err != nil {
t.Fatal(err)
}
version, err := ciliumChartVersion(path)
if err != nil || version != "test-version" {
t.Fatalf("ciliumChartVersion() = %q, %v", version, err)
}
}
func TestRenderPlatformDeliveryConfig(t *testing.T) {
func TestRenderDeliveryConfig(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "webhook.yaml")
if err := os.WriteFile(path, []byte("host: ${WEBHOOK_HOSTNAME}\npath: ${WEBHOOK_PATH}\nowner: ${GIT_OWNER}\ncatalog: ${TEKTON_CATALOG_REPO_URL}\nref: ${TEKTON_CATALOG_REPO_REF}\n"), 0644); err != nil {
if err := os.WriteFile(path, []byte("host: ${WEBHOOK_HOSTNAME}\npath: ${WEBHOOK_PATH}\nproduction: ${PRODUCTION_BRANCH}\ncatalog: ${TEKTON_CATALOG_REPO_URL}\nref: ${TEKTON_CATALOG_REPO_REF}\n"), 0644); err != nil {
t.Fatal(err)
}
cfg := config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "user-org"}, Flux: config.FluxConfig{ClusterDomain: "example.test", TektonCatalogRepo: "my-tekton-catalog"}, Templates: config.TemplateConfig{TektonCatalogRepoRef: "release"}}
if err := renderPlatformDeliveryConfig(dir, cfg); err != nil {
cfg := config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "user-org"}, Flux: config.FluxConfig{TektonCatalogRepo: "my-tekton-catalog"}, Delivery: config.DeliveryConfig{ProductionBranch: "production", WebhookHostname: "tekton.example.test", WebhookPath: "/hooks/forgejo"}, Templates: config.TemplateConfig{TektonCatalogRepoRef: "release"}}
if err := renderDeliveryConfig(dir, cfg); err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(content), "${") || !strings.Contains(string(content), "host: tekton.example.test") || !strings.Contains(string(content), "owner: user-org") || !strings.Contains(string(content), "https://git.example.test/user-org/my-tekton-catalog.git") || !strings.Contains(string(content), "ref: release") {
t.Fatalf("platform delivery configuration was not rendered: %s", content)
if strings.Contains(string(content), "${") || !strings.Contains(string(content), "/hooks/forgejo") || !strings.Contains(string(content), "production: production") || !strings.Contains(string(content), "https://git.example.test/user-org/my-tekton-catalog.git") || !strings.Contains(string(content), "ref: release") {
t.Fatalf("delivery configuration was not rendered: %s", content)
}
}
func TestRemoveDuplicateAppDeliverySource(t *testing.T) {
func TestGeneratedDeliveryIsGenericAndUsesSafePreviewCleanupContract(t *testing.T) {
cfg := config.Config{
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform"},
Flux: config.FluxConfig{Branch: "main", 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"},
}
content, err := renderAppDelivery(cfg)
if err != nil {
t.Fatal(err)
}
for _, expected := range []string{"maidn-node-static-image", "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) {
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") {
t.Fatal("generated delivery contains a non-generic or unsafe literal")
}
}
func TestGenerateAppDeliveryRequiresCompleteConfig(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "kustomization.yaml"), []byte("resources:\n - easycsr-frontend-source.yaml\n - delivery-source.yaml\n"), 0644); err != nil {
t.Fatal(err)
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 appName") {
t.Fatalf("GenerateAppDelivery() error = %v, want incomplete delivery error", err)
}
if err := os.WriteFile(filepath.Join(dir, "easycsr-frontend-source.yaml"), []byte("stale: source\n"), 0644); err != nil {
t.Fatal(err)
}
if err := removeDuplicateAppDeliverySource(dir, "easycsr-frontend"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "easycsr-frontend-source.yaml")); !os.IsNotExist(err) {
t.Fatalf("duplicate app source remains: %v", err)
}
kustomization, err := os.ReadFile(filepath.Join(dir, "kustomization.yaml"))
if err != nil || strings.Contains(string(kustomization), "easycsr-frontend-source.yaml") || !strings.Contains(string(kustomization), "delivery-source.yaml") {
t.Fatalf("Tekton Kustomization = %q, %v", kustomization, err)
}
}
func TestGeneratedDeliveryUsesCombinedStaticBuildArtifactContract(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: "static", BuildOutputDirectory: "dist/web-ui", BuildConfiguration: "production"},
}
content, err := renderAppDelivery(cfg)
if err != nil {
t.Fatal(err)
}
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) {
t.Fatalf("generated delivery does not contain %q", expected)
}
}
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"} {
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) {
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: "static", BuildOutputDirectory: "dist", BuildConfiguration: "production"},
}
content, err := renderAppDelivery(cfg)
if err != nil {
t.Fatal(err)
}
rendered := string(content)
for _, expected := range []string{
`app_dir="apps/staging/$APP_NAME"`, `root=apps/staging/kustomization.yaml`, `name: $APP_NAME`, `namespace: flux-system`,
`PROMOTION_BRANCH="maidn/promotion-$APP_NAME-$TAG"`, `git push origin "HEAD:$PROMOTION_BRANCH"`, `--post-file "$pr_body"`,
`head=$FORGEJO_OWNER%3A$PROMOTION_BRANCH`, `value: $(params.forgejo-base-url)`,
} {
if !strings.Contains(rendered, expected) {
t.Fatalf("generated delivery does not contain %q", expected)
}
}
start := strings.Index(rendered, `if [ "$ENVIRONMENT" = production ]; then`)
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"`) {
t.Fatal("production path pushes directly to manifests main")
}
}
func TestRenderAppDeliveryRequiresCompleteConfig(t *testing.T) {
_, err := renderAppDelivery(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") {
t.Fatalf("renderAppDelivery() 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")
}
}
@ -202,21 +94,14 @@ func TestWritePreviewDeliveryConfigIsTrustedAndNonSecret(t *testing.T) {
if err := os.WriteFile(filepath.Join(dir, "kustomization.yaml"), []byte("resources:\n"), 0644); err != nil {
t.Fatal(err)
}
cfg := config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform", Token: "secret"}, Flux: config.FluxConfig{ManifestsRepo: "manifests", Branch: "main"}, Delivery: config.DeliveryConfig{AppName: "dynamic-app", TektonDashboardURL: "https://tekton.example.test"}}
cfg := config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform", Token: "secret"}, Flux: config.FluxConfig{ManifestsRepo: "manifests", Branch: "main"}, Delivery: config.DeliveryConfig{AppName: "dynamic-app"}}
if err := writePreviewDeliveryConfig(dir, cfg); err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(filepath.Join(dir, "preview-delivery-config.yaml"))
if err != nil || !strings.Contains(string(content), "forgejo-origin: https://git.example.test") || strings.Contains(string(content), "forgejo-base-url") || !strings.Contains(string(content), "manifests-url: https://git.example.test/platform/manifests.git") || !strings.Contains(string(content), "tekton-dashboard-url: https://tekton.example.test") || strings.Contains(string(content), "secret") || strings.Contains(string(content), "dynamic-app") {
content, err := os.ReadFile(filepath.Join(dir, "maidn-preview-delivery-config.yaml"))
if err != nil || !strings.Contains(string(content), "forgejo-origin: https://git.example.test") || strings.Contains(string(content), "forgejo-base-url") || !strings.Contains(string(content), "manifests-url: https://git.example.test/platform/manifests.git") || strings.Contains(string(content), "secret") || strings.Contains(string(content), "dynamic-app") {
t.Fatalf("preview delivery config is not trusted and non-secret: %q, %v", content, err)
}
if _, err := os.Stat(filepath.Join(dir, "maidn-preview-delivery-config.yaml")); !os.IsNotExist(err) {
t.Fatalf("obsolete preview config remains: %v", err)
}
kustomization, err := os.ReadFile(filepath.Join(dir, "kustomization.yaml"))
if err != nil || strings.Contains(string(kustomization), "maidn-preview-delivery-config.yaml") || !strings.Contains(string(kustomization), "preview-delivery-config.yaml") {
t.Fatalf("preview config resource was not replaced: %q, %v", kustomization, err)
}
}
func TestWritePreviewDeliveryConfigRejectsAmbiguousKustomization(t *testing.T) {
@ -230,15 +115,15 @@ func TestWritePreviewDeliveryConfigRejectsAmbiguousKustomization(t *testing.T) {
}
}
func TestCopyAndRenderPlatformDeliveryBasesOverwritesExistingMigrationOutput(t *testing.T) {
func TestCopyAndRenderDeliveryBasesOverwritesExistingMigrationOutput(t *testing.T) {
templateDir := t.TempDir()
repoDir := t.TempDir()
files := []struct {
base, name, template, want string
}{
{"gateway", "route.yaml", "host: ${WEBHOOK_HOSTNAME}\npath: ${WEBHOOK_PATH}\n", "host: tekton.example.test\npath: /\n"},
{"gateway", "route.yaml", "host: ${WEBHOOK_HOSTNAME}\npath: ${WEBHOOK_PATH}\n", "host: tekton.example.test\npath: /hooks/forgejo\n"},
{"tekton", "catalog-source.yaml", "url: ${TEKTON_CATALOG_REPO_URL}\nref: ${TEKTON_CATALOG_REPO_REF}\n", "url: https://git.example.test/user-org/my-tekton-catalog.git\nref: release\n"},
{"tekton-triggers", "trigger.yaml", "owner: ${FORGEJO_OWNER}\nhost: ${WEBHOOK_HOSTNAME}\n", "owner: user-org\nhost: tekton.example.test\n"},
{"tekton-triggers", "trigger.yaml", "app: ${APP_NAME}\nrepo: ${APP_REPO_URL}\n", "app: demo\nrepo: https://git.example.test/demo.git\n"},
}
for _, file := range files {
templatePath := filepath.Join(templateDir, "base", file.base, file.name)
@ -259,30 +144,18 @@ func TestCopyAndRenderPlatformDeliveryBasesOverwritesExistingMigrationOutput(t *
if err := os.WriteFile(filepath.Join(templateDir, "base", "tekton", "kustomization.yaml"), []byte("resources:\n"), 0644); err != nil {
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")
if err := os.WriteFile(customGatewayFile, []byte("custom: route\n"), 0644); err != nil {
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{
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "user-org"},
Flux: config.FluxConfig{ClusterDomain: "example.test", TektonCatalogRepo: "my-tekton-catalog", ManifestsRepo: "manifests", Branch: "main"},
Flux: config.FluxConfig{TektonCatalogRepo: "my-tekton-catalog", ManifestsRepo: "manifests", Branch: "main"},
Delivery: config.DeliveryConfig{AppName: "demo", AppRepoURL: "https://git.example.test/demo.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/demo", BuildOutputDirectory: "dist", BuildConfiguration: "production", WebhookHostname: "tekton.example.test", WebhookPath: "/hooks/forgejo"},
Templates: config.TemplateConfig{TektonCatalogRepoRef: "release"},
}
if err := copyAndRenderPlatformDeliveryBases(templateDir, repoDir, cfg); err != nil {
if err := copyAndRenderDeliveryBases(templateDir, repoDir, cfg); err != nil {
t.Fatal(err)
}
for _, file := range files {
@ -295,10 +168,6 @@ func TestCopyAndRenderPlatformDeliveryBasesOverwritesExistingMigrationOutput(t *
if err != nil || string(content) != "custom: route\n" {
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) {
@ -490,7 +359,7 @@ func TestWriteDemocraticCSISecretEncryptsValues(t *testing.T) {
}
}
func TestWriteOpenBaoUnsealSecretRendersOnlyUnsealShares(t *testing.T) {
func TestWriteOpenBaoUnsealSecretRendersRecoveryMaterial(t *testing.T) {
originalRead := readOpenBaoRecovery
originalWrite := writeGeneratedSOPS
t.Cleanup(func() {
@ -503,66 +372,24 @@ func TestWriteOpenBaoUnsealSecretRendersOnlyUnsealShares(t *testing.T) {
}
return openbao.RecoveryMaterial{RootToken: "test-root", UnsealThreshold: 2, UnsealKeysB64: []string{"test-share-1", "test-share-2", "test-share-3"}}, nil
}
var rendered []byte
writeGeneratedSOPS = func(path, ageKeyPath string, plaintext []byte) error {
if ageKeyPath != "flux-age-identity" {
t.Fatal("OpenBao unseal secret used the wrong Flux age identity")
}
var secret struct {
Metadata map[string]string `yaml:"metadata"`
StringData map[string]string `yaml:"stringData"`
}
if err := yaml.Unmarshal(plaintext, &secret); err != nil {
t.Fatal(err)
}
if secret.Metadata["name"] != "openbao-unseal" || secret.Metadata["namespace"] != "openbao" || len(secret.StringData) != 3 {
t.Fatal("OpenBao unseal Secret was not rendered")
}
if _, ok := secret.StringData["root-token"]; ok {
t.Fatal("OpenBao unseal Secret contains a root token")
}
for _, name := range []string{"unseal-1", "unseal-2", "unseal-3"} {
if _, ok := secret.StringData[name]; !ok {
t.Fatalf("OpenBao unseal Secret is missing %s", name)
}
}
return nil
rendered = append([]byte(nil), plaintext...)
return os.WriteFile(path, []byte("sops: {}\n"), 0600)
}
if err := writeOpenBaoUnsealSecret(filepath.Join(t.TempDir(), "unseal.sops.yaml"), "recovery-identity", "recovery-bundle", "flux-age-identity"); err != nil {
t.Fatal(err)
}
var secret struct {
Metadata map[string]string `yaml:"metadata"`
StringData map[string]string `yaml:"stringData"`
}
func TestWriteOpenBaoUnsealSecretEncryptsSharesWithoutRootToken(t *testing.T) {
if _, err := exec.LookPath("age-keygen"); err != nil {
t.Skip("age-keygen is required for bootstrap encryption")
}
if _, err := exec.LookPath("sops"); err != nil {
t.Skip("sops is required for bootstrap encryption")
}
originalRead := readOpenBaoRecovery
t.Cleanup(func() { readOpenBaoRecovery = originalRead })
readOpenBaoRecovery = func(string, string) (openbao.RecoveryMaterial, error) {
return openbao.RecoveryMaterial{RootToken: "test-root", UnsealThreshold: 2, UnsealKeysB64: []string{"test-share-1", "test-share-2", "test-share-3"}}, nil
}
dir := t.TempDir()
identity := filepath.Join(dir, "age-key.txt")
if err := exec.Command("age-keygen", "-o", identity).Run(); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "unseal.sops.yaml")
if err := writeOpenBaoUnsealSecret(path, "recovery-identity", "recovery-bundle", identity); err != nil {
t.Fatal(err)
}
encrypted, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(encrypted), "sops:") {
t.Fatal("OpenBao unseal Secret was not SOPS encrypted")
}
if strings.Contains(string(encrypted), "root-token") {
t.Fatal("SOPS-encrypted OpenBao unseal Secret contains a root token")
if err := yaml.Unmarshal(rendered, &secret); err != nil || secret.Metadata["name"] != "openbao-unseal" || secret.Metadata["namespace"] != "openbao" || len(secret.StringData) != 4 || secret.StringData["root-token"] == "" || secret.StringData["unseal-3"] == "" {
t.Fatal("OpenBao unseal Secret was not rendered")
}
}
@ -842,55 +669,6 @@ func TestEnsureClusterKustomizationsUsesTemplateExternalSecretsResource(t *testi
}
}
func TestCopyClusterTemplateCopiesCurrentClusterContract(t *testing.T) {
templateDir := t.TempDir()
clusterTemplate := filepath.Join(templateDir, "clusters", "template")
resources := []string{
"snapshot-crds-kustomization.yaml", "democratic-csi-kustomization.yaml", "cert-manager-kustomization.yaml", "cluster-issuers-kustomization.yaml", "gateway-api-kustomization.yaml", "cilium-kustomization.yaml", "cilium-config-kustomization.yaml", "openbao-kustomization.yaml", "external-secrets-kustomization.yaml", "external-secrets-config-kustomization.yaml", "cnpg-kustomization.yaml", "gateway-kustomization.yaml", "external-dns-kustomization.yaml", "cloudflare-tunnel-kustomization.yaml", "monitoring-kustomization.yaml", "tekton-kustomization.yaml", "tekton-triggers-kustomization.yaml",
}
if err := os.MkdirAll(clusterTemplate, 0755); err != nil {
t.Fatal(err)
}
var content strings.Builder
content.WriteString("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n")
for _, resource := range resources {
content.WriteString(" - " + resource + "\n")
if err := os.WriteFile(filepath.Join(clusterTemplate, resource), []byte("apiVersion: v1\nkind: ConfigMap\n"), 0644); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(clusterTemplate, "kustomization.yaml"), []byte(content.String()), 0644); err != nil {
t.Fatal(err)
}
clusterDir := filepath.Join(t.TempDir(), "clusters", "maidn-cd-0")
if err := copyClusterTemplate(clusterTemplate, clusterDir); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(clusterDir, "cicd-manifests-repo.yaml"), []byte("apiVersion: v1\nkind: ConfigMap\n"), 0644); err != nil {
t.Fatal(err)
}
if err := ensureClusterKustomizations(clusterDir, true); err != nil {
t.Fatal(err)
}
first, err := os.ReadFile(filepath.Join(clusterDir, "kustomization.yaml"))
if err != nil {
t.Fatal(err)
}
if err := ensureClusterKustomizations(clusterDir, true); err != nil {
t.Fatal(err)
}
second, err := os.ReadFile(filepath.Join(clusterDir, "kustomization.yaml"))
if err != nil || string(first) != string(second) {
t.Fatalf("cluster Kustomization was not generated idempotently: %q, %v", second, err)
}
for _, resource := range append(resources, "cicd-manifests-repo.yaml") {
if !strings.Contains(string(second), " - "+resource+"\n") {
t.Fatalf("cluster Kustomization omitted template resource %q: %q", resource, second)
}
}
}
func TestWebhookTargetTimeoutExceedsExternalSecretRefreshInterval(t *testing.T) {
if webhookTargetTimeout <= time.Hour {
t.Fatal("webhook target timeout must exceed the one-hour ExternalSecret refresh interval")
@ -932,87 +710,16 @@ func TestRunnerAutoBootstrapFluxIgnoresPartialLegacyDelivery(t *testing.T) {
}
}
func TestRunnerEnableDeliveryAppliesDefaults(t *testing.T) {
originalPreflight := preflight
t.Cleanup(func() { preflight = originalPreflight })
preflight = func(cfg config.Config) error {
if !cfg.Delivery.Configured() {
t.Fatal("delivery defaults were not applied")
}
return errors.New("reached preflight")
}
cfg := runnerTestConfig(t.TempDir(), "")
cfg.Delivery.AppRepoRef = ""
cfg.Delivery.BuildOutputDirectory = ""
cfg.Delivery.BuildConfiguration = ""
cfg.Delivery.WebhookHostname = ""
cfg.Delivery.WebhookPath = ""
err := (Runner{Config: cfg, EnableDelivery: true}).Run()
if err == nil || !strings.Contains(err.Error(), "reached preflight") {
t.Fatalf("enable delivery did not resolve defaults before preflight: %v", err)
}
}
func TestRunnerAutoMergesBootstrapMigration(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodGet && (request.URL.Path == "/api/v1/repos/test-org/manifests/pulls" || request.URL.Path == "/api/v1/repos/test-org/cluster/pulls") {
if request.URL.Query().Get("state") != "open" || request.URL.Query().Get("head") != "maidn/bootstrap-test-cluster" {
t.Fatalf("unexpected migration lookup query: %q", request.URL.RawQuery)
}
_ = json.NewEncoder(writer).Encode([]struct {
Number int `json:"number"`
}{{Number: 4}})
return
}
if request.Method == http.MethodPost && (request.URL.Path == "/api/v1/repos/test-org/manifests/pulls/4/merge" || request.URL.Path == "/api/v1/repos/test-org/cluster/pulls/4/merge") {
var body struct {
Do string `json:"Do"`
}
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.Do != "merge" {
t.Fatalf("unexpected migration merge request: %#v, %v", body, err)
}
writer.WriteHeader(http.StatusOK)
return
}
t.Fatalf("unexpected Forgejo request %s %s", request.Method, request.URL.Path)
}))
defer server.Close()
manager := forgejo.NewRepoManager(server.URL, "test-token", "test-org", "bot", "manifests", "cluster", "main", "maidn/bootstrap-test-cluster")
manager.HTTPClient = server.Client()
manager.MigrationRepositories = []string{"manifests", "cluster"}
if err := (Runner{Config: config.Config{Flux: config.FluxConfig{RepoName: "cluster"}}, AutoMergeBootstrapMigration: true}).reconcileBootstrapMigration(manager); err != nil {
t.Fatal(err)
}
}
func TestRunnerRetainsMigrationApprovalGate(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("normal bootstrap must not call Forgejo to merge a migration")
}))
defer server.Close()
manager := forgejo.NewRepoManager(server.URL, "test-token", "test-org", "bot", "manifests", "cluster", "main", "maidn/bootstrap-test-cluster")
manager.HTTPClient = server.Client()
manager.MigrationRepositories = []string{"manifests"}
if err := (Runner{Config: config.Config{Flux: config.FluxConfig{RepoName: "cluster"}}}).reconcileBootstrapMigration(manager); err == nil || !strings.Contains(err.Error(), "merge and rerun bootstrap") {
t.Fatalf("normal bootstrap migration gate = %v", err)
}
}
func TestRunnerRegisterWebhookSkipsTemplateRevisions(t *testing.T) {
originalPreflight := preflight
originalGit := runGit
originalInitialize := initializeOpenBao
originalRead := readOperationalSecrets
originalCommand := runWebhookCommand
originalWebhook := ensureForgejoWebhook
t.Cleanup(func() {
preflight = originalPreflight
runGit = originalGit
initializeOpenBao = originalInitialize
readOperationalSecrets = originalRead
runWebhookCommand = originalCommand
ensureForgejoWebhook = originalWebhook
})
@ -1031,12 +738,9 @@ func TestRunnerRegisterWebhookSkipsTemplateRevisions(t *testing.T) {
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
}
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) {
if strings.Contains(strings.Join(args, " "), "externalsecret/forgejo-webhook") {
return []byte("True"), nil
if strings.Contains(strings.Join(args, " "), "secret/forgejo-webhook") {
return []byte(base64.StdEncoding.EncodeToString([]byte(authorization))), nil
}
return nil, nil
}
@ -1066,16 +770,14 @@ func TestRunnerRegisterWebhookSkipsTemplateRevisions(t *testing.T) {
}
}
func TestReconcileWebhookWaitsForReadyExternalSecret(t *testing.T) {
func TestReconcileWebhookWaitsForRefreshedTargetSecret(t *testing.T) {
originalInitialize := initializeOpenBao
originalRead := readOperationalSecrets
originalCommand := runWebhookCommand
originalWebhook := ensureForgejoWebhook
originalTimeout := webhookTargetTimeout
originalInterval := webhookTargetPollInterval
t.Cleanup(func() {
initializeOpenBao = originalInitialize
readOperationalSecrets = originalRead
runWebhookCommand = originalCommand
ensureForgejoWebhook = originalWebhook
webhookTargetTimeout = originalTimeout
@ -1083,19 +785,21 @@ func TestReconcileWebhookWaitsForReadyExternalSecret(t *testing.T) {
})
authorization := "Bearer test-webhook-authorization"
staleTarget := base64.StdEncoding.EncodeToString([]byte("Bearer stale-webhook-authorization"))
refreshedTarget := base64.StdEncoding.EncodeToString([]byte(authorization))
targetChecks := 0
refreshedObserved := false
runWebhookCommand = func(_ string, name string, args ...string) ([]byte, error) {
if name != "kubectl" || strings.Contains(strings.Join(args, " "), authorization) {
t.Fatal("webhook target probe used an unexpected command")
}
if strings.Contains(strings.Join(args, " "), "externalsecret/forgejo-webhook") {
if strings.Contains(strings.Join(args, " "), "secret/forgejo-webhook") {
targetChecks++
if targetChecks == 1 {
return []byte("False"), nil
return []byte(staleTarget), nil
}
refreshedObserved = true
return []byte("True"), nil
return []byte(refreshedTarget), nil
}
return nil, nil
}
@ -1104,7 +808,7 @@ func TestReconcileWebhookWaitsForReadyExternalSecret(t *testing.T) {
patches := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if !refreshedObserved {
t.Error("Forgejo was called before the refreshed webhook ExternalSecret was ready")
t.Error("Forgejo was called before the refreshed webhook Secret was observed")
writer.WriteHeader(http.StatusInternalServerError)
return
}
@ -1136,29 +840,24 @@ func TestReconcileWebhookWaitsForReadyExternalSecret(t *testing.T) {
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
}
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: "/"}}
if err := (Runner{Config: cfg}).reconcileWebhook(t.TempDir()); err != nil {
t.Fatal(err)
}
if targetChecks != 2 || patches != 1 {
t.Fatal("Forgejo webhook was not updated after the ExternalSecret became ready")
t.Fatal("Forgejo webhook was not updated after the target Secret refreshed")
}
}
func TestReconcileWebhookTimeoutDoesNotUpdateForgejo(t *testing.T) {
originalInitialize := initializeOpenBao
originalRead := readOperationalSecrets
originalCommand := runWebhookCommand
originalWebhook := ensureForgejoWebhook
originalTimeout := webhookTargetTimeout
originalInterval := webhookTargetPollInterval
t.Cleanup(func() {
initializeOpenBao = originalInitialize
readOperationalSecrets = originalRead
runWebhookCommand = originalCommand
ensureForgejoWebhook = originalWebhook
webhookTargetTimeout = originalTimeout
@ -1169,11 +868,8 @@ func TestReconcileWebhookTimeoutDoesNotUpdateForgejo(t *testing.T) {
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
}
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) {
return []byte("False"), nil
return []byte(base64.StdEncoding.EncodeToString([]byte("Bearer stale-webhook-authorization"))), nil
}
webhookTargetTimeout = -time.Nanosecond
webhookTargetPollInterval = 0
@ -1188,7 +884,7 @@ func TestReconcileWebhookTimeoutDoesNotUpdateForgejo(t *testing.T) {
t.Fatal("webhook refresh timeout did not return a safe rerun error")
}
if webhookUpdated {
t.Fatal("Forgejo webhook update was attempted before the ExternalSecret became ready")
t.Fatal("Forgejo webhook update was attempted before the target Secret refreshed")
}
}
@ -1215,42 +911,6 @@ func TestTerraformPlanPathIsAbsolute(t *testing.T) {
}
}
func TestTerraformReconcileTargetsStageImageTalconfigAndManagedBridgesOnly(t *testing.T) {
if targets := strings.Join(terraformReconcileTargets(config.Config{}), " "); targets != "-target=proxmox_virtual_environment_download_file.talos_iso -target=local_file.talconfig" {
t.Fatalf("default reconcile targets = %q", targets)
}
cfg := config.Config{Talos: config.TalosConfig{Cluster: config.TalosClusterConfig{ManageNetworkBridges: true}}}
targets := strings.Join(terraformReconcileTargets(cfg), " ")
if !strings.Contains(targets, "-target=proxmox_virtual_environment_download_file.talos_iso") || !strings.Contains(targets, "-target=local_file.talconfig") || !strings.Contains(targets, "-target=proxmox_virtual_environment_network_linux_bridge.cluster_bridge") || strings.Contains(targets, "proxmox_virtual_environment_vm.vm") {
t.Fatalf("managed bridge reconcile targets = %q", targets)
}
}
func TestTerraformStateResourcesAllowsANSINoStateFile(t *testing.T) {
original := runTerraformStateList
t.Cleanup(func() { runTerraformStateList = original })
runTerraformStateList = func(string, []string) ([]byte, []byte, error) {
return nil, []byte("\x1b[31mError:\x1b[0m \x1b[31mNO STATE\x1b[0m FILE was found!\n"), errors.New("exit status 1")
}
resources, err := listTerraformStateResources("terraform", nil)
if err != nil || len(resources) != 0 {
t.Fatalf("no-state Terraform result = %q, %v; want empty resources and nil error", resources, err)
}
}
func TestTerraformStateResourcesRejectsUnexpectedStateError(t *testing.T) {
original := runTerraformStateList
t.Cleanup(func() { runTerraformStateList = original })
runTerraformStateList = func(string, []string) ([]byte, []byte, error) {
return nil, []byte("Error: failed to load backend\n"), errors.New("exit status 1")
}
if _, err := listTerraformStateResources("terraform", nil); err == nil || !strings.Contains(err.Error(), "list Terraform state") {
t.Fatalf("unexpected Terraform state error was accepted: %v", err)
}
}
func TestVerifyStateTalosVMsRejectsForeignAndMissingVMs(t *testing.T) {
cfg := config.Config{Talos: config.TalosConfig{Nodes: []config.TalosNode{{Name: "cp-01", ProxmoxNode: "pve", VMID: 100}, {Name: "worker-01", ProxmoxNode: "pve", VMID: 101}}}}
if err := verifyStateTalosVMs([]string{`proxmox_virtual_environment_vm.vm["cp-01"]`, `proxmox_virtual_environment_vm.vm["foreign"]`}, cfg, false); err == nil || !strings.Contains(err.Error(), "unconfigured") {
@ -1261,221 +921,6 @@ func TestVerifyStateTalosVMsRejectsForeignAndMissingVMs(t *testing.T) {
}
}
func TestReconcileTerraformImportsConfiguredTalosVMsBeforePlan(t *testing.T) {
originalVerify := verifyTalosVMs
originalStateList := runTerraformStateList
originalRun := runTerraform
t.Cleanup(func() {
verifyTalosVMs = originalVerify
runTerraformStateList = originalStateList
runTerraform = originalRun
})
cfg := config.Config{ClusterID: "test-cluster", Talos: config.TalosConfig{
Nodes: []config.TalosNode{{Name: "cp-01", ProxmoxNode: "pve", VMID: 100, Role: "controlplane"}},
}}
importedAddress := talosVMImports(cfg)[0].Address
var events, state []string
verifyTalosVMs = func(got config.Config) error {
if len(got.Talos.Nodes) != 1 || got.Talos.Nodes[0].Name != "cp-01" || got.Talos.Nodes[0].ProxmoxNode != "pve" || got.Talos.Nodes[0].VMID != 100 {
t.Fatal("Talos VM identity verification used the wrong config")
}
events = append(events, "verify")
return nil
}
runTerraformStateList = func(string, []string) ([]byte, []byte, error) {
return []byte(strings.Join(state, "\n")), nil, nil
}
runTerraform = func(_ string, _ []string, name string, args ...string) error {
if name != "terraform" {
t.Fatalf("unexpected command %q", name)
}
switch args[0] {
case "init":
events = append(events, "init")
case "import":
if len(args) != 4 || args[2] != importedAddress || args[3] != "pve/100" {
t.Fatalf("unexpected Talos VM import: %q", args)
}
events = append(events, "import")
state = append(state, importedAddress)
case "plan":
if len(state) != 1 || state[0] != importedAddress {
return errors.New("VM create collision")
}
if strings.Contains(strings.Join(args, " "), "proxmox_virtual_environment_vm.vm") || !strings.Contains(strings.Join(args, " "), "-target=proxmox_virtual_environment_download_file.talos_iso") || !strings.Contains(strings.Join(args, " "), "-target=local_file.talconfig") {
t.Fatalf("reconcile plan did not exclude the Talos VM: %q", args)
}
events = append(events, "plan")
case "apply":
if strings.Contains(strings.Join(args, " "), "proxmox_virtual_environment_vm.vm") {
t.Fatalf("reconcile apply did not exclude the Talos VM: %q", args)
}
events = append(events, "apply")
default:
t.Fatalf("unexpected Terraform operation %q", args[0])
}
return nil
}
if err := (Runner{Config: cfg, Mode: Reconcile}).reconcileTerraform(t.TempDir()); err != nil {
t.Fatal(err)
}
if strings.Join(events, ",") != "init,verify,import,plan,apply" {
t.Fatalf("Terraform phase order = %q", events)
}
}
func TestRebuildTerraformRetainsFullTalosVMLifecycle(t *testing.T) {
originalVerify := verifyTalosVMs
originalStateList := runTerraformStateList
originalRun := runTerraform
originalDestroy := destroyTalosVMs
t.Cleanup(func() {
verifyTalosVMs = originalVerify
runTerraformStateList = originalStateList
runTerraform = originalRun
destroyTalosVMs = originalDestroy
})
cfg := config.Config{ClusterID: "test-cluster", Talos: config.TalosConfig{
Nodes: []config.TalosNode{{Name: "cp-01", ProxmoxNode: "pve", VMID: 100, Role: "controlplane"}},
}}
importedAddress := talosVMImports(cfg)[0].Address
var events, state []string
verifyTalosVMs = func(config.Config) error {
events = append(events, "verify")
return nil
}
runTerraformStateList = func(string, []string) ([]byte, []byte, error) {
return []byte(strings.Join(state, "\n")), nil, nil
}
destroyTalosVMs = func(_ string, _ []string) error {
events = append(events, "destroy")
return nil
}
runTerraform = func(_ string, _ []string, name string, args ...string) error {
if name != "terraform" {
t.Fatalf("unexpected command %q", name)
}
switch args[0] {
case "init":
events = append(events, "init")
case "import":
state = append(state, importedAddress)
events = append(events, "import")
case "plan":
if strings.Contains(strings.Join(args, " "), "-target=") {
t.Fatalf("rebuild plan must retain the full VM lifecycle: %q", args)
}
events = append(events, "plan")
case "apply":
events = append(events, "apply")
default:
t.Fatalf("unexpected Terraform operation %q", args[0])
}
return nil
}
if err := (Runner{Config: cfg, Mode: Rebuild}).reconcileTerraform(t.TempDir()); err != nil {
t.Fatal(err)
}
if strings.Join(events, ",") != "init,verify,import,destroy,plan,apply" {
t.Fatalf("Terraform phase order = %q", events)
}
}
func TestRebuildTalosVMsRetriesOnlyDeadlineErrors(t *testing.T) {
originalDestroy := destroyTalosVMs
originalDelay := destroyTalosVMRetryDelay
t.Cleanup(func() {
destroyTalosVMs = originalDestroy
destroyTalosVMRetryDelay = originalDelay
})
destroyTalosVMRetryDelay = 0
for _, test := range []struct {
name string
err error
calls int
}{
{name: "deadline", err: fmt.Errorf("context deadline exceeded"), calls: 3},
{name: "other error", err: fmt.Errorf("permission denied"), calls: 1},
} {
t.Run(test.name, func(t *testing.T) {
calls := 0
destroyTalosVMs = func(string, []string) error {
calls++
return test.err
}
if err := (Runner{}).rebuildTalosVMs(t.TempDir(), nil); !errors.Is(err, test.err) {
t.Fatalf("rebuild Talos VMs error = %v, want %v", err, test.err)
}
if calls != test.calls {
t.Fatalf("destroy calls = %d, want %d", calls, test.calls)
}
})
}
}
func TestRebuildTerraformSkipsAlreadyAbsentTalosVM(t *testing.T) {
originalVerify := verifyTalosVMs
originalRun := runTerraform
originalDestroy := destroyTalosVMs
t.Cleanup(func() {
verifyTalosVMs = originalVerify
runTerraform = originalRun
destroyTalosVMs = originalDestroy
})
cfg := config.Config{ClusterID: "test-cluster", Talos: config.TalosConfig{
Nodes: []config.TalosNode{{Name: "cp-01", ProxmoxNode: "pve", VMID: 100, Role: "controlplane"}},
}}
var events []string
for _, apiErr := range []proxmox.APIError{{StatusCode: 404}, {StatusCode: 500, Message: `{"data":null}`, Path: "/nodes/pve/qemu/100/config"}} {
verifyTalosVMs = func(config.Config) error {
return fmt.Errorf("inspect configured Talos VM: %w", apiErr)
}
destroyTalosVMs = func(string, []string) error {
t.Fatal("destroy ran for an already-absent VM")
return nil
}
runTerraform = func(_ string, _ []string, name string, args ...string) error {
if name != "terraform" {
t.Fatalf("unexpected command %q", name)
}
switch args[0] {
case "init", "plan", "apply":
events = append(events, args[0])
default:
t.Fatalf("unexpected Terraform operation %q", args[0])
}
return nil
}
if err := (Runner{Config: cfg, Mode: Rebuild}).reconcileTerraform(t.TempDir()); err != nil {
t.Fatal(err)
}
if strings.Join(events, ",") != "init,plan,apply" {
t.Fatalf("Terraform phase order = %q", events)
}
events = nil
}
}
func TestRebuildTerraformRejectsUnexpectedProxmoxError(t *testing.T) {
originalVerify := verifyTalosVMs
t.Cleanup(func() { verifyTalosVMs = originalVerify })
verifyTalosVMs = func(config.Config) error {
return fmt.Errorf("inspect configured Talos VM: %w", proxmox.APIError{StatusCode: 500, Message: "server unavailable", Path: "/nodes/pve/qemu/100/config"})
}
err := (Runner{Config: config.Config{Talos: config.TalosConfig{Nodes: []config.TalosNode{{Name: "cp-01", ProxmoxNode: "pve", VMID: 100}}}}, Mode: Rebuild}).rebuildTalosVMs(t.TempDir(), nil)
if err == nil || !strings.Contains(err.Error(), "server unavailable") {
t.Fatalf("unexpected Proxmox error was accepted: %v", err)
}
}
func TestEnsureLifecycleIdentityStoresMetadataUnderGeneratedDirectory(t *testing.T) {
repo := t.TempDir()
terraformDir := filepath.Join(repo, "terraform")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -40,61 +40,20 @@ func TestEnsureTemplateRevisionsReusesLockedCommitAfterBranchDrift(t *testing.T)
if err := ensureTemplateRevisions(workspace, cfg); err != nil {
t.Fatal(err)
}
locked := map[string]string{}
for name, commit := range git.commits {
locked[name] = commit
}
git.commits = map[string]string{"cicd": strings.Repeat("d", 40), "manifests": strings.Repeat("e", 40), "talos": strings.Repeat("f", 40)}
locked := git.commits["cicd"]
git.commits["cicd"] = strings.Repeat("d", 40)
git.resetCalls()
if err := ensureTemplateRevisions(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 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 git.fetched["cicd"] != locked || git.checkedOut["cicd"] != locked {
t.Fatalf("branch drift changed locked CICD revision: fetched %q, checked out %q", git.fetched["cicd"], git.checkedOut["cicd"])
}
if strings.Contains(git.commands(), "fetch origin main") {
t.Fatal("later run fetched a mutable branch instead of the lock commit")
}
}
func TestRefreshTemplateRevisionsUpdatesExistingLockToConfiguredHeads(t *testing.T) {
workspace, cfg, git := templateRevisionTestConfig(t)
useTemplateRevisionGit(t, git)
if err := ensureTemplateRevisions(workspace, cfg); err != nil {
t.Fatal(err)
}
git.commits = map[string]string{"cicd": strings.Repeat("d", 40), "manifests": strings.Repeat("e", 40), "talos": strings.Repeat("f", 40)}
git.resetCalls()
if err := refreshTemplateRevisions(workspace, cfg); err != nil {
t.Fatal(err)
}
lock, err := readTemplateRevisionLock(filepath.Join(workspace, "maidn-template-revisions.yaml"))
if err != nil {
t.Fatal(err)
}
for name, commit := range git.commits {
if git.fetched[name] != commit || git.checkedOut[name] != commit {
t.Fatalf("refresh did not use configured %s branch head: fetched %q, checked out %q", name, git.fetched[name], git.checkedOut[name])
}
}
if lock.CICD.Commit != git.commits["cicd"] || lock.Manifests.Commit != git.commits["manifests"] || lock.Talos.Commit != git.commits["talos"] {
t.Fatalf("refresh did not update template lock: %#v", lock)
}
if !strings.Contains(git.commands(), "fetch origin main") || strings.Contains(git.commands(), "template-password") {
t.Fatal("refresh did not safely fetch configured branch heads")
}
}
func TestEnsureTemplateRevisionsRejectsChangedRefWithoutGit(t *testing.T) {
workspace, cfg, git := templateRevisionTestConfig(t)
useTemplateRevisionGit(t, git)

View file

@ -66,7 +66,6 @@ func WriteRedacted(path string, cfg Config) error {
redacted.Templates.TektonCatalogRepoURL = RedactURL(redacted.Templates.TektonCatalogRepoURL)
redacted.Delivery.AppRepoURL = RedactURL(redacted.Delivery.AppRepoURL)
redacted.Delivery.ImageRepository = RedactURL(redacted.Delivery.ImageRepository)
redacted.Delivery.TektonDashboardURL = RedactURL(redacted.Delivery.TektonDashboardURL)
data, err := yaml.Marshal(redacted)
if err != nil {
return err
@ -107,117 +106,7 @@ func ResolveDelivery(cfg Config) (Config, error) {
return cfg, ValidateDelivery(cfg)
}
// ResolveFreshBootstrap validates the scoped contract needed before a new
// Forgejo organization can be scaffolded. It deliberately does not validate
// recovery, SOPS, Proxmox, or Talos state.
func ResolveFreshBootstrap(cfg Config, organization string, enableDelivery bool) (Config, error) {
if !validRepositoryPart(organization) {
return cfg, errors.New("organization must be a Forgejo owner name")
}
if cfg.Git.Owner != "" && cfg.Git.Owner != organization {
return cfg, errors.New("git owner must match --organization")
}
cfg.Git.Owner = organization
if cfg.WorkspaceDir == "" || !filepath.IsAbs(cfg.WorkspaceDir) {
return cfg, errors.New("workspaceDir must be an absolute isolated workspace path")
}
if cfg.Git.CloneParent == "" || !filepath.IsAbs(cfg.Git.CloneParent) || !isChildPath(cfg.WorkspaceDir, cfg.Git.CloneParent) {
return cfg, errors.New("git cloneParent must be an absolute child of workspaceDir")
}
if cfg.Git.Provider != "forgejo" || cfg.Git.BaseURL == "" || cfg.Git.Username == "" || cfg.Git.Token == "" {
return cfg, errors.New("git provider, baseUrl, username, and token are required for fresh bootstrap")
}
if err := validateForgejoOrigin(cfg.Git.BaseURL); err != nil {
return cfg, err
}
if !validRepositoryPart(cfg.Flux.RepoName) || !validRepositoryPart(cfg.Flux.ManifestsRepo) || !validRepositoryPart(cfg.Flux.TektonCatalogRepo) || cfg.Flux.Branch == "" || cfg.Flux.ClusterPath == "" || cfg.Flux.ClusterDomain == "" {
return cfg, errors.New("flux repoName, manifestsRepo, tektonCatalogRepo, branch, clusterPath, and clusterDomain are required")
}
if cfg.Talos.RepoDirName == "" || filepath.Base(cfg.Talos.RepoDirName) != cfg.Talos.RepoDirName || cfg.Talos.GeneratedDir == "" {
return cfg, errors.New("talos repoDirName and generatedDir are required for template locking")
}
for _, source := range []struct{ URL, Ref string }{
{cfg.Templates.TalosRepoURL, cfg.Templates.TalosRepoRef},
{cfg.Templates.CICDRepoURL, cfg.Templates.CICDRepoRef},
{cfg.Templates.ManifestsRepoURL, cfg.Templates.ManifestsRepoRef},
{cfg.Templates.TektonCatalogRepoURL, cfg.Templates.TektonCatalogRepoRef},
} {
if source.URL == "" || source.Ref == "" || RedactURL(source.URL) != source.URL {
return cfg, errors.New("template repository URLs and refs must be explicit and credential-free")
}
if err := validateRepositoryURL(source.URL); err != nil {
return cfg, err
}
}
return cfg, nil
}
// ResolveAppOnboarding validates only the source-owned delivery contract.
func ResolveAppOnboarding(cfg Config) (Config, error) {
if cfg.Git.Owner == "" || cfg.Git.Username == "" || cfg.Git.Token == "" || cfg.Flux.RepoName == "" || cfg.Flux.ManifestsRepo == "" || cfg.Flux.Branch == "" || cfg.Flux.ClusterDomain == "" {
return cfg, errors.New("git owner, username, token, flux repoName, manifestsRepo, branch, and clusterDomain are required for app onboarding")
}
if err := validateForgejoOrigin(cfg.Git.BaseURL); err != nil {
return cfg, err
}
resolved, err := ResolveDelivery(cfg)
if err != nil {
return cfg, err
}
owner, repository, err := deliveryRepositoryOwner(resolved.Delivery.AppRepoURL)
if err != nil {
return cfg, err
}
if owner != resolved.Git.Owner && (owner != "Maidn" || !strings.HasPrefix(repository, "maidn-e2e-")) {
return cfg, errors.New("delivery appRepoUrl owner must match git owner or identify a canonical Maidn E2E fixture")
}
if resolved.Delivery.ProductionBranch != "production" {
return cfg, errors.New("delivery productionBranch must be literal production for app onboarding")
}
return resolved, nil
}
func deliveryRepositoryOwner(value string) (string, string, error) {
parsed, err := url.Parse(value)
if err != nil {
return "", "", errors.New("delivery appRepoUrl must identify one Forgejo owner/repository.git")
}
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
if len(parts) != 2 || !strings.HasSuffix(parts[1], ".git") || !validRepositoryPart(parts[0]) || !validRepositoryPart(strings.TrimSuffix(parts[1], ".git")) {
return "", "", errors.New("delivery appRepoUrl must identify one Forgejo owner/repository.git")
}
return parts[0], strings.TrimSuffix(parts[1], ".git"), nil
}
func validRepositoryPart(value string) bool {
return value != "" && !strings.Contains(value, "..") && regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`).MatchString(value)
}
func isChildPath(parent, child string) bool {
relative, err := filepath.Rel(filepath.Clean(parent), filepath.Clean(child))
return err == nil && relative != "." && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative)
}
func validateForgejoOrigin(value string) error {
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawPath != "" || parsed.RawQuery != "" || parsed.Fragment != "" || strings.Trim(parsed.Path, "/") != "" {
return errors.New("git baseUrl must be a credential-free HTTPS origin")
}
return nil
}
func validateRepositoryURL(value string) error {
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || strings.Trim(parsed.Path, "/") == "" {
return errors.New("template repository URL must be a credential-free HTTPS repository URL")
}
return nil
}
func applyDefaults(cfg *Config) {
if cfg.Delivery.BuildStrategy == "" {
cfg.Delivery.BuildStrategy = "static"
}
if cfg.ClusterID == "" {
cfg.ClusterID = cfg.Talos.Cluster.Name
}
@ -320,9 +209,6 @@ func applyDefaults(cfg *Config) {
}
func applyDeliveryDefaults(cfg *Config) {
if cfg.Delivery.BuildStrategy == "" {
cfg.Delivery.BuildStrategy = "static"
}
if cfg.Delivery.AppRepoRef == "" {
cfg.Delivery.AppRepoRef = cfg.Flux.Branch
}
@ -373,9 +259,6 @@ func Validate(cfg Config) error {
return err
}
}
if err := ValidateSecretGrants(cfg.SecretGrants); err != nil {
return err
}
if cfg.Templates.TalosRepoURL == "" || cfg.Templates.TalosRepoRef == "" || cfg.Templates.CICDRepoURL == "" || cfg.Templates.CICDRepoRef == "" || cfg.Templates.ManifestsRepoURL == "" || cfg.Templates.ManifestsRepoRef == "" || cfg.Templates.TektonCatalogRepoURL == "" || cfg.Templates.TektonCatalogRepoRef == "" {
return errors.New("all template repository URLs and refs are required")
}
@ -497,59 +380,13 @@ func Validate(cfg Config) error {
return nil
}
// ValidateSecretGrants prevents a configuration change from widening an
// application's OpenBao policy outside its own path or named shared grants.
func ValidateSecretGrants(grants []SecretGrant) error {
name := regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
seen := map[string]bool{}
for _, grant := range grants {
if !name.MatchString(grant.Application) {
return errors.New("secret grant application must be a lowercase DNS label")
}
if grant.Consumer != "build" && grant.Consumer != "publish" && grant.Consumer != "runtime" {
return errors.New("secret grant consumer must be build, publish, or runtime")
}
if grant.Consumer == "runtime" {
if grant.Environment != "staging" && grant.Environment != "production" {
return errors.New("runtime secret grant environment must be staging or production")
}
} else if grant.Environment != "" {
return errors.New("build and publish secret grants must not set environment")
}
key := grant.Application + "/" + grant.Consumer + "/" + grant.Environment
if seen[key] {
return errors.New("duplicate secret grant consumer")
}
seen[key] = true
if len(grant.Secrets) == 0 {
return errors.New("secret grant requires at least one application secret")
}
secretNames := map[string]bool{}
for _, secret := range grant.Secrets {
if !name.MatchString(secret) || secretNames[secret] {
return errors.New("secret grant application secret names must be unique lowercase DNS labels")
}
secretNames[secret] = true
}
for _, shared := range grant.Shared {
if !name.MatchString(shared) {
return errors.New("shared secret grant name must be a lowercase DNS label")
}
}
}
return nil
}
// ValidateDelivery requires the complete app-delivery contract before rendering or publishing it.
func ValidateDelivery(cfg Config) error {
if cfg.Delivery.AppName == "" || cfg.Delivery.AppRepoURL == "" || cfg.Delivery.AppRepoRef == "" || cfg.Delivery.ProductionBranch == "" || cfg.Delivery.ImageRepository == "" || cfg.Delivery.BuildOutputDirectory == "" || cfg.Delivery.BuildConfiguration == "" || cfg.Delivery.WebhookHostname == "" || cfg.Delivery.WebhookPath == "" {
return errors.New("delivery appName, appRepoUrl, appRepoRef, productionBranch, imageRepository, buildOutputDirectory, buildConfiguration, webhookHostname, and webhookPath are required")
}
if !regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,45}[a-z0-9])?$`).MatchString(cfg.Delivery.AppName) {
return errors.New("delivery appName must be a lowercase DNS label of at most 47 characters")
}
if cfg.Delivery.BuildStrategy != "static" && cfg.Delivery.BuildStrategy != "runtime" {
return errors.New("delivery buildStrategy must be static or runtime")
if !regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`).MatchString(cfg.Delivery.AppName) {
return errors.New("delivery appName must be a lowercase DNS label")
}
if cfg.Delivery.ProductionBranch == cfg.Delivery.AppRepoRef || !validDeliveryBranch(cfg.Delivery.ProductionBranch) {
return errors.New("delivery productionBranch must be a valid branch distinct from appRepoRef")
@ -560,11 +397,6 @@ func ValidateDelivery(cfg Config) error {
if err := validateDeliveryRepositoryOrigin(cfg.Git.BaseURL, cfg.Delivery.AppRepoURL); err != nil {
return err
}
if cfg.Delivery.TektonDashboardURL != "" {
if err := validateForgejoOrigin(cfg.Delivery.TektonDashboardURL); err != nil {
return errors.New("delivery tektonDashboardUrl must be a credential-free HTTPS origin")
}
}
if strings.ContainsAny(cfg.Delivery.WebhookHostname, "/:@?#") || !strings.HasPrefix(cfg.Delivery.WebhookPath, "/") || strings.ContainsAny(cfg.Delivery.WebhookPath, "?#") {
return errors.New("delivery webhookHostname must be a hostname and webhookPath must be an absolute path")
}

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"},
Cilium: CiliumConfig{TrafficInterface: "eth1", LoadBalancerStart: "192.168.45.19", LoadBalancerEnd: "192.168.45.30"},
DemocraticCSI: DemocraticCSIConfig{TrueNASAPIKey: "api-key", TrueNASHost: "truenas.example.test", TargetPortal: "truenas.example.test:3260", ShareHost: "truenas.example.test", DatasetParentNFS: "pool/kubernetes/nfs/v", DatasetSnapshotsNFS: "pool/kubernetes/nfs/s", AllowedNetworks: "192.168.45.0/24", NameSuffix: "-test", PortalGroup: "1", InitiatorGroup: "1"},
Delivery: DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/test-org/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/test-org/web-ui", BuildStrategy: "static", BuildOutputDirectory: "dist", BuildConfiguration: "production", WebhookHostname: "tekton.example.test", WebhookPath: "/"},
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: "/"},
Talos: TalosConfig{
RepoDirName: "talos", TerraformDir: "terraform", GeneratedDir: "generated", ConfigFileName: "terraform.tfvars",
Proxmox: TalosProxmoxConfig{APIURL: "https://proxmox.example.test:8006", APITokenID: "id", APITokenSecret: "secret"},
@ -64,7 +64,6 @@ func TestResolveDeliveryAppliesDefaultsOnlyForExplicitAppOperations(t *testing.T
cfg.Delivery.AppRepoRef = ""
cfg.Delivery.BuildOutputDirectory = ""
cfg.Delivery.BuildConfiguration = ""
cfg.Delivery.BuildStrategy = ""
cfg.Delivery.WebhookHostname = ""
cfg.Delivery.WebhookPath = ""
@ -79,25 +78,11 @@ func TestResolveDeliveryAppliesDefaultsOnlyForExplicitAppOperations(t *testing.T
if err != nil {
t.Fatal(err)
}
if !delivery.Delivery.Configured() || delivery.Delivery.BuildStrategy != "static" || delivery.Delivery.AppRepoRef != "main" || delivery.Delivery.BuildOutputDirectory != "dist" || delivery.Delivery.BuildConfiguration != "production" || delivery.Delivery.WebhookURL() != "https://tekton.example.test/" {
if !delivery.Delivery.Configured() || delivery.Delivery.AppRepoRef != "main" || delivery.Delivery.BuildOutputDirectory != "dist" || delivery.Delivery.BuildConfiguration != "production" || delivery.Delivery.WebhookURL() != "https://tekton.example.test/" {
t.Fatalf("ResolveDelivery() did not apply the complete delivery contract: %#v", delivery.Delivery)
}
}
func TestValidateDeliveryBuildStrategy(t *testing.T) {
cfg := validConfig(t)
for _, strategy := range []string{"static", "runtime"} {
cfg.Delivery.BuildStrategy = strategy
if err := ValidateDelivery(cfg); err != nil {
t.Fatalf("ValidateDelivery() rejected %q: %v", strategy, err)
}
}
cfg.Delivery.BuildStrategy = "container"
if err := ValidateDelivery(cfg); err == nil || !strings.Contains(err.Error(), "buildStrategy") {
t.Fatalf("ValidateDelivery() accepted invalid build strategy: %v", err)
}
}
func TestValidateDeliveryRequiresCompleteConfig(t *testing.T) {
cfg := validConfig(t)
cfg.Delivery.ImageRepository = ""
@ -106,44 +91,6 @@ func TestValidateDeliveryRequiresCompleteConfig(t *testing.T) {
}
}
func TestValidateDeliveryRejectsUnsafeTektonDashboardURL(t *testing.T) {
cfg := validConfig(t)
cfg.Delivery.TektonDashboardURL = "https://token@example.test"
if err := ValidateDelivery(cfg); err == nil || !strings.Contains(err.Error(), "tektonDashboardUrl") {
t.Fatalf("ValidateDelivery() accepted credential-bearing dashboard URL: %v", err)
}
}
func TestResolveAppOnboardingAllowsOnlyCanonicalCrossOwnerSource(t *testing.T) {
cfg := validConfig(t)
cfg.Git.Owner = "test-org-2"
cfg.Delivery.AppRepoURL = "https://git.example.test/Maidn/maidn-e2e-web.git"
if _, err := ResolveAppOnboarding(cfg); err != nil {
t.Fatalf("ResolveAppOnboarding() rejected canonical source: %v", err)
}
cfg.Delivery.AppRepoURL = "https://git.example.test/test-org-2/web-ui.git"
if resolved, err := ResolveAppOnboarding(cfg); err != nil || resolved.Delivery.WebhookURL() != "https://tekton.example.test/" {
t.Fatalf("ResolveAppOnboarding() = %#v, %v", resolved.Delivery, err)
}
for _, appRepoURL := range []string{
"https://git.example.test/Maidn/web-ui.git",
"https://git.example.test/other-org/maidn-e2e-web.git",
} {
cfg.Delivery.AppRepoURL = appRepoURL
if _, err := ResolveAppOnboarding(cfg); err == nil || !strings.Contains(err.Error(), "canonical Maidn E2E fixture") {
t.Fatalf("ResolveAppOnboarding() accepted noncanonical cross-owner source %q: %v", appRepoURL, err)
}
}
}
func TestResolveAppOnboardingRequiresProductionBranch(t *testing.T) {
cfg := validConfig(t)
cfg.Delivery.ProductionBranch = "release"
if _, err := ResolveAppOnboarding(cfg); err == nil || !strings.Contains(err.Error(), "literal production") {
t.Fatalf("ResolveAppOnboarding() accepted nonstandard production branch: %v", err)
}
}
func TestValidateRejectsCredentialBearingDeliveryURLs(t *testing.T) {
cfg := validConfig(t)
cfg.Delivery.AppRepoURL = "https://reader:token@git.example.test/test-org/web-ui.git"
@ -152,23 +99,6 @@ func TestValidateRejectsCredentialBearingDeliveryURLs(t *testing.T) {
}
}
func TestValidateSecretGrants(t *testing.T) {
if err := ValidateSecretGrants([]SecretGrant{{Application: "orders-api", Consumer: "runtime", Environment: "production", Secrets: []string{"database"}, Shared: []string{"rabbitmq"}}}); err != nil {
t.Fatal(err)
}
for _, grant := range []SecretGrant{
{Application: "orders-api", Consumer: "runtime", Environment: "preview"},
{Application: "orders-api", Consumer: "build", Environment: "staging"},
{Application: "orders-api", Consumer: "publish"},
{Application: "orders-api", Consumer: "publish", Shared: []string{"../platform"}},
{Application: "orders-api", Consumer: "publish", Secrets: []string{"database", "database"}},
} {
if err := ValidateSecretGrants([]SecretGrant{grant}); err == nil {
t.Fatalf("invalid secret grant accepted: %#v", grant)
}
}
}
func TestValidateRequiresDeliveryRepositoryOnForgejoOrigin(t *testing.T) {
cfg := validConfig(t)
cfg.Delivery.AppRepoURL = "https://attacker.example.test/test-org/web-ui.git"
@ -213,32 +143,6 @@ func TestResolveDefaultsWebhookEndpoint(t *testing.T) {
}
}
func TestResolveFreshBootstrapRequiresAnIsolatedExplicitWorkspace(t *testing.T) {
cfg := validConfig(t)
cfg.Git.Owner = ""
cfg.Git.CloneParent = filepath.Join(cfg.WorkspaceDir, "checkouts")
cfg.Flux.TektonCatalogRepo = "catalog"
cfg.Templates.TektonCatalogRepoURL = "https://git.example.test/templates/catalog.git"
cfg.Templates.TektonCatalogRepoRef = "main"
resolved, err := ResolveFreshBootstrap(cfg, "new-org", false)
if err != nil || resolved.Git.Owner != "new-org" {
t.Fatalf("ResolveFreshBootstrap() = (%#v, %v)", resolved.Git.Owner, err)
}
cfg.Git.CloneParent = cfg.WorkspaceDir
if _, err := ResolveFreshBootstrap(cfg, "new-org", false); err == nil || !strings.Contains(err.Error(), "cloneParent") {
t.Fatalf("ResolveFreshBootstrap() error = %v, want isolated clone parent error", err)
}
}
func TestResolveFreshBootstrapRejectsConflictingOwner(t *testing.T) {
cfg := validConfig(t)
cfg.Git.CloneParent = filepath.Join(cfg.WorkspaceDir, "checkouts")
if _, err := ResolveFreshBootstrap(cfg, "other-org", false); err == nil || !strings.Contains(err.Error(), "owner") {
t.Fatalf("ResolveFreshBootstrap() error = %v, want owner mismatch", err)
}
}
func TestLoadRejectsUnknownFields(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte("workspaceDir: test\nunknown: value\n"), 0600); err != nil {

View file

@ -12,20 +12,9 @@ type Config struct {
Cilium CiliumConfig `yaml:"cilium"`
DemocraticCSI DemocraticCSIConfig `yaml:"democraticCsi"`
Delivery DeliveryConfig `yaml:"delivery"`
SecretGrants []SecretGrant `yaml:"secretGrants,omitempty"`
SOPS SOPSConfig `yaml:"sops"`
}
// SecretGrant gives one application consumer access to named application and
// shared OpenBao paths. It contains references, never values.
type SecretGrant struct {
Application string `yaml:"application"`
Consumer string `yaml:"consumer"`
Environment string `yaml:"environment,omitempty"`
Secrets []string `yaml:"secrets"`
Shared []string `yaml:"shared,omitempty"`
}
type DemocraticCSIConfig struct {
TrueNASAPIKey string `yaml:"truenasApiKey"`
TrueNASHost string `yaml:"truenasHost"`
@ -45,10 +34,8 @@ type DeliveryConfig struct {
AppRepoRef string `yaml:"appRepoRef"`
ProductionBranch string `yaml:"productionBranch"`
ImageRepository string `yaml:"imageRepository"`
BuildStrategy string `yaml:"buildStrategy"`
BuildOutputDirectory string `yaml:"buildOutputDirectory"`
BuildConfiguration string `yaml:"buildConfiguration"`
TektonDashboardURL string `yaml:"tektonDashboardUrl,omitempty"`
WebhookHostname string `yaml:"webhookHostname"`
WebhookPath string `yaml:"webhookPath"`
}

View file

@ -1,360 +0,0 @@
// 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 }}}
}

View file

@ -1,137 +0,0 @@
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

@ -1,232 +0,0 @@
// 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

@ -1,138 +0,0 @@
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

@ -24,7 +24,7 @@ type RepoManager struct {
FluxRepoName string
Branch string
MigrationBranch string
MigrationRepositories []string
MigrationPending bool
HTTPClient *http.Client
}
@ -36,10 +36,6 @@ type createRepoRequest struct {
DefaultBranch string `json:"default_branch"`
}
type createOrganizationRequest struct {
Username string `json:"username"`
}
type pullRequestRequest struct {
Title string `json:"title"`
Head string `json:"head"`
@ -57,7 +53,6 @@ type mergePullRequestRequest struct {
type hook struct {
ID int64 `json:"id"`
URL string `json:"url"`
Config map[string]string `json:"config"`
}
type hookRequest struct {
@ -97,7 +92,6 @@ type accessToken struct {
}
var copyGit = runGit
var hasRemoteBranch = (*RepoManager).HasRemoteBranch
func (e *APIError) Error() string {
return fmt.Sprintf("forgejo returned %s", e.Status)
@ -123,24 +117,10 @@ func CreateRegistryToken(baseURL, username, password, otp, name string) (string,
}
func createRegistryToken(client *http.Client, baseURL, username, password, otp, name string) (string, error) {
return createToken(client, baseURL, username, password, otp, name, []string{"read:package", "write:package"})
}
// CreateDeliveryStatusToken creates the dedicated token used only to publish
// commit statuses and pull-request comments.
func CreateDeliveryStatusToken(baseURL, username, password, otp string) (string, error) {
return createDeliveryStatusToken(&http.Client{Timeout: 15 * time.Second}, baseURL, username, password, otp)
}
func createDeliveryStatusToken(client *http.Client, baseURL, username, password, otp string) (string, error) {
return createToken(client, baseURL, username, password, otp, "maidn-delivery-status", []string{"write:issue", "write:repository"})
}
func createToken(client *http.Client, baseURL, username, password, otp, name string, scopes []string) (string, error) {
if strings.TrimSpace(baseURL) == "" || username == "" || password == "" || strings.TrimSpace(name) == "" {
return "", fmt.Errorf("Forgejo base URL, username, password, and token name are required")
}
body, err := json.Marshal(createTokenRequest{Name: name, Scopes: scopes})
body, err := json.Marshal(createTokenRequest{Name: name, Scopes: []string{"read:package", "write:package"}})
if err != nil {
return "", err
}
@ -164,10 +144,10 @@ func createToken(client *http.Client, baseURL, username, password, otp, name str
}
var token accessToken
if err := json.NewDecoder(response.Body).Decode(&token); err != nil {
return "", fmt.Errorf("parse Forgejo token response: %w", err)
return "", fmt.Errorf("parse Forgejo registry token response: %w", err)
}
if token.SHA1 == "" {
return "", fmt.Errorf("Forgejo did not return a token")
return "", fmt.Errorf("Forgejo did not return a registry token")
}
return token.SHA1, nil
}
@ -179,53 +159,6 @@ func (rm *RepoManager) InitializeAll(createRepo func(string) error, createFlux f
return rm.ensureRepo(rm.FluxRepoName, "Flux CD cluster configurations", createFlux)
}
// EnsureOrganization creates the configured owner only when explicitly allowed.
func (rm *RepoManager) EnsureOrganization(create bool) (bool, error) {
exists, err := rm.organizationExists()
if err != nil {
return false, err
}
if exists {
return false, nil
}
if !create {
return false, errors.New("Forgejo organization does not exist; rerun with --create-organization")
}
body, err := json.Marshal(createOrganizationRequest{Username: rm.Owner})
if err != nil {
return false, err
}
status, err := rm.apiRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/orgs", rm.BaseURL), body)
if err != nil {
return false, err
}
if status == http.StatusCreated {
return true, nil
}
if status == http.StatusConflict {
exists, err = rm.organizationExists()
if err == nil && exists {
return false, nil
}
}
return false, fmt.Errorf("unexpected Forgejo organization create status %d", status)
}
func (rm *RepoManager) organizationExists() (bool, error) {
status, err := rm.apiRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/orgs/%s", rm.BaseURL, url.PathEscape(rm.Owner)), nil)
if err != nil {
return false, err
}
switch status {
case http.StatusOK:
return true, nil
case http.StatusNotFound:
return false, nil
default:
return false, fmt.Errorf("unexpected Forgejo organization lookup status %d", status)
}
}
func (rm *RepoManager) ensureRepo(name, description string, createStructure func(string) error) error {
exists, err := rm.repoExists(name)
if err != nil {
@ -395,12 +328,7 @@ func (rm *RepoManager) setupRepository(repoURL, repoName string, existing bool,
if err != nil || !changed || !existing {
return err
}
for _, repository := range rm.MigrationRepositories {
if repository == repoName {
return rm.createMigrationPullRequest(repoName, targetBranch)
}
}
rm.MigrationRepositories = append(rm.MigrationRepositories, repoName)
rm.MigrationPending = true
return rm.createMigrationPullRequest(repoName, targetBranch)
}
@ -420,57 +348,12 @@ func (rm *RepoManager) CreatePullRequest(repo, title, head, base string) error {
if err != nil {
return err
}
if status != http.StatusCreated && status != http.StatusUnprocessableEntity && status != http.StatusConflict {
if status != http.StatusCreated && status != http.StatusUnprocessableEntity {
return fmt.Errorf("unexpected Forgejo pull request status %d", status)
}
return nil
}
// EnsurePullRequest creates one pull request or returns the one already open
// for the exact head branch. It refuses duplicate or otherwise ambiguous state.
func (rm *RepoManager) EnsurePullRequest(repo, title, head, base string) error {
if head == "" || base == "" || head == base {
return fmt.Errorf("Forgejo pull request head and base must be different non-empty branches")
}
open, err := rm.HasOpenPullRequest(repo, head)
if err != nil {
return err
}
if open {
return nil
}
body, err := json.Marshal(pullRequestRequest{Title: title, Head: head, Base: base})
if err != nil {
return err
}
status, err := rm.apiRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls", rm.BaseURL, rm.Owner, repo), body)
if err != nil {
return err
}
if status != http.StatusCreated {
return fmt.Errorf("unexpected Forgejo pull request status %d", status)
}
return nil
}
// HasOpenPullRequest reports whether exactly one pull request is open for head.
func (rm *RepoManager) HasOpenPullRequest(repo, head string) (bool, error) {
values := url.Values{"state": {"open"}, "head": {head}}
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls?%s", rm.BaseURL, rm.Owner, repo, values.Encode())
var pullRequests []pullRequest
status, err := rm.apiJSONRequest(http.MethodGet, endpoint, nil, &pullRequests)
if err != nil {
return false, err
}
if status != http.StatusOK {
return false, fmt.Errorf("unexpected Forgejo pull request lookup status %d", status)
}
if len(pullRequests) > 1 {
return false, fmt.Errorf("multiple open Forgejo pull requests exist for branch %q", head)
}
return len(pullRequests) == 1, nil
}
func (rm *RepoManager) MergePullRequest(repo, head string) error {
values := url.Values{"state": {"open"}, "head": {head}}
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls?%s", rm.BaseURL, rm.Owner, repo, values.Encode())
@ -509,72 +392,71 @@ func (rm *RepoManager) PushRef(dir, repoURL, sourceRef, targetBranch string) err
return runGit(dir, environment, "push", repoURL, sourceRef+":refs/heads/"+targetBranch)
}
// PublishRepositoryPullRequest applies a managed change on a dedicated branch.
// An existing branch is accepted only when it has exactly one open pull request.
func (rm *RepoManager) PublishRepositoryPullRequest(repo, title, branch, base string, change func(string) error) (bool, error) {
if repo == "" || branch == "" || base == "" || branch == base {
return false, errors.New("repository pull request requires distinct non-empty branches")
// 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")
}
repoURL := CloneURL(rm.BaseURL, rm.Owner, repo)
hasBranch, err := hasRemoteBranch(rm, repoURL, branch)
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) error {
if sourceDir == "" || sourceBranch == "" || deliveryBranch == "" || deliveryBranch == rm.Branch {
return fmt.Errorf("delivery source branch and dedicated delivery branch are required and must differ from the base branch")
}
temporary, err := os.MkdirTemp("", "maidn-delivery-*")
if err != nil {
return false, err
}
if hasBranch {
open, err := rm.HasOpenPullRequest(repo, branch)
if err != nil {
return false, err
}
if !open {
return false, fmt.Errorf("Forgejo branch %q exists without exactly one open pull request; refusing to reuse it", branch)
}
}
temporary, err := os.MkdirTemp("", "maidn-registration-*")
if err != nil {
return false, err
return err
}
defer os.RemoveAll(temporary)
if err := runGit("", os.Environ(), "clone", "--no-local", "--branch", sourceBranch, sourceDir, temporary); err != nil {
return err
}
if err := generate(temporary); err != nil {
return err
}
cleanupAskPass, environment, err := rm.gitEnvironment()
if err != nil {
return false, err
return err
}
defer cleanupAskPass()
checkout := base
if err := runGit(temporary, environment, "checkout", "-B", deliveryBranch); err != nil {
return err
}
if err := runGit(temporary, environment, "add", ".tekton"); err != nil {
return err
}
changed, err := gitDiffQuiet(temporary, environment, "--cached")
if err != nil {
return 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 err
}
}
}
hasBranch, err := rm.HasRemoteBranch(repoURL, deliveryBranch)
if err != nil {
return err
}
if hasBranch {
checkout = branch
if err := runGit(temporary, environment, "fetch", repoURL, "refs/heads/"+deliveryBranch); err != nil {
return err
}
if err := runGit("", environment, "clone", "--branch", checkout, repoURL, temporary); err != nil {
return false, err
}
if !hasBranch {
if err := runGit(temporary, environment, "checkout", "-B", branch, "origin/"+base); err != nil {
return false, err
}
}
if err := change(temporary); err != nil {
return false, err
}
changed, err := commitAndPush(temporary, repo, branch, environment)
different, err := gitDiffQuiet(temporary, environment, "HEAD", "FETCH_HEAD")
if err != nil {
return false, err
return err
}
if !changed {
if !hasBranch {
return false, nil
if !different {
return nil
}
open, err := rm.HasOpenPullRequest(repo, branch)
if err != nil {
return false, err
return fmt.Errorf("dedicated delivery branch %q differs from generated content; refusing to overwrite it", deliveryBranch)
}
if !open {
return false, nil
}
return true, nil
}
if err := rm.EnsurePullRequest(repo, title, branch, base); err != nil {
return false, err
}
return true, nil
return rm.PushBranch(temporary, repoURL, deliveryBranch)
}
func gitDiffQuiet(dir string, environment []string, args ...string) (bool, error) {
@ -607,43 +489,6 @@ func (rm *RepoManager) HasRemoteBranch(repoURL, branch string) (bool, error) {
return true, nil
}
// BranchRevision resolves branch to the checked-out commit that may be published.
func BranchRevision(dir, branch string) (string, error) {
command := exec.Command("git", "rev-parse", "--verify", branch+"^{commit}")
command.Dir = dir
revision, err := command.Output()
if err != nil {
return "", fmt.Errorf("resolve source branch %q: %w", branch, err)
}
if revision = bytes.TrimSpace(revision); len(revision) == 0 {
return "", fmt.Errorf("source branch %q has no commit", branch)
}
return string(revision), nil
}
// RemoteBranchRevision returns the remote branch commit, or an empty string when absent.
func (rm *RepoManager) RemoteBranchRevision(repoURL, branch string) (string, error) {
cleanupAskPass, environment, err := rm.gitEnvironment()
if err != nil {
return "", err
}
defer cleanupAskPass()
command := exec.Command("git", "ls-remote", "--refs", repoURL, "refs/heads/"+branch)
command.Env = environment
output, err := command.Output()
if err != nil {
return "", err
}
fields := strings.Fields(string(output))
if len(fields) == 0 {
return "", nil
}
if len(fields) != 2 || fields[1] != "refs/heads/"+branch {
return "", fmt.Errorf("unexpected remote ref response for branch %q", branch)
}
return fields[0], nil
}
func CurrentBranch(dir string) (string, error) {
command := exec.Command("git", "branch", "--show-current")
command.Dir = dir
@ -719,7 +564,7 @@ func (rm *RepoManager) EnsureWebhook(repo, webhookURL, authorization string) err
Events: []string{"push", "pull_request"},
}
for _, existing := range hooks {
if hookURL(existing) != webhookURL {
if existing.URL != webhookURL {
continue
}
request, err := json.Marshal(hookRequest{Active: createRequest.Active, AuthorizationHeader: createRequest.AuthorizationHeader, Config: createRequest.Config, Events: createRequest.Events})
@ -749,51 +594,10 @@ func (rm *RepoManager) EnsureWebhook(repo, webhookURL, authorization string) err
return nil
}
func hookURL(existing hook) string {
if existing.URL != "" {
return existing.URL
}
return existing.Config["url"]
}
// TriggerWebhookTest asks Forgejo to deliver a test push for the managed hook.
func (rm *RepoManager) TriggerWebhookTest(repo, webhookURL, branch string) error {
if repo == "" || webhookURL == "" || branch == "" {
return errors.New("Forgejo repository, webhook URL, and branch are required")
}
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/hooks", rm.BaseURL, rm.Owner, repo)
var hooks []hook
status, err := rm.apiJSONRequest(http.MethodGet, endpoint, nil, &hooks)
if err != nil {
return err
}
if status != http.StatusOK {
return fmt.Errorf("unexpected Forgejo webhook lookup status %d", status)
}
var matching []hook
for _, candidate := range hooks {
if hookURL(candidate) == webhookURL {
matching = append(matching, candidate)
}
}
if len(matching) != 1 {
return fmt.Errorf("expected one Forgejo webhook for URL %q", webhookURL)
}
values := url.Values{"ref": {branch}}
status, err = rm.apiRequest(http.MethodPost, fmt.Sprintf("%s/%d/tests?%s", endpoint, matching[0].ID, values.Encode()), nil)
if err != nil {
return err
}
if status != http.StatusNoContent {
return fmt.Errorf("unexpected Forgejo webhook test status %d", status)
}
return nil
}
// EnsureProtectedBranch disables direct pushes to a managed branch.
// EnsureProtectedBranch disables direct pushes to the configured production branch.
func (rm *RepoManager) EnsureProtectedBranch(repo, branch string) error {
if repo == "" || branch == "" {
return errors.New("Forgejo repository and branch are required")
return errors.New("Forgejo repository and production branch are required")
}
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/branch_protections", rm.BaseURL, rm.Owner, repo)
var protections []branchProtection
@ -811,11 +615,11 @@ func (rm *RepoManager) EnsureProtectedBranch(repo, branch string) error {
}
}
if len(matching) > 1 {
return fmt.Errorf("multiple Forgejo branch protections match branch %q", branch)
return fmt.Errorf("multiple Forgejo branch protections match production branch %q", branch)
}
if len(matching) == 1 {
if matching[0].EnablePush || matching[0].EnablePushWhitelist {
return fmt.Errorf("Forgejo branch %q permits direct pushes", branch)
return fmt.Errorf("Forgejo production branch %q permits direct pushes", branch)
}
return nil
}

View file

@ -41,34 +41,6 @@ func TestCreateRegistryTokenUsesBasicAuthAndPackageScopes(t *testing.T) {
}
}
func TestCreateDeliveryStatusTokenUsesOnlyStatusAndCommentScopes(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost || request.URL.Path != "/api/v1/users/delivery-bot/tokens" {
t.Fatal("unexpected Forgejo delivery-token request")
}
username, password, ok := request.BasicAuth()
if !ok || username != "delivery-bot" || password != "password" || request.Header.Get("X-Forgejo-OTP") != "123456" {
t.Fatal("delivery token request did not use the supplied credentials through HTTP authentication")
}
var body createTokenRequest
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.Name != "maidn-delivery-status" || !reflect.DeepEqual(body.Scopes, []string{"write:issue", "write:repository"}) {
t.Fatalf("delivery token request has unexpected privileges: %#v", body)
}
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(http.StatusCreated)
_, _ = writer.Write([]byte(`{"sha1":"delivery-token"}`))
}))
defer server.Close()
token, err := createDeliveryStatusToken(server.Client(), server.URL, "delivery-bot", "password", "123456")
if err != nil || token != "delivery-token" {
t.Fatal("CreateDeliveryStatusToken() did not return the Forgejo token")
}
}
func TestEnsureRepositoryCopyUsesAskPassAndCredentialFreeGitArguments(t *testing.T) {
original := copyGit
t.Cleanup(func() { copyGit = original })
@ -169,40 +141,6 @@ func TestRepoExistsOnlyCreatesOnNotFound(t *testing.T) {
}
}
func TestEnsureOrganizationCreatesOnlyWhenRequested(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requests++
switch request.Method {
case http.MethodGet:
if request.URL.Path != "/api/v1/orgs/new-org" {
t.Fatalf("unexpected organization lookup %q", request.URL.Path)
}
writer.WriteHeader(http.StatusNotFound)
case http.MethodPost:
var body createOrganizationRequest
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.Username != "new-org" {
t.Fatalf("unexpected organization create request: %#v, %v", body, err)
}
writer.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected Forgejo method %q", request.Method)
}
}))
defer server.Close()
manager := NewRepoManager(server.URL, "token", "new-org", "user", "", "", "main", "")
manager.HTTPClient = server.Client()
if _, err := manager.EnsureOrganization(false); err == nil {
t.Fatal("missing organization was accepted without explicit create")
}
if requests != 1 {
t.Fatal("organization lookup performed unexpected remote actions")
}
if created, err := manager.EnsureOrganization(true); err != nil || !created {
t.Fatalf("EnsureOrganization(true) = (%t, %v)", created, err)
}
}
func TestEnsureWebhookUpdatesMatchingURL(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
@ -212,7 +150,7 @@ func TestEnsureWebhookUpdatesMatchingURL(t *testing.T) {
if request.URL.Path != "/api/v1/repos/owner/app/hooks" {
t.Fatalf("unexpected lookup path %q", request.URL.Path)
}
_ = json.NewEncoder(writer).Encode([]hook{{ID: 7, Config: map[string]string{"url": "https://tekton.example.test/"}}})
_ = json.NewEncoder(writer).Encode([]hook{{ID: 7, URL: "https://tekton.example.test/"}})
case http.MethodPatch:
if request.URL.Path != "/api/v1/repos/owner/app/hooks/7" {
t.Fatalf("unexpected update path %q", request.URL.Path)
@ -266,28 +204,6 @@ func TestEnsureWebhookCreatesMissingWebhook(t *testing.T) {
}
}
func TestTriggerWebhookTestUsesManagedHookAndBaseBranch(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.Method + " " + request.URL.Path {
case http.MethodGet + " /api/v1/repos/owner/app/hooks":
_ = json.NewEncoder(writer).Encode([]hook{{ID: 7, URL: "https://tekton.example.test/"}})
case http.MethodPost + " /api/v1/repos/owner/app/hooks/7/tests":
if request.URL.Query().Get("ref") != "main" || request.Header.Get("Authorization") == "" {
t.Fatal("webhook test did not use the managed hook and base branch")
}
writer.WriteHeader(http.StatusNoContent)
default:
t.Fatalf("unexpected Forgejo request %s %s", request.Method, request.URL.String())
}
}))
defer server.Close()
manager := NewRepoManager(server.URL, "test-token", "owner", "user", "", "", "main", "")
manager.HTTPClient = server.Client()
if err := manager.TriggerWebhookTest("app", "https://tekton.example.test/", "main"); err != nil {
t.Fatal(err)
}
}
func TestEnsureProtectedBranchCreatesDirectPushProtection(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/api/v1/repos/owner/app/branch_protections" {
@ -317,28 +233,6 @@ 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) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodGet {
@ -400,34 +294,6 @@ func TestHasRemoteBranchReturnsFalseForMissingBranch(t *testing.T) {
}
}
func TestPublishRepositoryPullRequestRejectsExistingBranchWithoutOpenPullRequest(t *testing.T) {
original := hasRemoteBranch
t.Cleanup(func() { hasRemoteBranch = original })
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodGet || request.URL.Path != "/api/v1/repos/owner/cluster/pulls" || request.URL.Query().Get("state") != "open" || request.URL.Query().Get("head") != "maidn/register-web-ui-deadbeefcafe" {
t.Fatalf("unexpected pull request lookup: %s %s", request.Method, request.URL.String())
}
_ = json.NewEncoder(writer).Encode([]pullRequest{})
}))
defer server.Close()
manager := NewRepoManager(server.URL, "test-token", "owner", "user", "", "", "main", "")
manager.HTTPClient = server.Client()
hasRemoteBranch = func(got *RepoManager, repoURL, branch string) (bool, error) {
if got != manager || repoURL != CloneURL(server.URL, "owner", "cluster") || branch != "maidn/register-web-ui-deadbeefcafe" {
t.Fatalf("unexpected remote branch lookup: %q %q", repoURL, branch)
}
return true, nil
}
_, err := manager.PublishRepositoryPullRequest("cluster", "register web-ui", "maidn/register-web-ui-deadbeefcafe", "main", func(string) error {
t.Fatal("change ran for a stale registration branch")
return nil
})
if err == nil || !strings.Contains(err.Error(), "without exactly one open pull request") {
t.Fatalf("PublishRepositoryPullRequest() error = %v", err)
}
}
func TestMergePullRequest(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.Method {
@ -454,49 +320,6 @@ func TestMergePullRequest(t *testing.T) {
}
}
func TestCreatePullRequestAcceptsExistingConflict(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost || request.URL.Path != "/api/v1/repos/owner/cluster/pulls" {
t.Fatalf("unexpected pull request request: %s %s", request.Method, request.URL.Path)
}
writer.WriteHeader(http.StatusConflict)
}))
defer server.Close()
manager := NewRepoManager(server.URL, "token", "owner", "user", "manifests", "cluster", "main", "maidn/bootstrap-test")
manager.HTTPClient = server.Client()
if err := manager.CreatePullRequest("cluster", "title", "maidn/bootstrap-test", "main"); err != nil {
t.Fatal(err)
}
}
func TestEnsurePullRequestChecksExactOpenBranchBeforeCreating(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requests++
switch request.Method {
case http.MethodGet:
if request.URL.Path != "/api/v1/repos/owner/app/pulls" || request.URL.Query().Get("state") != "open" || request.URL.Query().Get("head") != "maidn/delivery-app" {
t.Fatalf("unexpected pull request lookup: %s", request.URL.String())
}
_, _ = writer.Write([]byte("[]"))
case http.MethodPost:
var body pullRequestRequest
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.Head != "maidn/delivery-app" || body.Base != "main" {
t.Fatalf("unexpected pull request create: %#v, %v", body, err)
}
writer.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected request method %s", request.Method)
}
}))
defer server.Close()
manager := NewRepoManager(server.URL, "test-token", "owner", "bot", "", "", "main", "")
manager.HTTPClient = server.Client()
if err := manager.EnsurePullRequest("app", "delivery", "maidn/delivery-app", "main"); err != nil || requests != 2 {
t.Fatalf("EnsurePullRequest() = %v, requests = %d", err, requests)
}
}
func TestRepoExistsReturnsFalseOnNotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusNotFound)

View file

@ -1,138 +0,0 @@
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

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

View file

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

View file

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

View file

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