Compare commits

...

4 commits

7 changed files with 371 additions and 17 deletions

View file

@ -13,7 +13,8 @@ var freshConfigPath, freshOrganization, onboardConfigPath, onboardFrom string
var freshCreateOrganization, freshEnableDelivery, freshYes bool var freshCreateOrganization, freshEnableDelivery, freshYes bool
var freshMode string var freshMode string
var loadFreshConfig = config.LoadRaw var loadFreshConfig = config.Load
var loadAppOnboardConfig = config.LoadRaw
var runFreshOrganization = bootstrap.RunFreshOrganization var runFreshOrganization = bootstrap.RunFreshOrganization
var resolveAppOnboarding = config.ResolveAppOnboarding var resolveAppOnboarding = config.ResolveAppOnboarding
var ensureOnboardCheckoutClean = forgejo.EnsureCleanCheckout var ensureOnboardCheckoutClean = forgejo.EnsureCleanCheckout
@ -73,7 +74,7 @@ func runBootstrapInit(cmd *cobra.Command, _ []string) error {
} }
func runAppOnboard(_ *cobra.Command, _ []string) error { func runAppOnboard(_ *cobra.Command, _ []string) error {
cfg, err := loadFreshConfig(onboardConfigPath) cfg, err := loadAppOnboardConfig(onboardConfigPath)
if err != nil { if err != nil {
return err return err
} }

View file

@ -2,21 +2,76 @@ package cmd
import ( import (
"errors" "errors"
"io"
"os"
"path/filepath"
"testing" "testing"
"github.com/Pingu-Studio/MaidnCLI/internal/bootstrap"
"github.com/Pingu-Studio/MaidnCLI/internal/config" "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) { func TestAppOnboardValidatesConfigBeforeInspectingCheckout(t *testing.T) {
originalConfig, originalResolve, originalClean := loadFreshConfig, resolveAppOnboarding, ensureOnboardCheckoutClean originalConfig, originalResolve, originalClean := loadAppOnboardConfig, resolveAppOnboarding, ensureOnboardCheckoutClean
originalConfigPath, originalFrom := onboardConfigPath, onboardFrom originalConfigPath, originalFrom := onboardConfigPath, onboardFrom
t.Cleanup(func() { t.Cleanup(func() {
loadFreshConfig = originalConfig loadAppOnboardConfig = originalConfig
resolveAppOnboarding = originalResolve resolveAppOnboarding = originalResolve
ensureOnboardCheckoutClean = originalClean ensureOnboardCheckoutClean = originalClean
onboardConfigPath, onboardFrom = originalConfigPath, originalFrom 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") } resolveAppOnboarding = func(config.Config) (config.Config, error) { return config.Config{}, errors.New("incomplete delivery") }
ensureOnboardCheckoutClean = func(string) error { ensureOnboardCheckoutClean = func(string) error {
t.Fatal("onboarding inspected checkout before validating config") t.Fatal("onboarding inspected checkout before validating config")
@ -29,16 +84,16 @@ func TestAppOnboardValidatesConfigBeforeInspectingCheckout(t *testing.T) {
} }
func TestAppOnboardScaffoldsOnlyTheValidatedCheckout(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 originalOrigin, originalBranch, originalGenerate := onboardCheckoutOrigin, onboardCheckoutBranch, generateAppDelivery
originalConfigPath, originalFrom := onboardConfigPath, onboardFrom originalConfigPath, originalFrom := onboardConfigPath, onboardFrom
t.Cleanup(func() { t.Cleanup(func() {
loadFreshConfig, resolveAppOnboarding, ensureOnboardCheckoutClean = originalConfig, originalResolve, originalClean loadAppOnboardConfig, resolveAppOnboarding, ensureOnboardCheckoutClean = originalConfig, originalResolve, originalClean
onboardCheckoutOrigin, onboardCheckoutBranch, generateAppDelivery = originalOrigin, originalBranch, originalGenerate onboardCheckoutOrigin, onboardCheckoutBranch, generateAppDelivery = originalOrigin, originalBranch, originalGenerate
onboardConfigPath, onboardFrom = originalConfigPath, originalFrom onboardConfigPath, onboardFrom = originalConfigPath, originalFrom
}) })
cfg := config.Config{Delivery: config.DeliveryConfig{AppRepoURL: "https://git.example.test/new-org/app.git", AppRepoRef: "main"}} cfg := config.Config{Delivery: config.DeliveryConfig{AppRepoURL: "https://git.example.test/new-org/app.git", AppRepoRef: "main"}}
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 } resolveAppOnboarding = func(config.Config) (config.Config, error) { return cfg, nil }
ensureOnboardCheckoutClean = func(path string) error { ensureOnboardCheckoutClean = func(path string) error {
if path != "app-checkout" { if path != "app-checkout" {

View file

@ -34,6 +34,22 @@ go run . bootstrap --config <private-bootstrap-config> --mode=rebuild --yes
The rebuild replaces Terraform-managed Talos VMs only. It does not manage or The rebuild replaces Terraform-managed Talos VMs only. It does not manage or
delete TrueNAS datasets or unrelated infrastructure. 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 ## OpenBao And Webhooks
After a rebuild or an OpenBao restart, refresh Kubernetes auth and reseed the After a rebuild or an OpenBao restart, refresh Kubernetes auth and reseed the

View file

@ -46,6 +46,7 @@ type Runner struct {
RegisterWebhook bool RegisterWebhook bool
EnableDelivery bool EnableDelivery bool
SkipDeliveryScaffolding bool SkipDeliveryScaffolding bool
AutoMergeBootstrapMigration bool
} }
type operationalSecrets struct { type operationalSecrets struct {
@ -270,8 +271,8 @@ func (r Runner) Run() error {
); err != nil { ); err != nil {
return err return err
} }
if manager.MigrationPending { if err := r.reconcileBootstrapMigration(manager); err != nil {
return errors.New("existing repository migration PR created; merge and rerun bootstrap before infrastructure changes") return err
} }
if err := r.reconcileCloudflareTunnel(); err != nil { if err := r.reconcileCloudflareTunnel(); err != nil {
return err return err
@ -331,6 +332,19 @@ func (r Runner) Run() error {
return nil 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) { func resolveLifecycleMode(mode Mode, confirmRebuild bool) (Mode, error) {
if mode == "" { if mode == "" {
mode = Reconcile mode = Reconcile
@ -1090,6 +1104,9 @@ func copyTemplateBaseComponents(templateDir, repoDir string, includeDelivery boo
} }
func copyClusterTemplate(source, destination string) error { func copyClusterTemplate(source, destination string) error {
if err := os.MkdirAll(destination, 0755); err != nil {
return err
}
entries, err := os.ReadDir(destination) entries, err := os.ReadDir(destination)
if err != nil && !os.IsNotExist(err) { if err != nil && !os.IsNotExist(err) {
return err return err

View file

@ -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) { func TestWebhookTargetTimeoutExceedsExternalSecretRefreshInterval(t *testing.T) {
if webhookTargetTimeout <= time.Hour { if webhookTargetTimeout <= time.Hour {
t.Fatal("webhook target timeout must exceed the one-hour ExternalSecret refresh interval") 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) { func TestRunnerRegisterWebhookSkipsTemplateRevisions(t *testing.T) {
originalPreflight := preflight originalPreflight := preflight
originalGit := runGit originalGit := runGit

View file

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"github.com/Pingu-Studio/MaidnCLI/internal/config" "github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo" "github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
@ -90,6 +91,7 @@ func freshLifecycleRunner(cfg config.Config, options FreshOrganizationOptions) R
ConfirmRebuild: options.ConfirmRebuild, ConfirmRebuild: options.ConfirmRebuild,
EnableDelivery: options.EnableDelivery, EnableDelivery: options.EnableDelivery,
SkipDeliveryScaffolding: !options.EnableDelivery, SkipDeliveryScaffolding: !options.EnableDelivery,
AutoMergeBootstrapMigration: true,
} }
} }
@ -98,30 +100,147 @@ func validateFreshWorkspace(cfg config.Config) error {
if err != nil || filepath.Dir(cloneRelative) != "." { if err != nil || filepath.Dir(cloneRelative) != "." {
return errors.New("git cloneParent must be a direct child of isolated workspaceDir") 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) { if os.IsNotExist(err) {
return nil return nil
} }
if err != nil { if err != nil {
return fmt.Errorf("inspect workspaceDir: %w", err) 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 { if len(entries) == 0 {
return nil 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}) { 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") return errors.New("workspaceDir contains ambiguous state; use a new empty isolated workspaceDir")
} }
allowed := map[string]bool{ allowedFiles := map[string]bool{
"maidn-template-revisions.yaml": true, "maidn-template-revisions.yaml": true,
"maidn-bootstrap.resolved.yaml": true,
}
allowedDirectories := map[string]bool{
"maidn-cicd-cluster-template": true, "maidn-cicd-cluster-template": true,
"cicd-deployment-manifests-template": true, "cicd-deployment-manifests-template": true,
cloneRelative: 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 { 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 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 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

@ -184,7 +184,7 @@ func TestFreshLifecycleUsesSelectedModeAndGatesDelivery(t *testing.T) {
{FreshOrganizationOptions{Mode: Rebuild, ConfirmRebuild: true, EnableDelivery: true}, Rebuild}, {FreshOrganizationOptions{Mode: Rebuild, ConfirmRebuild: true, EnableDelivery: true}, Rebuild},
} { } {
runner := freshLifecycleRunner(cfg, test.options) 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) t.Fatalf("fresh lifecycle runner = %#v", runner)
} }
preflight = func(got config.Config) error { preflight = func(got config.Config) error {
@ -201,10 +201,61 @@ func TestFreshLifecycleUsesSelectedModeAndGatesDelivery(t *testing.T) {
func TestPlanFreshOrganizationRejectsUnrecognizedWorkspaceState(t *testing.T) { func TestPlanFreshOrganizationRejectsUnrecognizedWorkspaceState(t *testing.T) {
cfg := freshPlanConfig(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) t.Fatal(err)
} }
if _, _, err := PlanFreshOrganization(cfg, FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true}); err == nil { if _, _, err := PlanFreshOrganization(cfg, FreshOrganizationOptions{Organization: "new-org", CreateOrganization: true}); err == nil {
t.Fatal("ambiguous workspace state was accepted") 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)
}
}
}