247 lines
8.8 KiB
Go
247 lines
8.8 KiB
Go
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",
|
|
}
|
|
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 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,
|
|
EnableDelivery: options.EnableDelivery,
|
|
SkipDeliveryScaffolding: !options.EnableDelivery,
|
|
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()
|
|
}
|