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