diff --git a/README.md b/README.md index fd7a4b4..9ad4abf 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ dlv version - `cicd-tool repo init --org --flux-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 -- `maidn bootstrap init --config --organization --create-organization --enable-delivery` locks an isolated workspace and scaffolds the Forgejo organization repositories; it does not run infrastructure or cluster actions. +- `maidn bootstrap init --config --organization --create-organization --enable-delivery` locks an isolated workspace, initializes the Forgejo repositories, then runs the non-destructive bootstrap reconcile lifecycle; use `--mode=rebuild --yes` for an authorized rebuild. Delivery scaffolding requires `--enable-delivery`. - `maidn app onboard --config --from ` validates a clean configured checkout and adds its `.tekton` delivery contract. See `docs/operations.md` for the authorized operating and verification runbook. diff --git a/cmd/fresh.go b/cmd/fresh.go index 80bc083..c2c6120 100644 --- a/cmd/fresh.go +++ b/cmd/fresh.go @@ -10,7 +10,8 @@ import ( ) var freshConfigPath, freshOrganization, onboardConfigPath, onboardFrom string -var freshCreateOrganization, freshEnableDelivery bool +var freshCreateOrganization, freshEnableDelivery, freshYes bool +var freshMode string var loadFreshConfig = config.LoadRaw var runFreshOrganization = bootstrap.RunFreshOrganization @@ -43,6 +44,8 @@ func init() { 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, "Initialize delivery repository prerequisites") + 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") @@ -59,7 +62,7 @@ func runBootstrapInit(cmd *cobra.Command, _ []string) error { if err != nil { return err } - plan, err := runFreshOrganization(cfg, bootstrap.FreshOrganizationOptions{Organization: freshOrganization, CreateOrganization: freshCreateOrganization, EnableDelivery: freshEnableDelivery}) + plan, err := runFreshOrganization(cfg, bootstrap.FreshOrganizationOptions{Organization: freshOrganization, CreateOrganization: freshCreateOrganization, EnableDelivery: freshEnableDelivery, Mode: bootstrap.Mode(freshMode), ConfirmRebuild: freshYes}) if err != nil { return err } diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 5622db5..5c4b8c5 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -40,11 +40,12 @@ const ( ) type Runner struct { - Config config.Config - Mode Mode - ConfirmRebuild bool - RegisterWebhook bool - EnableDelivery bool + Config config.Config + Mode Mode + ConfirmRebuild bool + RegisterWebhook bool + EnableDelivery bool + SkipDeliveryScaffolding bool } type operationalSecrets struct { @@ -139,6 +140,9 @@ 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 @@ -166,14 +170,8 @@ 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.Mode, err = resolveLifecycleMode(r.Mode, r.ConfirmRebuild); err != nil { + return err } if r.RegisterWebhook { return r.reconcileWebhook(filepath.Join(r.Config.Git.CloneParent, r.Config.Talos.RepoDirName, r.Config.Talos.GeneratedDir)) @@ -191,9 +189,11 @@ func (r Runner) Run() error { 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) + if !r.SkipDeliveryScaffolding { + 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) + } } if err := config.WriteRedacted(filepath.Join(workspace, "maidn-bootstrap.resolved.yaml"), r.Config); err != nil { return err @@ -331,6 +331,19 @@ func (r Runner) Run() error { 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 { diff --git a/internal/bootstrap/fresh.go b/internal/bootstrap/fresh.go index 6d985e0..0b0e21e 100644 --- a/internal/bootstrap/fresh.go +++ b/internal/bootstrap/fresh.go @@ -14,15 +14,25 @@ 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 @@ -42,39 +52,47 @@ func PlanFreshOrganization(cfg config.Config, options FreshOrganizationOptions) if options.EnableDelivery { 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 executes only the reversible source-control setup plan. -// Infrastructure, credentials, secret material, and cluster actions remain gated. +// 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 := EnsureTemplateRevisions(resolved); err != nil { + if err := ensureFreshTemplateRevisions(resolved); err != nil { return plan, fmt.Errorf("lock template revisions: %w", err) } - manager := forgejo.NewRepoManager(resolved.Git.BaseURL, resolved.Git.Token, resolved.Git.Owner, resolved.Git.Username, "", "", resolved.Flux.Branch, "") + 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) } - for _, repository := range []struct{ name, description string }{ - {resolved.Flux.ManifestsRepo, "Centralized deployment manifests for Flux CD"}, - {resolved.Flux.RepoName, "Flux CD cluster configurations"}, - } { - if _, err := manager.EnsureInitializedRepository(repository.name, repository.description); err != nil { - return plan, fmt.Errorf("ensure Forgejo repository %q: %w", repository.name, err) - } - } - if options.EnableDelivery { - if _, err := manager.EnsureRepositoryCopy(resolved.Flux.TektonCatalogRepo, "User-managed Tekton pipeline catalog", resolved.Templates.TektonCatalogRepoURL); err != nil { - return plan, fmt.Errorf("initialize Tekton catalog repository: %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, + EnableDelivery: options.EnableDelivery, + SkipDeliveryScaffolding: !options.EnableDelivery, + } +} + func validateFreshWorkspace(cfg config.Config) error { cloneRelative, err := filepath.Rel(cfg.WorkspaceDir, cfg.Git.CloneParent) if err != nil || filepath.Dir(cloneRelative) != "." { diff --git a/internal/bootstrap/fresh_test.go b/internal/bootstrap/fresh_test.go index 2c2eb13..1afe33b 100644 --- a/internal/bootstrap/fresh_test.go +++ b/internal/bootstrap/fresh_test.go @@ -1,12 +1,17 @@ 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 { @@ -42,12 +47,158 @@ func TestPlanFreshOrganizationOrdersOnlyFreshPhases(t *testing.T) { "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 TestFreshLifecycleUsesSelectedModeAndGatesDelivery(t *testing.T) { + originalPreflight := preflight + t.Cleanup(func() { preflight = originalPreflight }) + cfg := runnerTestConfig(t.TempDir(), "") + 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 != test.options.EnableDelivery || runner.SkipDeliveryScaffolding == test.options.EnableDelivery { + t.Fatalf("fresh lifecycle runner = %#v", runner) + } + preflight = func(got config.Config) error { + if got.Delivery.Configured() != test.options.EnableDelivery { + t.Fatal("fresh lifecycle did not gate delivery scaffolding with --enable-delivery") + } + return errors.New("stop") + } + if err := runner.Run(); err == nil || !strings.Contains(err.Error(), "preflight: stop") { + t.Fatalf("fresh lifecycle run = %v", err) + } + } +} + func TestPlanFreshOrganizationRejectsUnrecognizedWorkspaceState(t *testing.T) { cfg := freshPlanConfig(t) if err := os.WriteFile(filepath.Join(cfg.WorkspaceDir, "leftover"), []byte("state"), 0600); err != nil {