343 lines
14 KiB
Go
343 lines
14 KiB
Go
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 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")
|
|
}
|
|
|
|
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 := 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)
|
|
}
|