maidn-cli/cmd/bootstrap.go

284 lines
11 KiB
Go

package cmd
import (
"fmt"
"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 bootstrapMode string
var bootstrapYes bool
var bootstrapPromptDemocraticCSI bool
var bootstrapPromptOperationalSecrets bool
var bootstrapInitializeOpenBaoRecovery bool
var bootstrapInitializeOpenBao bool
var bootstrapCreateForgejoRegistryToken bool
var bootstrapRegisterWebhook bool
var bootstrapRotateWebhookAuthorization bool
var bootstrapPublishAppFrom string
var bootstrapMergeBootstrapPR bool
var bootstrapManageNetworkBridges bool
var upsertOperationalSecret = bootstrap.UpsertOperationalSecret
var loadPublishAppConfig = config.Load
var ensurePublishAppCheckoutClean = forgejo.EnsureCleanCheckout
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(&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(&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().StringVar(&bootstrapPublishAppFrom, "publish-app-from", "", "Push this app checkout's current branch and create a Forgejo delivery PR")
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")
}
func runBootstrap(cmd *cobra.Command, args []string) error {
var cfg config.Config
var err error
if bootstrapCreateForgejoRegistryToken {
if bootstrapConfigPath == "" {
return fmt.Errorf("--create-forgejo-registry-token requires --config")
}
cfg, err = config.Load(bootstrapConfigPath)
if err != nil {
return err
}
if err := config.ValidateDelivery(cfg); err != nil {
return err
}
if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil {
return err
}
return createForgejoRegistryToken(cfg)
}
if bootstrapRotateWebhookAuthorization {
if bootstrapConfigPath == "" {
return fmt.Errorf("--rotate-webhook-authorization requires --config")
}
cfg, err = config.Load(bootstrapConfigPath)
if err != nil {
return err
}
if err := config.ValidateDelivery(cfg); 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, "")
if err := manager.MergePullRequest(cfg.Flux.RepoName, "maidn/bootstrap-"+cfg.ClusterID); err != nil {
return err
}
}
if bootstrapPublishAppFrom != "" {
if bootstrapConfigPath == "" {
return fmt.Errorf("--publish-app-from requires --config")
}
cfg, err = loadPublishAppConfig(bootstrapConfigPath)
if err != nil {
return err
}
if err := config.ValidateDelivery(cfg); err != nil {
return err
}
if err := ensurePublishAppCheckoutClean(bootstrapPublishAppFrom); err != nil {
return err
}
origin, err := forgejo.CheckoutOrigin(bootstrapPublishAppFrom)
if err != nil {
return err
}
if config.RedactURL(origin) != cfg.Delivery.AppRepoURL {
return fmt.Errorf("--publish-app-from origin does not match delivery appRepoUrl")
}
branch, err := forgejo.CurrentBranch(bootstrapPublishAppFrom)
if err != nil {
return err
}
deliveryBranch, err := forgejo.DeliveryBranch(cfg.Delivery.AppName, cfg.Delivery.AppRepoRef)
if err != nil {
return err
}
if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil {
return err
}
owner, repo, err := forgejo.RepositoryFromURL(cfg.Delivery.AppRepoURL)
if err != nil {
return err
}
manager := forgejo.NewRepoManager(cfg.Git.BaseURL, cfg.Git.Token, owner, cfg.Git.Username, "", "", cfg.Delivery.AppRepoRef, "")
created, err := manager.EnsureRepository(repo, "Application source for Maidn CI/CD delivery")
if err != nil {
return err
}
if created && branch != cfg.Delivery.AppRepoRef {
return fmt.Errorf("new application repository requires the checkout branch to match delivery appRepoRef")
}
if err := manager.EnsureProtectedBranch(repo, cfg.Delivery.ProductionBranch); err != nil {
return fmt.Errorf("protect Forgejo production branch: %w", err)
}
if err := manager.PushRef(bootstrapPublishAppFrom, cfg.Delivery.AppRepoURL, branch, branch); err != nil {
return err
}
if err := manager.PublishDeliveryBranch(bootstrapPublishAppFrom, branch, cfg.Delivery.AppRepoURL, deliveryBranch, func(dir string) error {
return bootstrap.GenerateAppDelivery(dir, cfg)
}); err != nil {
return err
}
return manager.CreatePullRequest(repo, "feat: migrate delivery to Tekton", deliveryBranch, cfg.Delivery.AppRepoRef)
}
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 {
err = config.ValidateDelivery(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 bootstrapRegisterWebhook {
if err := config.ValidateDelivery(cfg); 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}
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
}