From 651c2938d59f97f9b0adf889ead37182db41750e Mon Sep 17 00:00:00 2001 From: eding Date: Sat, 5 Sep 2026 20:28:29 +0200 Subject: [PATCH 1/2] feat: scaffold fresh organization bootstrap --- README.md | 2 + cmd/fresh.go | 99 ++++++++++++++++++++++++++++ cmd/fresh_test.go | 63 ++++++++++++++++++ internal/bootstrap/fresh.go | 109 +++++++++++++++++++++++++++++++ internal/bootstrap/fresh_test.go | 59 +++++++++++++++++ internal/config/config.go | 88 +++++++++++++++++++++++++ internal/config/config_test.go | 26 ++++++++ internal/forgejo/repo.go | 51 +++++++++++++++ internal/forgejo/repo_test.go | 34 ++++++++++ 9 files changed, 531 insertions(+) create mode 100644 cmd/fresh.go create mode 100644 cmd/fresh_test.go create mode 100644 internal/bootstrap/fresh.go create mode 100644 internal/bootstrap/fresh_test.go diff --git a/README.md b/README.md index 63cc3bd..fd7a4b4 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ 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 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 new file mode 100644 index 0000000..80bc083 --- /dev/null +++ b/cmd/fresh.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "fmt" + + "github.com/Pingu-Studio/MaidnCLI/internal/bootstrap" + "github.com/Pingu-Studio/MaidnCLI/internal/config" + "github.com/Pingu-Studio/MaidnCLI/internal/forgejo" + "github.com/spf13/cobra" +) + +var freshConfigPath, freshOrganization, onboardConfigPath, onboardFrom string +var freshCreateOrganization, freshEnableDelivery bool + +var loadFreshConfig = config.LoadRaw +var runFreshOrganization = bootstrap.RunFreshOrganization +var resolveAppOnboarding = config.ResolveAppOnboarding +var ensureOnboardCheckoutClean = forgejo.EnsureCleanCheckout +var onboardCheckoutOrigin = forgejo.CheckoutOrigin +var onboardCheckoutBranch = forgejo.CurrentBranch +var generateAppDelivery = bootstrap.GenerateAppDelivery + +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 source-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, "Initialize delivery repository prerequisites") + _ = 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 scaffold") + _ = 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}) + 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 := loadFreshConfig(onboardConfigPath) + if err != nil { + return err + } + cfg, err = resolveAppOnboarding(cfg) + if err != nil { + return err + } + if err := ensureOnboardCheckoutClean(onboardFrom); err != nil { + return err + } + origin, err := onboardCheckoutOrigin(onboardFrom) + if err != nil { + return err + } + if config.RedactURL(origin) != cfg.Delivery.AppRepoURL { + return fmt.Errorf("--from origin does not match delivery appRepoUrl") + } + branch, err := onboardCheckoutBranch(onboardFrom) + if err != nil { + return err + } + if branch != cfg.Delivery.AppRepoRef { + return fmt.Errorf("--from branch must match delivery appRepoRef") + } + return generateAppDelivery(onboardFrom, cfg) +} diff --git a/cmd/fresh_test.go b/cmd/fresh_test.go new file mode 100644 index 0000000..9cf53da --- /dev/null +++ b/cmd/fresh_test.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "errors" + "testing" + + "github.com/Pingu-Studio/MaidnCLI/internal/config" +) + +func TestAppOnboardValidatesConfigBeforeInspectingCheckout(t *testing.T) { + originalConfig, originalResolve, originalClean := loadFreshConfig, resolveAppOnboarding, ensureOnboardCheckoutClean + originalConfigPath, originalFrom := onboardConfigPath, onboardFrom + t.Cleanup(func() { + loadFreshConfig = originalConfig + resolveAppOnboarding = originalResolve + ensureOnboardCheckoutClean = originalClean + onboardConfigPath, onboardFrom = originalConfigPath, originalFrom + }) + loadFreshConfig = func(string) (config.Config, error) { return config.Config{}, nil } + resolveAppOnboarding = func(config.Config) (config.Config, error) { return config.Config{}, errors.New("incomplete delivery") } + ensureOnboardCheckoutClean = func(string) error { + t.Fatal("onboarding inspected checkout before validating config") + return nil + } + onboardConfigPath, onboardFrom = "private.yaml", "app-checkout" + if err := runAppOnboard(nil, nil); err == nil { + t.Fatal("onboarding accepted invalid configuration") + } +} + +func TestAppOnboardScaffoldsOnlyTheValidatedCheckout(t *testing.T) { + originalConfig, originalResolve, originalClean := loadFreshConfig, resolveAppOnboarding, ensureOnboardCheckoutClean + originalOrigin, originalBranch, originalGenerate := onboardCheckoutOrigin, onboardCheckoutBranch, generateAppDelivery + originalConfigPath, originalFrom := onboardConfigPath, onboardFrom + t.Cleanup(func() { + loadFreshConfig, resolveAppOnboarding, ensureOnboardCheckoutClean = originalConfig, originalResolve, originalClean + onboardCheckoutOrigin, onboardCheckoutBranch, generateAppDelivery = originalOrigin, originalBranch, originalGenerate + onboardConfigPath, onboardFrom = originalConfigPath, originalFrom + }) + cfg := config.Config{Delivery: config.DeliveryConfig{AppRepoURL: "https://git.example.test/new-org/app.git", AppRepoRef: "main"}} + loadFreshConfig = func(string) (config.Config, error) { return cfg, nil } + resolveAppOnboarding = func(config.Config) (config.Config, error) { return cfg, nil } + ensureOnboardCheckoutClean = func(path string) error { + if path != "app-checkout" { + t.Fatal("onboarding checked the wrong checkout") + } + return nil + } + onboardCheckoutOrigin = func(string) (string, error) { return cfg.Delivery.AppRepoURL, nil } + onboardCheckoutBranch = func(string) (string, error) { return cfg.Delivery.AppRepoRef, nil } + generations := 0 + generateAppDelivery = func(path string, got config.Config) error { + if path != "app-checkout" || got.Delivery.AppRepoURL != cfg.Delivery.AppRepoURL { + t.Fatal("onboarding generated delivery for the wrong checkout or config") + } + generations++ + return nil + } + onboardConfigPath, onboardFrom = "private.yaml", "app-checkout" + if err := runAppOnboard(nil, nil); err != nil || generations != 1 { + t.Fatalf("runAppOnboard() = %v, generations = %d", err, generations) + } +} diff --git a/internal/bootstrap/fresh.go b/internal/bootstrap/fresh.go new file mode 100644 index 0000000..6d985e0 --- /dev/null +++ b/internal/bootstrap/fresh.go @@ -0,0 +1,109 @@ +package bootstrap + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/Pingu-Studio/MaidnCLI/internal/config" + "github.com/Pingu-Studio/MaidnCLI/internal/forgejo" +) + +type FreshOrganizationOptions struct { + Organization string + CreateOrganization bool + EnableDelivery bool +} + +type FreshOrganizationPlan struct { + Phases []string +} + +// 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) { + 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", + } + if options.EnableDelivery { + phases = append(phases, "initialize the user-managed Tekton catalog repository") + } + return resolved, FreshOrganizationPlan{Phases: phases}, nil +} + +// RunFreshOrganization executes only the reversible source-control setup plan. +// Infrastructure, credentials, secret material, and cluster actions remain gated. +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 { + 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, "") + 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) + } + } + return plan, nil +} + +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") + } + entries, err := os.ReadDir(cfg.WorkspaceDir) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("inspect workspaceDir: %w", err) + } + if len(entries) == 0 { + return nil + } + lock, err := readTemplateRevisionLock(filepath.Join(cfg.WorkspaceDir, "maidn-template-revisions.yaml")) + 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") + } + allowed := map[string]bool{ + "maidn-template-revisions.yaml": true, + "maidn-cicd-cluster-template": true, + "cicd-deployment-manifests-template": true, + cloneRelative: true, + } + for _, entry := range entries { + if !allowed[entry.Name()] { + return errors.New("workspaceDir contains ambiguous state; use a new empty isolated workspaceDir") + } + } + return nil +} diff --git a/internal/bootstrap/fresh_test.go b/internal/bootstrap/fresh_test.go new file mode 100644 index 0000000..2c2eb13 --- /dev/null +++ b/internal/bootstrap/fresh_test.go @@ -0,0 +1,59 @@ +package bootstrap + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/Pingu-Studio/MaidnCLI/internal/config" +) + +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", + } + if !reflect.DeepEqual(plan.Phases, want) { + t.Fatalf("plan phases = %#v, want %#v", plan.Phases, want) + } +} + +func TestPlanFreshOrganizationRejectsUnrecognizedWorkspaceState(t *testing.T) { + cfg := freshPlanConfig(t) + if err := os.WriteFile(filepath.Join(cfg.WorkspaceDir, "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") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 8a98945..2c7a400 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -106,6 +106,94 @@ 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 + } + } + if enableDelivery { + var err error + cfg, err = ResolveDelivery(cfg) + if 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.Flux.ManifestsRepo == "" || cfg.Flux.Branch == "" { + return cfg, errors.New("git owner and flux manifestsRepo and branch are required for app onboarding") + } + if err := validateForgejoOrigin(cfg.Git.BaseURL); err != nil { + return cfg, err + } + return ResolveDelivery(cfg) +} + +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.ClusterID == "" { cfg.ClusterID = cfg.Talos.Cluster.Name diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6b89f06..626289b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -143,6 +143,32 @@ 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 { diff --git a/internal/forgejo/repo.go b/internal/forgejo/repo.go index f1b4d6d..c65eb9d 100644 --- a/internal/forgejo/repo.go +++ b/internal/forgejo/repo.go @@ -36,6 +36,10 @@ type createRepoRequest struct { DefaultBranch string `json:"default_branch"` } +type createOrganizationRequest struct { + Username string `json:"username"` +} + type pullRequestRequest struct { Title string `json:"title"` Head string `json:"head"` @@ -159,6 +163,53 @@ func (rm *RepoManager) InitializeAll(createRepo func(string) error, createFlux f return rm.ensureRepo(rm.FluxRepoName, "Flux CD cluster configurations", createFlux) } +// EnsureOrganization creates the configured owner only when explicitly allowed. +func (rm *RepoManager) EnsureOrganization(create bool) (bool, error) { + exists, err := rm.organizationExists() + if err != nil { + return false, err + } + if exists { + return false, nil + } + if !create { + return false, errors.New("Forgejo organization does not exist; rerun with --create-organization") + } + body, err := json.Marshal(createOrganizationRequest{Username: rm.Owner}) + if err != nil { + return false, err + } + status, err := rm.apiRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/orgs", rm.BaseURL), body) + if err != nil { + return false, err + } + if status == http.StatusCreated { + return true, nil + } + if status == http.StatusConflict { + exists, err = rm.organizationExists() + if err == nil && exists { + return false, nil + } + } + return false, fmt.Errorf("unexpected Forgejo organization create status %d", status) +} + +func (rm *RepoManager) organizationExists() (bool, error) { + status, err := rm.apiRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/orgs/%s", rm.BaseURL, url.PathEscape(rm.Owner)), nil) + if err != nil { + return false, err + } + switch status { + case http.StatusOK: + return true, nil + case http.StatusNotFound: + return false, nil + default: + return false, fmt.Errorf("unexpected Forgejo organization lookup status %d", status) + } +} + func (rm *RepoManager) ensureRepo(name, description string, createStructure func(string) error) error { exists, err := rm.repoExists(name) if err != nil { diff --git a/internal/forgejo/repo_test.go b/internal/forgejo/repo_test.go index 1092730..d81333b 100644 --- a/internal/forgejo/repo_test.go +++ b/internal/forgejo/repo_test.go @@ -141,6 +141,40 @@ func TestRepoExistsOnlyCreatesOnNotFound(t *testing.T) { } } +func TestEnsureOrganizationCreatesOnlyWhenRequested(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + requests++ + switch request.Method { + case http.MethodGet: + if request.URL.Path != "/api/v1/orgs/new-org" { + t.Fatalf("unexpected organization lookup %q", request.URL.Path) + } + writer.WriteHeader(http.StatusNotFound) + case http.MethodPost: + var body createOrganizationRequest + if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.Username != "new-org" { + t.Fatalf("unexpected organization create request: %#v, %v", body, err) + } + writer.WriteHeader(http.StatusCreated) + default: + t.Fatalf("unexpected Forgejo method %q", request.Method) + } + })) + defer server.Close() + manager := NewRepoManager(server.URL, "token", "new-org", "user", "", "", "main", "") + manager.HTTPClient = server.Client() + if _, err := manager.EnsureOrganization(false); err == nil { + t.Fatal("missing organization was accepted without explicit create") + } + if requests != 1 { + t.Fatal("organization lookup performed unexpected remote actions") + } + if created, err := manager.EnsureOrganization(true); err != nil || !created { + t.Fatalf("EnsureOrganization(true) = (%t, %v)", created, err) + } +} + func TestEnsureWebhookUpdatesMatchingURL(t *testing.T) { requests := 0 server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { From fe3ce965112d26fc14b74b40abb694edfead17e1 Mon Sep 17 00:00:00 2001 From: eding Date: Sat, 5 Sep 2026 20:53:07 +0200 Subject: [PATCH 2/2] fix: run fresh bootstrap lifecycle --- README.md | 2 +- cmd/fresh.go | 7 +- internal/bootstrap/bootstrap.go | 45 +++++---- internal/bootstrap/fresh.go | 50 ++++++---- internal/bootstrap/fresh_test.go | 151 +++++++++++++++++++++++++++++++ 5 files changed, 220 insertions(+), 35 deletions(-) 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 {