package cmd import ( "errors" "fmt" "os" "strings" "github.com/Pingu-Studio/MaidnCLI/internal/bootstrap" "github.com/Pingu-Studio/MaidnCLI/internal/config" "github.com/Pingu-Studio/MaidnCLI/internal/forgejo" "github.com/Pingu-Studio/MaidnCLI/internal/openbao" "github.com/Pingu-Studio/MaidnCLI/internal/ui" "github.com/spf13/cobra" ) var bootstrapConfigPath string var bootstrapOutputPath string var bootstrapWorkspaceDir string var bootstrapMode string var bootstrapYes bool var bootstrapPromptDemocraticCSI bool var bootstrapPromptOperationalSecrets bool var bootstrapInitializeOpenBaoRecovery bool var bootstrapInitializeOpenBao bool var bootstrapCreateForgejoRegistryToken bool var bootstrapForgejoPasswordFile string var bootstrapCreateForgejoDeliveryStatusToken bool var bootstrapProvisionAppSecretIdentities bool var bootstrapE2EApp string var bootstrapRegisterWebhook bool var bootstrapRotateWebhookAuthorization bool var bootstrapMergeBootstrapPR bool var bootstrapManageNetworkBridges bool var bootstrapEnableDelivery bool var bootstrapDestroyDemocraticCSIStorage bool var bootstrapRefreshTemplateRevisions bool var upsertOperationalSecret = bootstrap.UpsertOperationalSecret var readOperationalSecrets = bootstrap.ReadOperationalSecrets var initializeOpenBao = bootstrap.InitializeOpenBao var createForgejoDeliveryStatusToken = forgejo.CreateDeliveryStatusToken var promptForgejoDeliveryStatusToken = ui.PromptForgejoDeliveryStatusToken var bootstrapCmd = &cobra.Command{ Use: "bootstrap", Short: "Bootstrap Talos and Flux from config or an interactive wizard.", RunE: runBootstrap, } func init() { rootCmd.AddCommand(bootstrapCmd) bootstrapCmd.Flags().StringVar(&bootstrapConfigPath, "config", "", "Path to bootstrap config YAML") bootstrapCmd.Flags().StringVar(&bootstrapOutputPath, "out", "maidn-bootstrap.yaml", "Path to save generated config") bootstrapCmd.Flags().StringVar(&bootstrapWorkspaceDir, "workspace-dir", "", "Override workspace directory for this bootstrap run") bootstrapCmd.Flags().StringVar(&bootstrapMode, "mode", string(bootstrap.Reconcile), "Lifecycle mode: reconcile or rebuild") bootstrapCmd.Flags().BoolVar(&bootstrapYes, "yes", false, "Confirm destructive rebuild") bootstrapCmd.Flags().BoolVar(&bootstrapPromptDemocraticCSI, "prompt-democratic-csi", false, "Prompt for and save Democratic CSI settings in --config") bootstrapCmd.Flags().BoolVar(&bootstrapPromptOperationalSecrets, "prompt-operational-secrets", false, "Prompt for and encrypt operational secrets for --config") bootstrapCmd.Flags().BoolVar(&bootstrapInitializeOpenBaoRecovery, "initialize-openbao-recovery", false, "Create and save a separate OpenBao recovery age identity for --config") bootstrapCmd.Flags().BoolVar(&bootstrapInitializeOpenBao, "initialize-openbao", false, "Initialize OpenBao and seed encrypted operational secrets for --config") bootstrapCmd.Flags().BoolVar(&bootstrapCreateForgejoRegistryToken, "create-forgejo-registry-token", false, "Create a least-privilege Forgejo package registry token and seed it through OpenBao") bootstrapCmd.Flags().StringVar(&bootstrapForgejoPasswordFile, "forgejo-password-file", "", "Read the Forgejo password from this local file when creating a registry token") bootstrapCmd.Flags().BoolVar(&bootstrapCreateForgejoDeliveryStatusToken, "create-forgejo-delivery-status-token", false, "Create or reuse the Forgejo delivery-status token and seed it through OpenBao") bootstrapCmd.Flags().BoolVar(&bootstrapProvisionAppSecretIdentities, "provision-app-secret-identities", false, "Create restricted app-secret and E2E OpenBao identities") bootstrapCmd.Flags().StringVar(&bootstrapE2EApp, "e2e-app", "", "Fixture app granted an E2E probe identity") bootstrapCmd.Flags().BoolVar(&bootstrapRegisterWebhook, "register-webhook", false, "Seed OpenBao secrets and register the Forgejo webhook") bootstrapCmd.Flags().BoolVar(&bootstrapRotateWebhookAuthorization, "rotate-webhook-authorization", false, "Replace the Forgejo webhook authorization and reconcile it through OpenBao") bootstrapCmd.Flags().BoolVar(&bootstrapMergeBootstrapPR, "merge-bootstrap-pr", false, "Merge the generated Flux repository migration PR before bootstrapping") bootstrapCmd.Flags().BoolVar(&bootstrapManageNetworkBridges, "manage-network-bridges", false, "Persist Terraform management for existing Talos network bridges") bootstrapCmd.Flags().BoolVar(&bootstrapEnableDelivery, "enable-delivery", false, "Resolve delivery defaults and reconcile the configured app delivery source") bootstrapCmd.Flags().BoolVar(&bootstrapDestroyDemocraticCSIStorage, "destroy-democratic-csi-storage", false, "Delete only TrueNAS datasets under this cluster's configured Democratic CSI parent during rebuild") bootstrapCmd.Flags().BoolVar(&bootstrapRefreshTemplateRevisions, "refresh-template-revisions", false, "Refresh existing template revision locks from configured branch heads during reconcile") } func runBootstrap(cmd *cobra.Command, args []string) error { if err := validateTemplateRevisionRefresh(bootstrapRefreshTemplateRevisions, bootstrap.Mode(bootstrapMode), cmd != nil && cmd.Flags().Changed("mode")); err != nil { return err } if bootstrapRefreshTemplateRevisions && (bootstrapProvisionAppSecretIdentities || bootstrapCreateForgejoRegistryToken || bootstrapCreateForgejoDeliveryStatusToken || bootstrapRotateWebhookAuthorization || bootstrapInitializeOpenBao || bootstrapMergeBootstrapPR) { return errors.New("--refresh-template-revisions is available only for the bootstrap lifecycle") } var cfg config.Config var err error if bootstrapProvisionAppSecretIdentities { if bootstrapConfigPath == "" || bootstrapE2EApp == "" { return errors.New("--provision-app-secret-identities requires --config and --e2e-app") } cfg, err = config.Load(bootstrapConfigPath) if err != nil { return err } if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil { return err } return bootstrap.ProvisionAppSecretIdentities(cfg, bootstrapE2EApp) } if bootstrapCreateForgejoRegistryToken { if bootstrapConfigPath == "" { return fmt.Errorf("--create-forgejo-registry-token requires --config") } cfg, err = config.Load(bootstrapConfigPath) if err != nil { return err } cfg, err = config.ResolveDelivery(cfg) if err != nil { return err } if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil { return err } return createForgejoRegistryToken(cfg) } if bootstrapCreateForgejoDeliveryStatusToken { if bootstrapConfigPath == "" { return fmt.Errorf("--create-forgejo-delivery-status-token requires --config") } cfg, err = config.Load(bootstrapConfigPath) if err != nil { return err } cfg, err = config.ResolveDelivery(cfg) if err != nil { return err } if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil { return err } return createOrReuseForgejoDeliveryStatusToken(cfg) } if bootstrapRotateWebhookAuthorization { if bootstrapConfigPath == "" { return fmt.Errorf("--rotate-webhook-authorization requires --config") } cfg, err = config.Load(bootstrapConfigPath) if err != nil { return err } cfg, err = config.ResolveDelivery(cfg) if err != nil { return err } authorization, err := bootstrap.NewWebhookAuthorization() if err != nil { return fmt.Errorf("generate Forgejo webhook authorization: %w", err) } if err := bootstrap.UpsertOperationalSecret(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, "cicd/forgejo-webhook", "authorization", authorization); err != nil { return fmt.Errorf("save Forgejo webhook authorization: %w", err) } return bootstrap.Runner{Config: cfg, RegisterWebhook: true, RefreshWebhookSecret: true}.Run() } if bootstrapInitializeOpenBao { if bootstrapConfigPath == "" { return fmt.Errorf("--initialize-openbao requires --config") } cfg, err = config.Load(bootstrapConfigPath) if err != nil { return err } if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil { return err } return bootstrap.InitializeOpenBao(cfg) } if bootstrapMergeBootstrapPR { if bootstrapConfigPath == "" { return fmt.Errorf("--merge-bootstrap-pr requires --config") } cfg, err = config.Load(bootstrapConfigPath) if err != nil { return err } if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil { return err } manager := forgejo.NewRepoManager(cfg.Git.BaseURL, cfg.Git.Token, cfg.Git.Owner, cfg.Git.Username, "", "", cfg.Flux.Branch, "") for _, repository := range []string{cfg.Flux.ManifestsRepo, cfg.Flux.RepoName} { open, err := manager.HasOpenPullRequest(repository, "maidn/bootstrap-"+cfg.ClusterID) if err != nil { return err } if open { if err := manager.MergePullRequest(repository, "maidn/bootstrap-"+cfg.ClusterID); err != nil { return err } } } } if bootstrapConfigPath != "" { if bootstrapPromptDemocraticCSI || bootstrapPromptOperationalSecrets || bootstrapInitializeOpenBaoRecovery || bootstrapManageNetworkBridges { cfg, err = config.LoadRaw(bootstrapConfigPath) if err == nil { if bootstrapPromptDemocraticCSI { cfg = ui.PromptDemocraticCSI(cfg) } if bootstrapManageNetworkBridges { cfg.Talos.Cluster.ManageNetworkBridges = true } cfg, err = config.Resolve(cfg) } if err == nil && bootstrapPromptOperationalSecrets { cfg, err = config.ResolveDelivery(cfg) } if err == nil && bootstrapPromptOperationalSecrets { var secrets map[string]map[string]string secrets, err = ui.PromptOperationalSecrets(cfg) if err == nil { var authorization string authorization, err = bootstrap.NewWebhookAuthorization() if err == nil { secrets["cicd/forgejo-webhook"] = map[string]string{"authorization": authorization} err = bootstrap.WriteOperationalSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, secrets) } } } if err == nil && bootstrapInitializeOpenBaoRecovery { var recipient string recipient, err = openbao.EnsureRecoveryIdentity(cfg.SOPS.RecoveryIdentityPath) if err == nil { cfg.SOPS.RecoveryRecipient = recipient } } if err == nil { err = config.Save(bootstrapConfigPath, cfg) } } else { cfg, err = config.Load(bootstrapConfigPath) } } else { cfg, err = ui.RunBootstrapWizard(config.Config{}) if err == nil { cfg, err = config.Resolve(cfg) } if err == nil { err = config.Save(bootstrapOutputPath, cfg) if err == nil { fmt.Printf("[INFO] Saved config to %s\n", bootstrapOutputPath) } } } if err != nil { return err } if bootstrapWorkspaceDir != "" { cfg.WorkspaceDir = bootstrapWorkspaceDir } if bootstrapRegisterWebhook { cfg, err = config.ResolveDelivery(cfg) if err != nil { return err } if err := seedForgejoOperationalCredentials(cfg); err != nil { return err } } runner := bootstrap.Runner{Config: cfg, Mode: bootstrap.Mode(bootstrapMode), ConfirmRebuild: bootstrapYes, RegisterWebhook: bootstrapRegisterWebhook, EnableDelivery: bootstrapEnableDelivery, DestroyDemocraticCSIStorage: bootstrapDestroyDemocraticCSIStorage, RefreshTemplateRevisions: bootstrapRefreshTemplateRevisions} return runner.Run() } func validateTemplateRevisionRefresh(refresh bool, mode bootstrap.Mode, modeExplicit bool) error { if refresh && (!modeExplicit || mode != bootstrap.Reconcile) { return errors.New("--refresh-template-revisions requires --mode=reconcile") } return nil } func seedForgejoOperationalCredentials(cfg config.Config) error { if err := upsertOperationalSecret(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, "cicd/forgejo", "username", cfg.Git.Username); err != nil { return fmt.Errorf("save Forgejo username for webhook registration: %w", err) } if err := upsertOperationalSecret(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, "cicd/forgejo", "token", cfg.Git.Token); err != nil { return fmt.Errorf("save Forgejo token for webhook registration: %w", err) } return nil } func createForgejoRegistryToken(cfg config.Config) error { if _, err := bootstrap.ReadOperationalSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath); err != nil { return err } password, otp, name, err := forgejoRegistryTokenCredentials() if err != nil { return err } token, err := forgejo.CreateRegistryToken(cfg.Git.BaseURL, cfg.Git.Username, password, otp, name) if err != nil { return fmt.Errorf("create Forgejo registry token: %w", err) } dockerConfig, err := bootstrap.ForgejoRegistryDockerConfig(cfg.Delivery.ImageRepository, cfg.Git.Username, token) if err != nil { return err } if err := bootstrap.UpsertOperationalSecret(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, "cicd/forgejo-registry", "dockerconfigjson", dockerConfig); err != nil { return fmt.Errorf("Forgejo registry token was created but could not be saved; revoke the new token in Forgejo and retry: %w", err) } if err := bootstrap.InitializeOpenBao(cfg); err != nil { return fmt.Errorf("Forgejo registry token was saved to encrypted operational secrets but OpenBao seeding failed; rerun bootstrap --config %q --initialize-openbao: %w", bootstrapConfigPath, err) } return nil } func forgejoRegistryTokenCredentials() (password, otp, name string, err error) { if bootstrapForgejoPasswordFile == "" { return ui.PromptForgejoRegistryToken() } data, err := os.ReadFile(bootstrapForgejoPasswordFile) if err != nil { return "", "", "", fmt.Errorf("read Forgejo password file: %w", err) } password = strings.TrimSpace(string(data)) if password == "" { return "", "", "", errors.New("Forgejo password file is empty") } return password, "", "maidn-registry", nil } func createOrReuseForgejoDeliveryStatusToken(cfg config.Config) error { secrets, err := readOperationalSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath) if err != nil { return err } values, found := secrets["cicd/forgejo-delivery-status"] var token string if found { if len(values) != 1 || values["token"] == "" { return errors.New("operational SOPS secrets has ambiguous cicd/forgejo-delivery-status state; refusing to create another token") } token = values["token"] } else { password, otp, err := promptForgejoDeliveryStatusToken() if err != nil { return err } token, err = createForgejoDeliveryStatusToken(cfg.Git.BaseURL, cfg.Git.Username, password, otp) if err != nil { return fmt.Errorf("create Forgejo delivery-status token: %w", redactCredentialError(err, password, otp)) } if err := upsertOperationalSecret(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, "cicd/forgejo-delivery-status", "token", token); err != nil { return errors.New("Forgejo delivery-status token was created but could not be saved; revoke the new token in Forgejo and retry") } } if err := initializeOpenBao(cfg); err != nil { return fmt.Errorf("Forgejo delivery-status token is in encrypted operational secrets but OpenBao seeding failed; rerun bootstrap with --config and --create-forgejo-delivery-status-token: %w", redactCredentialError(err, token)) } return nil } func redactCredentialError(err error, sensitive ...string) error { message := err.Error() for _, value := range sensitive { if value != "" { message = strings.ReplaceAll(message, value, "[REDACTED]") } } return errors.New(message) }