diff --git a/cmd/fresh.go b/cmd/fresh.go index c2c6120..0c04b84 100644 --- a/cmd/fresh.go +++ b/cmd/fresh.go @@ -13,7 +13,8 @@ var freshConfigPath, freshOrganization, onboardConfigPath, onboardFrom string var freshCreateOrganization, freshEnableDelivery, freshYes bool var freshMode string -var loadFreshConfig = config.LoadRaw +var loadFreshConfig = config.Load +var loadAppOnboardConfig = config.LoadRaw var runFreshOrganization = bootstrap.RunFreshOrganization var resolveAppOnboarding = config.ResolveAppOnboarding var ensureOnboardCheckoutClean = forgejo.EnsureCleanCheckout @@ -73,7 +74,7 @@ func runBootstrapInit(cmd *cobra.Command, _ []string) error { } func runAppOnboard(_ *cobra.Command, _ []string) error { - cfg, err := loadFreshConfig(onboardConfigPath) + cfg, err := loadAppOnboardConfig(onboardConfigPath) if err != nil { return err } diff --git a/cmd/fresh_test.go b/cmd/fresh_test.go index 9cf53da..2c94707 100644 --- a/cmd/fresh_test.go +++ b/cmd/fresh_test.go @@ -2,21 +2,76 @@ 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 TestAppOnboardValidatesConfigBeforeInspectingCheckout(t *testing.T) { - originalConfig, originalResolve, originalClean := loadFreshConfig, resolveAppOnboarding, ensureOnboardCheckoutClean + originalConfig, originalResolve, originalClean := loadAppOnboardConfig, resolveAppOnboarding, ensureOnboardCheckoutClean originalConfigPath, originalFrom := onboardConfigPath, onboardFrom t.Cleanup(func() { - loadFreshConfig = originalConfig + loadAppOnboardConfig = originalConfig resolveAppOnboarding = originalResolve ensureOnboardCheckoutClean = originalClean onboardConfigPath, onboardFrom = originalConfigPath, originalFrom }) - loadFreshConfig = func(string) (config.Config, error) { return config.Config{}, nil } + loadAppOnboardConfig = func(string) (config.Config, error) { return config.Config{}, nil } resolveAppOnboarding = func(config.Config) (config.Config, error) { return config.Config{}, errors.New("incomplete delivery") } ensureOnboardCheckoutClean = func(string) error { t.Fatal("onboarding inspected checkout before validating config") @@ -29,16 +84,16 @@ func TestAppOnboardValidatesConfigBeforeInspectingCheckout(t *testing.T) { } func TestAppOnboardScaffoldsOnlyTheValidatedCheckout(t *testing.T) { - originalConfig, originalResolve, originalClean := loadFreshConfig, resolveAppOnboarding, ensureOnboardCheckoutClean + originalConfig, originalResolve, originalClean := loadAppOnboardConfig, resolveAppOnboarding, ensureOnboardCheckoutClean originalOrigin, originalBranch, originalGenerate := onboardCheckoutOrigin, onboardCheckoutBranch, generateAppDelivery originalConfigPath, originalFrom := onboardConfigPath, onboardFrom t.Cleanup(func() { - loadFreshConfig, resolveAppOnboarding, ensureOnboardCheckoutClean = originalConfig, originalResolve, originalClean + loadAppOnboardConfig, 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 } + loadAppOnboardConfig = 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" { diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index e9580c6..ce06890 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -46,6 +46,7 @@ type Runner struct { RegisterWebhook bool EnableDelivery bool SkipDeliveryScaffolding bool + AutoMergeBootstrapMigration bool } type operationalSecrets struct { @@ -270,8 +271,8 @@ func (r Runner) Run() error { ); 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.reconcileBootstrapMigration(manager); err != nil { + return err } if err := r.reconcileCloudflareTunnel(); err != nil { return err @@ -331,6 +332,19 @@ func (r Runner) Run() error { return nil } +func (r Runner) reconcileBootstrapMigration(manager *forgejo.RepoManager) error { + if !manager.MigrationPending { + return nil + } + if !r.AutoMergeBootstrapMigration { + return errors.New("existing repository migration PR created; merge and rerun bootstrap before infrastructure changes") + } + if err := manager.MergePullRequest(r.Config.Flux.RepoName, manager.MigrationBranch); err != nil { + return fmt.Errorf("merge bootstrap migration PR: %w", err) + } + return nil +} + func resolveLifecycleMode(mode Mode, confirmRebuild bool) (Mode, error) { if mode == "" { mode = Reconcile @@ -1090,6 +1104,9 @@ 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 diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index b0843c6..688f4eb 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -749,6 +749,55 @@ 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") @@ -812,6 +861,52 @@ func TestRunnerEnableDeliveryAppliesDefaults(t *testing.T) { } } +func TestRunnerAutoMergesBootstrapMigration(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/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}}) + case http.MethodPost + " /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) + default: + 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.MigrationPending = true + 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.MigrationPending = true + 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 diff --git a/internal/bootstrap/fresh.go b/internal/bootstrap/fresh.go index 0b0e21e..2946d6c 100644 --- a/internal/bootstrap/fresh.go +++ b/internal/bootstrap/fresh.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/Pingu-Studio/MaidnCLI/internal/config" "github.com/Pingu-Studio/MaidnCLI/internal/forgejo" @@ -90,6 +91,7 @@ func freshLifecycleRunner(cfg config.Config, options FreshOrganizationOptions) R ConfirmRebuild: options.ConfirmRebuild, EnableDelivery: options.EnableDelivery, SkipDeliveryScaffolding: !options.EnableDelivery, + AutoMergeBootstrapMigration: true, } } @@ -98,30 +100,147 @@ func validateFreshWorkspace(cfg config.Config) error { 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) + 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 } - lock, err := readTemplateRevisionLock(filepath.Join(cfg.WorkspaceDir, "maidn-template-revisions.yaml")) + 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") } - allowed := map[string]bool{ - "maidn-template-revisions.yaml": true, + 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 !allowed[entry.Name()] { + 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() +} diff --git a/internal/bootstrap/fresh_test.go b/internal/bootstrap/fresh_test.go index 1afe33b..e8e2ca1 100644 --- a/internal/bootstrap/fresh_test.go +++ b/internal/bootstrap/fresh_test.go @@ -184,7 +184,7 @@ func TestFreshLifecycleUsesSelectedModeAndGatesDelivery(t *testing.T) { {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 { + if runner.Mode != test.mode || runner.ConfirmRebuild != test.options.ConfirmRebuild || runner.EnableDelivery != test.options.EnableDelivery || runner.SkipDeliveryScaffolding == test.options.EnableDelivery || !runner.AutoMergeBootstrapMigration { t.Fatalf("fresh lifecycle runner = %#v", runner) } preflight = func(got config.Config) error { @@ -201,10 +201,61 @@ func TestFreshLifecycleUsesSelectedModeAndGatesDelivery(t *testing.T) { func TestPlanFreshOrganizationRejectsUnrecognizedWorkspaceState(t *testing.T) { cfg := freshPlanConfig(t) - if err := os.WriteFile(filepath.Join(cfg.WorkspaceDir, "leftover"), []byte("state"), 0600); err != nil { + 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) + } + } +}