package cmd import ( "errors" "fmt" "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 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 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().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") } func runBootstrap(cmd *cobra.Command, args []string) error { 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}.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} return runner.Run() } 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 := ui.PromptForgejoRegistryToken() 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 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) }