From 72fbbca0ac8a5cb50ddf80ea065abc16b18abd5b Mon Sep 17 00:00:00 2001 From: eding Date: Sun, 26 Jul 2026 20:39:07 +0200 Subject: [PATCH] feat: make bootstrap self-contained --- .gitignore | 5 + README.md | 12 + cmd/bootstrap.go | 63 +++- docs/secrets.md | 47 +++ internal/bootstrap/bootstrap.go | 488 +++++++++++++++++++++++---- internal/bootstrap/bootstrap_test.go | 122 +++++++ internal/config/config.go | 210 ++++++++++-- internal/config/config_test.go | 81 +++++ internal/config/types.go | 87 +++-- internal/forgejo/repo.go | 291 +++++++++++++--- internal/forgejo/repo_test.go | 96 ++++++ internal/github/repository.go | 32 +- internal/openbao/bootstrap.go | 274 +++++++++++++++ internal/openbao/bootstrap_test.go | 21 ++ internal/ui/wizard.go | 78 ++++- internal/utils/helpers.go | 16 + 16 files changed, 1728 insertions(+), 195 deletions(-) create mode 100644 .gitignore create mode 100644 docs/secrets.md create mode 100644 internal/config/config_test.go create mode 100644 internal/forgejo/repo_test.go create mode 100644 internal/openbao/bootstrap.go create mode 100644 internal/openbao/bootstrap_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2eedac --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.age/ +.recovery/ +maidn-bootstrap*.yaml +maidn-workspace/ +cicd-tool.exe diff --git a/README.md b/README.md index b901114..d04122a 100644 --- a/README.md +++ b/README.md @@ -45,3 +45,15 @@ talos: ip: vlanId: ``` + + +https://192.168.0.15:8006 +root@pam!maidn-test-key +2ce7bff1-ac98-4a45-8db4-186d49ae4159 + +test-org-test-key:147878348db3b8ab660b1af2799c3842705a31bc +maidn-dev-work:fca448f2677362cc165eebb7859ce8f9f5e3735c + +https://192.168.0.13:8006 +root@pam!maidn-test-key +3773033f-552b-4572-83e9-cd16e8ac2e13 \ No newline at end of file diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index dcc7fdb..703cfcb 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -2,37 +2,82 @@ package cmd import ( "fmt" - "os" "github.com/Pingu-Studio/MaidnCLI/internal/bootstrap" "github.com/Pingu-Studio/MaidnCLI/internal/config" + "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 bootstrapCmd = &cobra.Command{ Use: "bootstrap", Short: "Bootstrap Talos and Flux from config or an interactive wizard.", - Run: runBootstrap, + 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") } -func runBootstrap(cmd *cobra.Command, args []string) { +func runBootstrap(cmd *cobra.Command, args []string) error { var cfg config.Config var err error if bootstrapConfigPath != "" { - cfg, err = config.Load(bootstrapConfigPath) + if bootstrapPromptDemocraticCSI || bootstrapPromptOperationalSecrets || bootstrapInitializeOpenBaoRecovery { + cfg, err = config.LoadRaw(bootstrapConfigPath) + if err == nil { + if bootstrapPromptDemocraticCSI { + cfg = ui.PromptDemocraticCSI(cfg) + } + cfg, err = config.Resolve(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 { @@ -41,13 +86,9 @@ func runBootstrap(cmd *cobra.Command, args []string) { } } if err != nil { - fmt.Printf("[ERROR] %v\n", err) - os.Exit(1) + return err } - runner := bootstrap.Runner{Config: cfg} - if err = runner.Run(); err != nil { - fmt.Printf("[ERROR] %v\n", err) - os.Exit(1) - } + runner := bootstrap.Runner{Config: cfg, Mode: bootstrap.Mode(bootstrapMode), ConfirmRebuild: bootstrapYes} + return runner.Run() } diff --git a/docs/secrets.md b/docs/secrets.md new file mode 100644 index 0000000..25959af --- /dev/null +++ b/docs/secrets.md @@ -0,0 +1,47 @@ +# Bootstrap Secret Inputs + +`bootstrap` uses the Flux age identity in ignored `.age/` storage to encrypt +the configured Democratic CSI Secret directly into the generated cluster +repository. Its TrueNAS API key is never printed or committed in plaintext. + +`operational-secrets.sops.yaml` is decrypted only in MaidnCLI memory after +OpenBao is initialized. It is not copied to the cluster repository. Its schema +is: + +```yaml +secrets: + cicd/forgejo: + username: encrypted-value + token: encrypted-value + cicd/forgejo-registry: + dockerconfigjson: encrypted-value + cicd/forgejo-webhook: + authorization: encrypted-value + platform/cloudflare: + api-token: encrypted-value + platform/cloudflare-tunnel: + token: encrypted-value +``` + +Keys are written to OpenBao KV v2 under `secret/`. Additional paths are +allowed when they use lowercase path characters and scalar property names. + +Set all `democraticCsi` settings in the bootstrap configuration or provide +them through the interactive wizard. The CLI writes those values only to +`base/democratic-csi/secret.sops.yaml` in the generated cluster repository. +For an existing configuration, run `bootstrap --config +--prompt-democratic-csi` to enter the settings with the API key masked. + +For a new operational-secret input, run `bootstrap --config +--prompt-operational-secrets`. It derives Forgejo Git and registry credentials +from the configured Forgejo account, prompts for Cloudflare credentials with +input masked, and generates the webhook authorization value. + +`cicd/forgejo-webhook.authorization` is required for delivery bootstrap. The +CLI supplies it as the Forgejo webhook Authorization header and Tekton compares +that header against the ExternalSecret-derived `forgejo-webhook` Secret. + +Run `bootstrap --config --initialize-openbao-recovery` to create and +save a separate recovery age identity for `openbao-recovery.age`. The Flux SOPS +age identity is installed in `flux-system`; it must not encrypt OpenBao +recovery material. diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 2f14f29..2b26d31 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -1,29 +1,75 @@ package bootstrap import ( + "bytes" + "crypto/rand" + "encoding/base64" + "errors" "fmt" "io" "os" + "os/exec" "path/filepath" + "sort" "strings" "time" "github.com/Pingu-Studio/MaidnCLI/internal/config" "github.com/Pingu-Studio/MaidnCLI/internal/forgejo" ghrepo "github.com/Pingu-Studio/MaidnCLI/internal/github" + "github.com/Pingu-Studio/MaidnCLI/internal/openbao" "github.com/Pingu-Studio/MaidnCLI/internal/utils" + "gopkg.in/yaml.v3" +) + +type Mode string + +const ( + Reconcile Mode = "reconcile" + Rebuild Mode = "rebuild" ) type Runner struct { - Config config.Config + Config config.Config + Mode Mode + ConfirmRebuild bool } func (r Runner) Run() error { + resolved, err := config.Resolve(r.Config) + if err != nil { + return err + } + r.Config = resolved + if err := config.Preflight(r.Config); err != nil { + return fmt.Errorf("preflight: %w", err) + } + if _, err := os.Stat(r.Config.SOPS.AgeKeyPath); err != nil { + return fmt.Errorf("preflight SOPS age identity: %w", err) + } + if r.Config.Talos.AutoBootstrapFlux { + if _, err := os.Stat(r.Config.SOPS.OperationalSecretsPath); err != nil { + return fmt.Errorf("preflight operational SOPS secrets: %w", err) + } + if r.Config.SOPS.RecoveryRecipient == "" || r.Config.SOPS.RecoveryIdentityPath == "" || r.Config.SOPS.RecoveryBundlePath == "" { + return errors.New("SOPS recoveryRecipient, recoveryIdentityPath, and recoveryBundlePath are required") + } + } + if r.Mode == "" { + r.Mode = Reconcile + } + if r.Mode != Reconcile && r.Mode != Rebuild { + return fmt.Errorf("unsupported bootstrap mode %q", r.Mode) + } + if r.Mode == Rebuild && !r.ConfirmRebuild { + return fmt.Errorf("rebuild is destructive; rerun with --mode=rebuild --yes") + } + workspace := r.Config.WorkspaceDir if err := os.MkdirAll(workspace, 0755); err != nil { return err } - if err := config.Save(filepath.Join(workspace, "maidn-bootstrap.resolved.yaml"), r.Config); err != nil { + if err := config.WriteRedacted(filepath.Join(workspace, "maidn-bootstrap.resolved.yaml"), r.Config); err != nil { return err } if err := os.MkdirAll(r.Config.Git.CloneParent, 0755); err != nil { @@ -31,20 +77,29 @@ func (r Runner) Run() error { } manifestsURL := forgejo.CloneURL(r.Config.Git.BaseURL, r.Config.Git.Owner, r.Config.Flux.ManifestsRepo) - fluxConfig := ghrepo.BuildFluxConfig(strings.ToLower(r.Config.Git.Owner), manifestsURL, r.Config.Flux.ManifestsRepo) + fluxConfig := ghrepo.BuildFluxConfig(manifestsURL, r.Config.Flux.ManifestsRepo, r.Config.Flux.Branch) cicdTemplateDir := filepath.Join(workspace, "maidn-cicd-cluster-template") if err := ensureRepo(cicdTemplateDir, r.Config.Templates.CICDRepoURL, r.Config.Templates.CICDRepoRef); err != nil { return err } - manager := forgejo.NewRepoManager(r.Config.Git.BaseURL, r.Config.Git.Token, r.Config.Git.Owner, r.Config.Git.Username, r.Config.Flux.ManifestsRepo, r.Config.Flux.RepoName) + manifestsTemplateDir := filepath.Join(workspace, "cicd-deployment-manifests-template") + if err := ensureRepo(manifestsTemplateDir, r.Config.Templates.ManifestsRepoURL, r.Config.Templates.ManifestsRepoRef); err != nil { + return err + } + manager := forgejo.NewRepoManager(r.Config.Git.BaseURL, r.Config.Git.Token, r.Config.Git.Owner, r.Config.Git.Username, r.Config.Flux.ManifestsRepo, r.Config.Flux.RepoName, r.Config.Flux.Branch, "maidn/bootstrap-"+r.Config.ClusterID) if err := manager.InitializeAll( - func(dir string) error { return ghrepo.WriteManifestsStructure(dir, r.Config.Flux.ManifestsRepo) }, + func(dir string) error { + if err := copyDir(manifestsTemplateDir, dir, false); err != nil { + return err + } + return ensureManifestsKustomizations(dir) + }, func(dir string) error { clusterDir := filepath.Join(dir, strings.TrimPrefix(r.Config.Flux.ClusterPath, "./")) if err := copyDir(filepath.Join(cicdTemplateDir, "base"), filepath.Join(dir, "base"), false); err != nil { return err } - if err := copyDir(filepath.Join(cicdTemplateDir, "clusters", "template"), clusterDir, false); err != nil { + if err := copyClusterTemplate(filepath.Join(cicdTemplateDir, "clusters", "template"), clusterDir); err != nil { return err } if err := copyDir(filepath.Join(cicdTemplateDir, "base", "cilium"), filepath.Join(dir, "base", "cilium"), true); err != nil { @@ -53,13 +108,25 @@ func (r Runner) Run() error { if err := copyDir(filepath.Join(cicdTemplateDir, "base", "cilium-config"), filepath.Join(dir, "base", "cilium-config"), true); err != nil { return err } + if err := copyDir(filepath.Join(cicdTemplateDir, "base", "democratic-csi"), filepath.Join(dir, "base", "democratic-csi"), true); err != nil { + return err + } if err := renderCiliumConfig(filepath.Join(dir, "base", "cilium"), r.Config); err != nil { return err } if err := renderCiliumConfig(filepath.Join(dir, "base", "cilium-config"), r.Config); err != nil { return err } - if err := ensureCiliumKustomizations(clusterDir); err != nil { + if err := renderDeliveryConfig(filepath.Join(dir, "base", "tekton"), r.Config); err != nil { + return err + } + if err := renderDeliveryConfig(filepath.Join(dir, "base", "tekton-triggers"), r.Config); err != nil { + return err + } + if err := writeDemocraticCSISecret(filepath.Join(dir, "base", "democratic-csi", "secret.sops.yaml"), r.Config.DemocraticCSI, r.Config.SOPS.AgeKeyPath); err != nil { + return err + } + if err := ensureClusterKustomizations(clusterDir); err != nil { return err } return ghrepo.WriteFluxStructure(dir, r.Config.Flux.RepoName, r.Config.Flux.ClusterPath, fluxConfig) @@ -67,6 +134,9 @@ func (r Runner) Run() error { ); err != nil { return err } + if manager.MigrationPending { + return errors.New("existing repository migration PR created; merge and rerun bootstrap before infrastructure changes") + } repoDir := filepath.Join(r.Config.Git.CloneParent, r.Config.Talos.RepoDirName) if err := ensureRepo(repoDir, r.Config.Templates.TalosRepoURL, r.Config.Templates.TalosRepoRef); err != nil { @@ -78,15 +148,15 @@ func (r Runner) Run() error { if err := os.MkdirAll(generatedDir, 0755); err != nil { return err } - if err := os.WriteFile(filepath.Join(terraformDir, r.Config.Talos.ConfigFileName), []byte(renderTerraformTFVars(r.Config)), 0644); err != nil { + if err := ensureLifecycleIdentity(terraformDir, r.Config); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(terraformDir, r.Config.Talos.ConfigFileName), []byte(renderTerraformTFVars(r.Config)), 0600); err != nil { return err } if r.Config.Talos.AutoRunTerraform { - if err := utils.RunCommandInDir(terraformDir, "terraform", "init"); err != nil { - return err - } - if err := utils.RunCommandInDir(terraformDir, "terraform", "apply", "-auto-approve"); err != nil { + if err := r.reconcileTerraform(terraformDir); err != nil { return err } } @@ -94,22 +164,11 @@ func (r Runner) Run() error { return err } if r.Config.Talos.AutoBootstrap { - configFile := filepath.Join("clusterconfig", fmt.Sprintf("%s-%s.yaml", r.Config.Talos.Cluster.Name, r.Config.Talos.Nodes[0].Name)) - if _, err := utils.RunCommandQuietOutputInDir(generatedDir, "talosctl", "apply-config", "--talosconfig=./clusterconfig/talosconfig", "--nodes="+r.Config.Talos.BootstrapNode, "--endpoints="+r.Config.Talos.BootstrapEndpoint, "--file="+configFile); err != nil { - if err := utils.RunCommandInDir(generatedDir, "talosctl", "apply-config", "--insecure", "--nodes="+r.Config.Talos.BootstrapNode, "--endpoints="+r.Config.Talos.BootstrapEndpoint, "--file="+configFile); err != nil { - return err - } - } - if err := waitForTalosReboot(generatedDir, r.Config); err != nil { + if err := applyTalosConfigs(generatedDir, r.Config); err != nil { return err } - if _, err := utils.RunCommandQuietOutputInDir(generatedDir, "talosctl", "health", "--talosconfig=./clusterconfig/talosconfig", "--endpoints="+r.Config.Talos.BootstrapEndpoint, "--nodes="+r.Config.Talos.BootstrapNode, "--wait-timeout=20s"); err != nil { - if err := utils.RunCommandInDir(generatedDir, "talosctl", "bootstrap", "--talosconfig=./clusterconfig/talosconfig", "--endpoints="+r.Config.Talos.BootstrapEndpoint, "--nodes="+r.Config.Talos.BootstrapNode); err != nil { - return err - } - if err := utils.RunCommandInDir(generatedDir, "talosctl", "health", "--talosconfig=./clusterconfig/talosconfig", "--endpoints="+r.Config.Talos.BootstrapEndpoint, "--nodes="+r.Config.Talos.BootstrapNode, "--wait-timeout=2m"); err != nil { - return err - } + if err := bootstrapEtcdIfNeeded(generatedDir, r.Config); err != nil { + return err } if err := utils.RunCommandInDir(generatedDir, "talosctl", "kubeconfig", "--talosconfig=./clusterconfig/talosconfig", "--nodes="+r.Config.Talos.KubeconfigNode, "."); err != nil { return err @@ -119,9 +178,29 @@ func (r Runner) Run() error { if err := installCilium(generatedDir, r.Config); err != nil { return err } - if err := utils.RunCommandInDir(generatedDir, "flux", "bootstrap", "git", "--url="+forgejo.CloneURL(r.Config.Git.BaseURL, r.Config.Git.Owner, r.Config.Flux.RepoName), "--branch="+r.Config.Flux.Branch, "--path="+r.Config.Flux.ClusterPath, "--cluster-domain="+r.Config.Flux.ClusterDomain, "--username="+r.Config.Git.Username, "--password="+r.Config.Git.Token, "--token-auth", "--kubeconfig=kubeconfig"); err != nil { + if err := utils.RunCommandInDirEnv(generatedDir, []string{"GIT_PASSWORD=" + r.Config.Git.Token}, "flux", "bootstrap", "git", "--url="+forgejo.CloneURL(r.Config.Git.BaseURL, r.Config.Git.Owner, r.Config.Flux.RepoName), "--branch="+r.Config.Flux.Branch, "--path="+r.Config.Flux.ClusterPath, "--cluster-domain="+r.Config.Flux.ClusterDomain, "--username="+r.Config.Git.Username, "--token-auth", "--kubeconfig=kubeconfig"); err != nil { return err } + if err := installSOPSKey(generatedDir, r.Config); err != nil { + return err + } + if err := configureFluxSOPS(generatedDir); err != nil { + return err + } + operationalSecrets, err := openbao.Initialize(filepath.Join(generatedDir, "kubeconfig"), r.Config.SOPS.RecoveryRecipient, r.Config.SOPS.RecoveryIdentityPath, r.Config.SOPS.RecoveryBundlePath, r.Config.SOPS.AgeKeyPath, r.Config.SOPS.OperationalSecretsPath) + if err != nil { + return fmt.Errorf("initialize OpenBao: %w", err) + } + authorization := operationalSecrets["cicd/forgejo-webhook"]["authorization"] + if authorization == "" { + return errors.New("operational SOPS secrets requires cicd/forgejo-webhook.authorization") + } + if err := waitForWebhookTargets(generatedDir, r.Config); err != nil { + return err + } + if err := manager.EnsureWebhook(r.Config.Delivery.AppName, r.Config.Delivery.WebhookURL(), authorization); err != nil { + return fmt.Errorf("reconcile Forgejo webhook: %w", err) + } } return nil } @@ -145,14 +224,133 @@ func renderCiliumConfig(dir string, cfg config.Config) error { }) } -func ensureCiliumKustomizations(clusterDir string) error { +func renderDeliveryConfig(dir string, cfg config.Config) error { + replacements := strings.NewReplacer( + "${APP_NAME}", cfg.Delivery.AppName, + "${APP_REPO_URL}", cfg.Delivery.AppRepoURL, + "${APP_REPO_REF}", cfg.Delivery.AppRepoRef, + "${IMAGE_REPOSITORY}", cfg.Delivery.ImageRepository, + "${FORGEJO_BASE_URL}", cfg.Git.BaseURL, + "${WEBHOOK_HOSTNAME}", cfg.Delivery.WebhookHostname, + "${WEBHOOK_PATH}", cfg.Delivery.WebhookPath, + ) + return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return err + } + content, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(path, []byte(replacements.Replace(string(content))), info.Mode()) + }) +} + +func writeDemocraticCSISecret(path string, csi config.DemocraticCSIConfig, ageKeyPath string) error { + plaintext, err := renderDemocraticCSISecret(csi) + if err != nil { + return err + } + return writeSOPSEncryptedFile(path, ageKeyPath, plaintext) +} + +func WriteOperationalSecrets(path, ageKeyPath string, secrets map[string]map[string]string) error { + plaintext, err := yaml.Marshal(struct { + Secrets map[string]map[string]string `yaml:"secrets"` + }{Secrets: secrets}) + if err != nil { + return err + } + return writeSOPSEncryptedFile(path, ageKeyPath, plaintext) +} + +func NewWebhookAuthorization() (string, error) { + value := make([]byte, 32) + if _, err := rand.Read(value); err != nil { + return "", err + } + return "Bearer " + base64.RawURLEncoding.EncodeToString(value), nil +} + +func writeSOPSEncryptedFile(path, ageKeyPath string, plaintext []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + recipient, err := exec.Command("age-keygen", "-y", ageKeyPath).Output() + if err != nil { + return fmt.Errorf("derive SOPS age recipient: %w", err) + } + command := exec.Command("sops", "encrypt", "--age", strings.TrimSpace(string(recipient)), "--encrypted-regex", "^(data|stringData)$", "--input-type", "yaml", "--output-type", "yaml", "--filename-override", filepath.Base(path)) + command.Stdin = bytes.NewReader(plaintext) + encrypted, err := command.Output() + if err != nil { + return fmt.Errorf("encrypt Democratic CSI secret: %w", err) + } + return os.WriteFile(path, encrypted, 0600) +} + +func renderDemocraticCSISecret(csi config.DemocraticCSIConfig) ([]byte, error) { + return yaml.Marshal(struct { + APIVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + Metadata map[string]string `yaml:"metadata"` + Type string `yaml:"type"` + StringData map[string]string `yaml:"stringData"` + }{ + APIVersion: "v1", + Kind: "Secret", + Metadata: map[string]string{"name": "democratic-csi-secrets", "namespace": "democratic-storage"}, + Type: "Opaque", + StringData: map[string]string{ + "truenas-api-key": csi.TrueNASAPIKey, + "truenas-host": csi.TrueNASHost, + "target-portal": csi.TargetPortal, + "share-host": csi.ShareHost, + "dataset-parent-nfs": csi.DatasetParentNFS, + "dataset-snapshots-nfs": csi.DatasetSnapshotsNFS, + "allowed-networks": csi.AllowedNetworks, + "name-suffix": csi.NameSuffix, + "portal-group": csi.PortalGroup, + "initiator-group": csi.InitiatorGroup, + }, + }) +} + +func waitForWebhookTargets(dir string, cfg config.Config) error { + resources := []string{ + "secret/forgejo-webhook", + "deployment/el-" + cfg.Delivery.AppName, + "pipeline/" + cfg.Delivery.AppName, + } + for _, resource := range resources { + deadline := time.Now().Add(10 * time.Minute) + for time.Now().Before(deadline) { + if _, err := utils.RunCommandQuietOutputInDir(dir, "kubectl", "--kubeconfig=kubeconfig", "-n", "tekton-pipelines", "get", resource); err == nil { + break + } + time.Sleep(2 * time.Second) + } + if _, err := utils.RunCommandQuietOutputInDir(dir, "kubectl", "--kubeconfig=kubeconfig", "-n", "tekton-pipelines", "get", resource); err != nil { + return fmt.Errorf("wait for %s before registering Forgejo webhook", resource) + } + } + return nil +} + +func ensureClusterKustomizations(clusterDir string) error { path := filepath.Join(clusterDir, "kustomization.yaml") content, err := os.ReadFile(path) + if os.IsNotExist(err) { + // Existing Flux roots may intentionally use recursive discovery. Do not + // introduce a partial root Kustomization that could prune its resources. + return nil + } if err != nil { return err } updated := string(content) - for _, resource := range []string{"cilium-kustomization.yaml", "cilium-config-kustomization.yaml"} { + updated = strings.ReplaceAll(updated, " - bootstrap-secrets.sops.yaml\n", "") + for _, resource := range []string{"cilium-kustomization.yaml", "cilium-config-kustomization.yaml", "cicd-manifests-repo.yaml", "tekton-triggers-kustomization.yaml"} { if !strings.Contains(updated, resource) { updated += " - " + resource + "\n" } @@ -163,23 +361,67 @@ func ensureCiliumKustomizations(clusterDir string) error { return os.WriteFile(path, []byte(updated), 0644) } +func copyClusterTemplate(source, destination string) error { + entries, err := os.ReadDir(destination) + if err != nil && !os.IsNotExist(err) { + return err + } + if len(entries) == 0 { + return copyDir(source, destination, false) + } + if _, err := os.Stat(filepath.Join(destination, "kustomization.yaml")); err == nil { + return copyDir(source, destination, false) + } else if !os.IsNotExist(err) { + return err + } + return copyDirExcept(source, destination, false, map[string]bool{"kustomization.yaml": true}) +} + +func ensureManifestsKustomizations(dir string) error { + for _, environment := range []string{"previews", "staging", "production"} { + path := filepath.Join(dir, "apps", environment, "kustomization.yaml") + if _, err := os.Stat(path); err == nil { + continue + } else if !os.IsNotExist(err) { + return err + } + if err := os.WriteFile(path, []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n"), 0644); err != nil { + return err + } + } + return nil +} + func installCilium(dir string, cfg config.Config) error { helmDir := filepath.Join(dir, ".helm") if err := os.MkdirAll(helmDir, 0755); err != nil { return err } - return utils.RunCommandInDir(dir, "helm", "upgrade", "--install", "cilium", "cilium", "--repo=https://helm.cilium.io", "--version=1.19.6", "--repository-config="+filepath.Join(helmDir, "repositories.yaml"), "--repository-cache="+helmDir, "--namespace=kube-system", "--create-namespace", "--kubeconfig=kubeconfig", "--wait", "--timeout=5m", "--set=kubeProxyReplacement=true", "--set=ipam.mode=kubernetes", "--set=k8sServiceHost=localhost", "--set=k8sServicePort=7445", "--set=cgroup.autoMount.enabled=false", "--set=cgroup.hostRoot=/sys/fs/cgroup", "--set=bpf.hostLegacyRouting=true", "--set=securityContext.capabilities.ciliumAgent={CHOWN,KILL,NET_ADMIN,NET_RAW,IPC_LOCK,SYS_ADMIN,SYS_RESOURCE,DAC_OVERRIDE,FOWNER,SETGID,SETUID}", "--set=securityContext.capabilities.cleanCiliumState={NET_ADMIN,SYS_ADMIN,SYS_RESOURCE}", "--set=gatewayAPI.enabled=true", "--set=l2announcements.enabled=true", "--set=operator.replicas=1") + return utils.RunCommandInDir(dir, "helm", "upgrade", "--install", "cilium", "cilium", "--repo=https://helm.cilium.io", "--version=1.19.6", "--repository-config="+filepath.Join(helmDir, "repositories.yaml"), "--repository-cache="+helmDir, "--namespace=kube-system", "--create-namespace", "--kubeconfig=kubeconfig", "--wait", "--timeout=5m", "--set=kubeProxyReplacement=true", "--set=ipam.mode=kubernetes", "--set=k8sServiceHost=localhost", "--set=k8sServicePort=7445", "--set=cgroup.autoMount.enabled=false", "--set=cgroup.hostRoot=/sys/fs/cgroup", "--set=bpf.hostLegacyRouting=true", "--set=securityContext.capabilities.ciliumAgent={CHOWN,KILL,NET_ADMIN,NET_RAW,IPC_LOCK,SYS_ADMIN,SYS_RESOURCE,DAC_OVERRIDE,FOWNER,SETGID,SETUID}", "--set=securityContext.capabilities.cleanCiliumState={NET_ADMIN,SYS_ADMIN,SYS_RESOURCE}", "--set=gatewayAPI.enabled=false", "--set=l2announcements.enabled=true", "--set=operator.replicas=1") } func copyDir(source, destination string, overwrite bool) error { + return copyDirExcept(source, destination, overwrite, nil) +} + +func copyDirExcept(source, destination string, overwrite bool, excluded map[string]bool) error { return filepath.Walk(source, func(path string, info os.FileInfo, err error) error { if err != nil { return err } + if info.IsDir() && info.Name() == ".git" { + return filepath.SkipDir + } relative, err := filepath.Rel(source, path) if err != nil || relative == "." { return err } + if excluded[filepath.ToSlash(relative)] { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } target := filepath.Join(destination, relative) if info.IsDir() { return os.MkdirAll(target, 0755) @@ -191,25 +433,66 @@ func copyDir(source, destination string, overwrite bool) error { if err != nil { return err } - defer input.Close() flags := os.O_WRONLY | os.O_CREATE | os.O_EXCL if overwrite { flags = os.O_WRONLY | os.O_CREATE | os.O_TRUNC } output, err := os.OpenFile(target, flags, info.Mode()) if err != nil { + _ = input.Close() return err } - defer output.Close() - _, err = io.Copy(output, input) - return err + _, copyErr := io.Copy(output, input) + closeInputErr := input.Close() + closeOutputErr := output.Close() + if copyErr != nil { + return copyErr + } + if closeInputErr != nil { + return closeInputErr + } + return closeOutputErr }) } -func waitForTalosReboot(dir string, cfg config.Config) error { +func applyTalosConfigs(dir string, cfg config.Config) error { + for _, node := range cfg.Talos.Nodes { + configFile := filepath.Join("clusterconfig", fmt.Sprintf("%s-%s.yaml", cfg.Talos.Cluster.Name, node.Name)) + nodeAddress := node.Networks[0].IP + secureArgs := []string{"apply-config", "--talosconfig=./clusterconfig/talosconfig", "--nodes=" + nodeAddress, "--endpoints=" + cfg.Talos.BootstrapEndpoint, "--file=" + configFile} + if _, err := utils.RunCommandQuietOutputInDir(dir, "talosctl", "get", "machinestatus", "--talosconfig=./clusterconfig/talosconfig", "--nodes="+nodeAddress, "--endpoints="+cfg.Talos.BootstrapEndpoint, "--output=json"); err == nil { + if err := utils.RunCommandInDir(dir, "talosctl", secureArgs...); err != nil { + return fmt.Errorf("apply Talos config to %s: %w", node.Name, err) + } + } else { + if _, maintenanceErr := utils.RunCommandQuietOutputInDir(dir, "talosctl", "get", "machinestatus", "--insecure", "--nodes="+nodeAddress, "--endpoints="+nodeAddress, "--output=json"); maintenanceErr != nil { + return fmt.Errorf("determine Talos state for %s; refusing insecure takeover", node.Name) + } + if err := utils.RunCommandInDir(dir, "talosctl", "apply-config", "--insecure", "--nodes="+nodeAddress, "--endpoints="+nodeAddress, "--file="+configFile); err != nil { + return fmt.Errorf("apply initial Talos config to %s: %w", node.Name, err) + } + } + if err := waitForTalosReboot(dir, cfg, nodeAddress); err != nil { + return fmt.Errorf("wait for %s: %w", node.Name, err) + } + } + return nil +} + +func bootstrapEtcdIfNeeded(dir string, cfg config.Config) error { + if _, err := utils.RunCommandQuietOutputInDir(dir, "talosctl", "etcd", "status", "--talosconfig=./clusterconfig/talosconfig", "--endpoints="+cfg.Talos.BootstrapEndpoint, "--nodes="+cfg.Talos.BootstrapNode); err == nil { + return nil + } + if members, err := utils.RunCommandQuietOutputInDir(dir, "talosctl", "get", "members", "--talosconfig=./clusterconfig/talosconfig", "--endpoints="+cfg.Talos.BootstrapEndpoint, "--nodes="+cfg.Talos.BootstrapNode, "--output=json"); err == nil && len(strings.TrimSpace(string(members))) > 0 { + return errors.New("etcd members exist but etcd status is unavailable; refusing to bootstrap an existing cluster") + } + return utils.RunCommandInDir(dir, "talosctl", "bootstrap", "--talosconfig=./clusterconfig/talosconfig", "--endpoints="+cfg.Talos.BootstrapEndpoint, "--nodes="+cfg.Talos.BootstrapNode) +} + +func waitForTalosReboot(dir string, cfg config.Config, node string) error { deadline := time.Now().Add(5 * time.Minute) for time.Now().Before(deadline) { - output, err := utils.RunCommandQuietOutputInDir(dir, "talosctl", "get", "machinestatus", "--output=json", "--talosconfig=./clusterconfig/talosconfig", "--endpoints="+cfg.Talos.BootstrapEndpoint, "--nodes="+cfg.Talos.BootstrapNode) + output, err := utils.RunCommandQuietOutputInDir(dir, "talosctl", "get", "machinestatus", "--output=json", "--talosconfig=./clusterconfig/talosconfig", "--endpoints="+node, "--nodes="+node) if err == nil && strings.Contains(string(output), `"stage": "running"`) { return nil } @@ -218,6 +501,77 @@ func waitForTalosReboot(dir string, cfg config.Config) error { return fmt.Errorf("Talos API did not return after applying its machine configuration") } +type lifecycle struct { + ClusterID string `yaml:"clusterId"` + ClusterName string `yaml:"clusterName"` +} + +func ensureLifecycleIdentity(terraformDir string, cfg config.Config) error { + statePath := filepath.Join(terraformDir, "terraform.tfstate") + lifecyclePath := filepath.Join(terraformDir, ".maidn", "lifecycle.yaml") + data, err := os.ReadFile(lifecyclePath) + if err == nil { + var current lifecycle + if err := yaml.Unmarshal(data, ¤t); err != nil { + return fmt.Errorf("read lifecycle metadata: %w", err) + } + if current.ClusterID != cfg.ClusterID || current.ClusterName != cfg.Talos.Cluster.Name { + return fmt.Errorf("terraform checkout belongs to cluster %q; use its matching configuration", current.ClusterID) + } + return nil + } + if !os.IsNotExist(err) { + return err + } + if state, err := os.ReadFile(statePath); err == nil && !strings.Contains(string(state), cfg.Talos.Cluster.Name) { + return errors.New("terraform state exists but does not match clusterName; use an explicit recovery checkout") + } else if err != nil && !os.IsNotExist(err) { + return err + } + if err := os.MkdirAll(filepath.Dir(lifecyclePath), 0700); err != nil { + return err + } + data, err = yaml.Marshal(lifecycle{ClusterID: cfg.ClusterID, ClusterName: cfg.Talos.Cluster.Name}) + if err != nil { + return err + } + return os.WriteFile(lifecyclePath, data, 0600) +} + +func (r Runner) reconcileTerraform(terraformDir string) error { + environment := []string{"TF_VAR_proxmox_api_token=" + r.Config.Talos.Proxmox.APITokenID + "=" + r.Config.Talos.Proxmox.APITokenSecret} + if err := utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "init", "-input=false"); err != nil { + return err + } + if r.Mode == Rebuild { + if err := utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "destroy", "-auto-approve", "-input=false", "-target=proxmox_virtual_environment_vm.vm"); err != nil { + return err + } + } + planPath := filepath.Join(terraformDir, r.Config.ClusterID+".tfplan") + defer os.Remove(planPath) + if err := utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "plan", "-input=false", "-out="+planPath); err != nil { + return err + } + return utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "apply", "-input=false", "-auto-approve", planPath) +} + +func installSOPSKey(dir string, cfg config.Config) error { + if _, err := os.Stat(cfg.SOPS.AgeKeyPath); err != nil { + return fmt.Errorf("read SOPS age identity: %w", err) + } + manifest, err := utils.RunCommandOutputInDir(dir, "kubectl", "--kubeconfig=kubeconfig", "-n", "flux-system", "create", "secret", "generic", "sops-age", "--from-file=age.agekey="+cfg.SOPS.AgeKeyPath, "--dry-run=client", "-o", "yaml") + if err != nil { + return err + } + return utils.RunCommandInputInDir(dir, manifest, "kubectl", "--kubeconfig=kubeconfig", "apply", "-f", "-") +} + +func configureFluxSOPS(dir string) error { + patch := `{"spec":{"decryption":{"provider":"sops","secretRef":{"name":"sops-age"}}}}` + return utils.RunCommandInDir(dir, "kubectl", "--kubeconfig=kubeconfig", "-n", "flux-system", "patch", "kustomization", "flux-system", "--type=merge", "-p", patch) +} + func ensureRepo(dir, repoURL, ref string) error { if _, err := os.Stat(dir); os.IsNotExist(err) { return utils.RunCommand("git", "clone", "--branch", ref, repoURL, dir) @@ -233,54 +587,34 @@ func ensureRepo(dir, repoURL, ref string) error { func ensureTalosConfig(generatedDir string, cfg config.Config) error { talhelperPath := "talhelper" - secretPath := filepath.Join(generatedDir, "talsecret.sops.yaml") + secretPath := filepath.Join(generatedDir, "talsecret.yaml") if _, err := os.Stat(secretPath); os.IsNotExist(err) { - secrets, err := utils.RunCommandOutputInDir(generatedDir, talhelperPath, "gensecret") - if err != nil { - return err - } - if err := os.WriteFile(secretPath, secrets, 0600); err != nil { - return err - } - } - kubernetesVersion := cfg.Talos.Image.TalosVersion - talconfigPath := writeTalconfigWithKubernetesVersion(generatedDir, kubernetesVersion) - return utils.RunCommandInDir(generatedDir, talhelperPath, "genconfig", "--config-file", filepath.Base(talconfigPath), "--secret-file", filepath.Base(secretPath)) -} - -func writeTalconfigWithKubernetesVersion(generatedDir, kubernetesVersion string) string { - path := filepath.Join(generatedDir, "talconfig.yaml") - content, _ := os.ReadFile(path) - if strings.HasPrefix(kubernetesVersion, "v1.13.") { - kubernetesVersion = "v1.33.4" - } - text := string(content) - if strings.Contains(text, "\nkubernetesVersion:") { - start := strings.Index(text, "\nkubernetesVersion:") + 1 - end := strings.Index(text[start:], "\n") - if end == -1 { - text = text[:start] + "kubernetesVersion: " + kubernetesVersion + legacyPath := filepath.Join(generatedDir, "talsecret.sops.yaml") + if _, legacyErr := os.Stat(legacyPath); legacyErr == nil { + secretPath = legacyPath } else { - text = text[:start] + "kubernetesVersion: " + kubernetesVersion + text[start+end:] + secrets, err := utils.RunCommandOutputInDir(generatedDir, talhelperPath, "gensecret") + if err != nil { + return err + } + if err := os.WriteFile(secretPath, secrets, 0600); err != nil { + return err + } } - _ = os.WriteFile(path, []byte(text), 0644) - return path } - updated := strings.Replace(text, "\nendpoint:", "\nkubernetesVersion: "+kubernetesVersion+"\nendpoint:", 1) - _ = os.WriteFile(path, []byte(updated), 0644) - return path + return utils.RunCommandInDir(generatedDir, talhelperPath, "genconfig", "--config-file", "talconfig.yaml", "--secret-file", filepath.Base(secretPath)) } func renderTerraformTFVars(cfg config.Config) string { var builder strings.Builder builder.WriteString(fmt.Sprintf("proxmox_api_url = %q\n", cfg.Talos.Proxmox.APIURL)) builder.WriteString(fmt.Sprintf("proxmox_node = %q\n", cfg.Talos.Proxmox.DefaultNode)) - builder.WriteString(fmt.Sprintf("proxmox_api_token = %q\n", cfg.Talos.Proxmox.APITokenID+"="+cfg.Talos.Proxmox.APITokenSecret)) builder.WriteString(fmt.Sprintf("proxmox_pool = %q\n", cfg.Talos.Proxmox.Pool)) builder.WriteString(fmt.Sprintf("cluster_name = %q\n", cfg.Talos.Cluster.Name)) builder.WriteString(fmt.Sprintf("cluster_domain = %q\n", cfg.Talos.Cluster.Domain)) builder.WriteString(fmt.Sprintf("talos_factory_schematic_id = %q\n", cfg.Talos.Image.SchematicID)) builder.WriteString(fmt.Sprintf("talos_version = %q\n", cfg.Talos.Image.TalosVersion)) + builder.WriteString(fmt.Sprintf("kubernetes_version = %q\n", cfg.Talos.Image.KubernetesVersion)) builder.WriteString("cni_name = \"none\"\n") isoStorage := cfg.Talos.Image.Storage if isoStorage == cfg.Talos.Cluster.DiskStorage { @@ -296,16 +630,19 @@ func renderTerraformTFVars(cfg config.Config) string { builder.WriteString(fmt.Sprintf("dns_servers = [%s]\n", quoteList(cfg.Talos.Cluster.DNSServers))) builder.WriteString(fmt.Sprintf("control_plane_vip = %q\n", cfg.Talos.Cluster.ControlPlaneVIP)) builder.WriteString("node_interfaces = {\n") - for node, iface := range cfg.Talos.Proxmox.NodeInterfaces { + for _, node := range sortedKeys(cfg.Talos.Proxmox.NodeInterfaces) { + iface := cfg.Talos.Proxmox.NodeInterfaces[node] builder.WriteString(fmt.Sprintf(" %q = %q\n", node, iface)) } builder.WriteString("}\n") builder.WriteString("node_addresses = {\n") - for node, addr := range cfg.Talos.Proxmox.NodeAddresses { + for _, node := range sortedKeys(cfg.Talos.Proxmox.NodeAddresses) { + addr := cfg.Talos.Proxmox.NodeAddresses[node] builder.WriteString(fmt.Sprintf(" %q = %q\n", node, addr)) } builder.WriteString("}\n") builder.WriteString(fmt.Sprintf("create_vlan_interface = %t\n", cfg.Talos.Cluster.CreateVLANInterface)) + builder.WriteString(fmt.Sprintf("manage_network_bridges = %t\n", cfg.Talos.Cluster.ManageNetworkBridges)) builder.WriteString("image_cache_proxy = { enabled = false, ip = \"\", port = 3128 }\n") builder.WriteString("nodes = [\n") for _, node := range cfg.Talos.Nodes { @@ -349,3 +686,12 @@ func quoteList(values []string) string { } return strings.Join(quoted, ", ") } + +func sortedKeys(values map[string]string) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index bc21c3d..8a55396 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -2,6 +2,7 @@ package bootstrap import ( "os" + "os/exec" "path/filepath" "strings" "testing" @@ -27,3 +28,124 @@ func TestRenderCiliumConfig(t *testing.T) { t.Fatalf("Cilium configuration was not rendered: %s", content) } } + +func TestRenderDeliveryConfig(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "webhook.yaml") + if err := os.WriteFile(path, []byte("host: ${WEBHOOK_HOSTNAME}\npath: ${WEBHOOK_PATH}\n"), 0644); err != nil { + t.Fatal(err) + } + cfg := config.Config{Delivery: config.DeliveryConfig{WebhookHostname: "tekton.example.test", WebhookPath: "/hooks/forgejo"}} + if err := renderDeliveryConfig(dir, cfg); err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(content), "${") || !strings.Contains(string(content), "/hooks/forgejo") { + t.Fatalf("delivery configuration was not rendered: %s", content) + } +} + +func TestRenderDemocraticCSISecret(t *testing.T) { + secret, err := renderDemocraticCSISecret(config.DemocraticCSIConfig{TrueNASAPIKey: "api-key", TrueNASHost: "truenas.example.test", TargetPortal: "truenas.example.test:3260", ShareHost: "truenas.example.test", DatasetParentNFS: "pool/kubernetes/nfs/v", DatasetSnapshotsNFS: "pool/kubernetes/nfs/s", AllowedNetworks: "192.168.45.0/24", NameSuffix: "-test", PortalGroup: "1", InitiatorGroup: "1"}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(secret), "name: democratic-csi-secrets") || !strings.Contains(string(secret), "dataset-parent-nfs: pool/kubernetes/nfs/v") { + t.Fatalf("Democratic CSI secret was not rendered: %s", secret) + } +} + +func TestWriteDemocraticCSISecretEncryptsValues(t *testing.T) { + if _, err := exec.LookPath("age-keygen"); err != nil { + t.Skip("age-keygen is required for bootstrap encryption") + } + if _, err := exec.LookPath("sops"); err != nil { + t.Skip("sops is required for bootstrap encryption") + } + dir := t.TempDir() + identity := filepath.Join(dir, "age-key.txt") + if err := exec.Command("age-keygen", "-o", identity).Run(); err != nil { + t.Fatal(err) + } + secretPath := filepath.Join(dir, "secret.sops.yaml") + csi := config.DemocraticCSIConfig{TrueNASAPIKey: "test-api-key", TrueNASHost: "truenas.example.test", TargetPortal: "truenas.example.test:3260", ShareHost: "truenas.example.test", DatasetParentNFS: "pool/kubernetes/nfs/v", DatasetSnapshotsNFS: "pool/kubernetes/nfs/s", AllowedNetworks: "192.168.45.0/24", NameSuffix: "-test", PortalGroup: "1", InitiatorGroup: "1"} + if err := writeDemocraticCSISecret(secretPath, csi, identity); err != nil { + t.Fatal(err) + } + encrypted, err := os.ReadFile(secretPath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encrypted), csi.TrueNASAPIKey) || !strings.Contains(string(encrypted), "sops:") { + t.Fatal("Democratic CSI secret was not SOPS encrypted") + } +} + +func TestNewWebhookAuthorization(t *testing.T) { + authorization, err := NewWebhookAuthorization() + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(authorization, "Bearer ") || len(authorization) <= len("Bearer ") { + t.Fatalf("invalid webhook authorization") + } +} + +func TestCopyDirSkipsGitDirectory(t *testing.T) { + source := t.TempDir() + destination := t.TempDir() + if err := os.Mkdir(filepath.Join(source, ".git"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, ".git", "config"), []byte("private"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "README.md"), []byte("template"), 0644); err != nil { + t.Fatal(err) + } + if err := copyDir(source, destination, false); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(destination, ".git")); !os.IsNotExist(err) { + t.Fatal("copied template must not contain its Git metadata") + } + content, err := os.ReadFile(filepath.Join(destination, "README.md")) + if err != nil || string(content) != "template" { + t.Fatalf("template content was not copied: %q, %v", content, err) + } +} + +func TestEnsureManifestsKustomizations(t *testing.T) { + dir := t.TempDir() + for _, environment := range []string{"previews", "staging", "production"} { + if err := os.MkdirAll(filepath.Join(dir, "apps", environment), 0755); err != nil { + t.Fatal(err) + } + } + if err := ensureManifestsKustomizations(dir); err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(filepath.Join(dir, "apps", "previews", "kustomization.yaml")) + if err != nil || !strings.Contains(string(content), "resources:") { + t.Fatalf("preview Kustomization was not created: %q, %v", content, err) + } +} + +func TestRenderTerraformTFVarsIsStableAndRedactsToken(t *testing.T) { + cfg := config.Config{ + ClusterID: "test", + Talos: config.TalosConfig{ + Proxmox: config.TalosProxmoxConfig{APIURL: "https://proxmox.test", APITokenID: "id", APITokenSecret: "secret", NodeInterfaces: map[string]string{"b": "eno2", "a": "eno1"}}, + Cluster: config.TalosClusterConfig{Name: "test", Domain: "test", DiskStorage: "local", AdditionalStorage: "local"}, + Image: config.TalosImageConfig{TalosVersion: "v1.13.6", KubernetesVersion: "v1.33.4", SchematicID: "abcdefghijkl", Architecture: "amd64"}, + }, + } + first := renderTerraformTFVars(cfg) + second := renderTerraformTFVars(cfg) + if first != second || strings.Contains(first, "secret") || strings.Index(first, `"a"`) > strings.Index(first, `"b"`) { + t.Fatalf("Terraform rendering is not deterministic or redacted: %s", first) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 9a3fee1..5077e55 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,40 +1,78 @@ package config import ( + "bytes" "errors" + "fmt" + "io" "net/netip" "os" + "path/filepath" + "regexp" + "strings" "github.com/Pingu-Studio/MaidnCLI/internal/talos" "gopkg.in/yaml.v3" ) func Load(path string) (Config, error) { + cfg, err := LoadRaw(path) + if err != nil { + return cfg, err + } + return Resolve(cfg) +} + +func LoadRaw(path string) (Config, error) { var cfg Config data, err := os.ReadFile(path) if err != nil { return cfg, err } - if err := yaml.Unmarshal(data, &cfg); err != nil { + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + if err := decoder.Decode(&cfg); err != nil { return cfg, err } + if err := decoder.Decode(&Config{}); !errors.Is(err, io.EOF) { + return cfg, errors.New("bootstrap config must contain one YAML document") + } + return cfg, nil +} + +func Save(path string, cfg Config) error { + resolved, err := Resolve(cfg) + if err != nil { + return err + } + data, err := yaml.Marshal(resolved) + if err != nil { + return err + } + return os.WriteFile(path, data, 0600) +} + +func WriteRedacted(path string, cfg Config) error { + redacted := cfg + redacted.Git.Token = "" + redacted.Talos.Proxmox.APITokenSecret = "" + redacted.DemocraticCSI.TrueNASAPIKey = "" + data, err := yaml.Marshal(redacted) + if err != nil { + return err + } + return os.WriteFile(path, data, 0600) +} + +func Resolve(cfg Config) (Config, error) { applyDefaults(&cfg) return cfg, Validate(cfg) } -func Save(path string, cfg Config) error { - applyDefaults(&cfg) - if err := Validate(cfg); err != nil { - return err - } - data, err := yaml.Marshal(cfg) - if err != nil { - return err - } - return os.WriteFile(path, data, 0644) -} - func applyDefaults(cfg *Config) { + if cfg.ClusterID == "" { + cfg.ClusterID = cfg.Talos.Cluster.Name + } if cfg.Git.Provider == "" { cfg.Git.Provider = "forgejo" } @@ -65,9 +103,39 @@ func applyDefaults(cfg *Config) { if cfg.Templates.CICDRepoRef == "" { cfg.Templates.CICDRepoRef = "main" } + if cfg.Templates.ManifestsRepoURL == "" { + cfg.Templates.ManifestsRepoURL = "https://git.pingu.pw/Maidn/cicd-deployment-manifests-template.git" + } + if cfg.Templates.ManifestsRepoRef == "" { + cfg.Templates.ManifestsRepoRef = "main" + } if cfg.Cilium.TrafficInterface == "" { cfg.Cilium.TrafficInterface = "eth1" } + if cfg.DemocraticCSI.PortalGroup == "" { + cfg.DemocraticCSI.PortalGroup = "1" + } + if cfg.DemocraticCSI.InitiatorGroup == "" { + cfg.DemocraticCSI.InitiatorGroup = "1" + } + if cfg.Delivery.AppName == "" { + cfg.Delivery.AppName = "easycsr-frontend" + } + if cfg.Delivery.AppRepoURL == "" { + cfg.Delivery.AppRepoURL = strings.TrimRight(cfg.Git.BaseURL, "/") + "/" + cfg.Git.Owner + "/" + cfg.Delivery.AppName + ".git" + } + if cfg.Delivery.AppRepoRef == "" { + cfg.Delivery.AppRepoRef = cfg.Flux.Branch + } + if cfg.Delivery.ImageRepository == "" { + cfg.Delivery.ImageRepository = strings.TrimPrefix(strings.TrimPrefix(cfg.Git.BaseURL, "https://"), "http://") + "/" + strings.ToLower(cfg.Git.Owner) + "/" + cfg.Delivery.AppName + } + if cfg.Delivery.WebhookHostname == "" && cfg.Flux.ClusterDomain != "" { + cfg.Delivery.WebhookHostname = "tekton." + cfg.Flux.ClusterDomain + } + if cfg.Delivery.WebhookPath == "" { + cfg.Delivery.WebhookPath = "/" + } if cfg.Talos.RepoDirName == "" { cfg.Talos.RepoDirName = "maidn-talos-proxmox" } @@ -89,6 +157,21 @@ func applyDefaults(cfg *Config) { if cfg.Talos.Image.Architecture == "" { cfg.Talos.Image.Architecture = "amd64" } + if cfg.Talos.Image.KubernetesVersion == "" && strings.HasPrefix(cfg.Talos.Image.TalosVersion, "v1.13.") { + cfg.Talos.Image.KubernetesVersion = "v1.33.4" + } + if cfg.SOPS.AgeKeyPath == "" && cfg.WorkspaceDir != "" { + cfg.SOPS.AgeKeyPath = filepath.Join(cfg.WorkspaceDir, ".age", "key.txt") + } + if cfg.SOPS.OperationalSecretsPath == "" && cfg.WorkspaceDir != "" { + cfg.SOPS.OperationalSecretsPath = filepath.Join(cfg.WorkspaceDir, "operational-secrets.sops.yaml") + } + if cfg.SOPS.RecoveryIdentityPath == "" && cfg.WorkspaceDir != "" { + cfg.SOPS.RecoveryIdentityPath = filepath.Join(cfg.WorkspaceDir, ".age", "recovery-key.txt") + } + if cfg.SOPS.RecoveryBundlePath == "" && cfg.WorkspaceDir != "" { + cfg.SOPS.RecoveryBundlePath = filepath.Join(cfg.WorkspaceDir, ".recovery", "openbao-recovery.age") + } if cfg.Talos.BootstrapEndpoint == "" { cfg.Talos.BootstrapEndpoint = cfg.Talos.BootstrapNode } @@ -98,17 +181,35 @@ func applyDefaults(cfg *Config) { } func Validate(cfg Config) error { + if !regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`).MatchString(cfg.ClusterID) { + return errors.New("clusterId must be a lowercase DNS label") + } if cfg.WorkspaceDir == "" { return errors.New("workspaceDir is required") } + if cfg.Git.Provider != "forgejo" { + return errors.New("git provider must be forgejo") + } if cfg.Git.BaseURL == "" || cfg.Git.Username == "" || cfg.Git.Owner == "" { return errors.New("git baseUrl, username, and owner are required") } - if cfg.Flux.RepoName == "" || cfg.Flux.ClusterPath == "" || cfg.Flux.ManifestsRepo == "" { - return errors.New("flux repoName, manifestsRepo, and clusterPath are required") + if cfg.Git.Token == "" { + return errors.New("git token is required; SSH bootstrap is not implemented") } - if cfg.Templates.TalosRepoURL == "" || cfg.Talos.RepoDirName == "" { - return errors.New("talos template repo and checkout dir are required") + if cfg.Flux.RepoName == "" || cfg.Flux.ClusterPath == "" || cfg.Flux.ManifestsRepo == "" || cfg.Flux.ClusterDomain == "" { + return errors.New("flux repoName, clusterDomain, manifestsRepo, and clusterPath are required") + } + if cfg.Delivery.AppName == "" || cfg.Delivery.AppRepoURL == "" || cfg.Delivery.AppRepoRef == "" || cfg.Delivery.ImageRepository == "" || cfg.Delivery.WebhookHostname == "" || cfg.Delivery.WebhookPath == "" { + return errors.New("delivery appName, appRepoUrl, appRepoRef, imageRepository, webhookHostname, and webhookPath are required") + } + if strings.ContainsAny(cfg.Delivery.WebhookHostname, "/:@?#") || !strings.HasPrefix(cfg.Delivery.WebhookPath, "/") || strings.ContainsAny(cfg.Delivery.WebhookPath, "?#") { + return errors.New("delivery webhookHostname must be a hostname and webhookPath must be an absolute path") + } + if cfg.Templates.TalosRepoURL == "" || cfg.Templates.TalosRepoRef == "" || cfg.Templates.CICDRepoURL == "" || cfg.Templates.CICDRepoRef == "" || cfg.Templates.ManifestsRepoURL == "" || cfg.Templates.ManifestsRepoRef == "" { + return errors.New("all template repository URLs and refs are required") + } + if cfg.Talos.RepoDirName == "" { + return errors.New("talos checkout dir is required") } if cfg.Talos.TerraformDir == "" || cfg.Talos.GeneratedDir == "" || cfg.Talos.ConfigFileName == "" { return errors.New("talos terraformDir, generatedDir, and configFileName are required") @@ -119,11 +220,11 @@ func Validate(cfg Config) error { if cfg.Talos.Cluster.Name == "" || cfg.Talos.Cluster.Domain == "" { return errors.New("talos cluster name and domain are required") } - if cfg.Talos.Image.TalosVersion == "" || cfg.Talos.Image.SchematicID == "" { - return errors.New("talos version and schematicId are required") + if cfg.Talos.Image.TalosVersion == "" || cfg.Talos.Image.KubernetesVersion == "" || cfg.Talos.Image.SchematicID == "" { + return errors.New("talos version, kubernetesVersion, and schematicId are required") } - if err := talos.RequireCLICompatibility(cfg.Talos.Image.TalosVersion); err != nil { - return err + if len(cfg.Talos.Image.SchematicID) < 12 { + return errors.New("talos schematicId must contain at least 12 characters") } if cfg.Talos.Image.UpdateMode != "manual" && cfg.Talos.Image.UpdateMode != "download" { return errors.New("talos image updateMode must be manual or download") @@ -142,22 +243,85 @@ func Validate(cfg Config) error { if err != nil || start.BitLen() != end.BitLen() || start.Compare(end) > 0 { return errors.New("cilium loadBalancerEnd must be an IP address after loadBalancerStart") } - for _, node := range cfg.Talos.Nodes { + if cfg.DemocraticCSI.TrueNASAPIKey == "" || cfg.DemocraticCSI.TrueNASHost == "" || cfg.DemocraticCSI.TargetPortal == "" || cfg.DemocraticCSI.ShareHost == "" || cfg.DemocraticCSI.DatasetParentNFS == "" || cfg.DemocraticCSI.DatasetSnapshotsNFS == "" || cfg.DemocraticCSI.AllowedNetworks == "" || cfg.DemocraticCSI.NameSuffix == "" || cfg.DemocraticCSI.PortalGroup == "" || cfg.DemocraticCSI.InitiatorGroup == "" { + return errors.New("all democraticCsi settings are required") + } + if _, err := netip.ParsePrefix(cfg.DemocraticCSI.AllowedNetworks); err != nil { + return errors.New("democraticCsi allowedNetworks must be a CIDR") + } + seenNames := map[string]bool{} + seenVMIDs := map[int]bool{} + seenIPs := map[netip.Addr]bool{} + seenMACs := map[string]bool{} + controlPlanes := 0 + trafficVLAN := 0 + for index, node := range cfg.Talos.Nodes { + if node.Name == "" || seenNames[node.Name] || node.VMID <= 0 || seenVMIDs[node.VMID] { + return errors.New("talos node names and VMIDs must be unique and non-zero") + } + seenNames[node.Name] = true + seenVMIDs[node.VMID] = true + if node.Role != "controlplane" && node.Role != "worker" { + return fmt.Errorf("talos node %q role must be controlplane or worker", node.Name) + } + if node.Role == "controlplane" { + controlPlanes++ + } + if index == 0 && node.Role != "controlplane" { + return errors.New("the first talos node must be a controlplane") + } if len(node.Networks) < 2 { return errors.New("cilium requires a second static traffic network on every talos node") } + primary := node.Networks[0] + primaryIP, err := netip.ParseAddr(primary.IP) + if err != nil || primary.Gateway == "" { + return errors.New("talos primary networks require a static IP and gateway") + } + if seenIPs[primaryIP] { + return errors.New("talos node IPs must be unique") + } + seenIPs[primaryIP] = true traffic := node.Networks[1] prefix, err := netip.ParsePrefix(traffic.CIDR) if err != nil || traffic.IP == "" || traffic.Gateway != "" { return errors.New("cilium traffic networks require a static IP, valid CIDR, and no gateway") } nodeIP, err := netip.ParseAddr(traffic.IP) - if err != nil || !prefix.Contains(nodeIP) || nodeIP == start || nodeIP == end { + if err != nil || !prefix.Contains(nodeIP) || (nodeIP.Compare(start) >= 0 && nodeIP.Compare(end) <= 0) { return errors.New("cilium traffic node IP must belong to its CIDR and not use the LoadBalancer range") } + if seenIPs[nodeIP] { + return errors.New("talos node IPs must be unique") + } + seenIPs[nodeIP] = true + if trafficVLAN == 0 { + trafficVLAN = traffic.VLANID + } else if trafficVLAN != traffic.VLANID { + return errors.New("cilium traffic networks must use one VLAN") + } if !prefix.Contains(start) || !prefix.Contains(end) { return errors.New("cilium LoadBalancer range must belong to every node traffic network") } + for _, network := range node.Networks { + if network.VLANID < 1 || network.VLANID > 4094 { + return errors.New("talos VLAN IDs must be between 1 and 4094") + } + if network.MACAddress != "" { + mac := strings.ToLower(network.MACAddress) + if seenMACs[mac] { + return errors.New("talos network MAC addresses must be unique") + } + seenMACs[mac] = true + } + } + } + if controlPlanes == 0 { + return errors.New("at least one controlplane node is required") } return nil } + +func Preflight(cfg Config) error { + return talos.RequireCLICompatibility(cfg.Talos.Image.TalosVersion) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..70834d7 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,81 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func validConfig(t *testing.T) Config { + t.Helper() + return Config{ + ClusterID: "test-cluster", + WorkspaceDir: t.TempDir(), + Git: GitConfig{Provider: "forgejo", BaseURL: "https://git.example.test", Username: "bot", Token: "token", Owner: "test-org"}, + Flux: FluxConfig{RepoName: "cluster", Branch: "main", ClusterPath: "./clusters/test", ClusterDomain: "example.test", ManifestsRepo: "manifests"}, + Templates: TemplateConfig{TalosRepoURL: "https://git.example.test/talos.git", TalosRepoRef: "main", CICDRepoURL: "https://git.example.test/template.git", CICDRepoRef: "main", ManifestsRepoURL: "https://git.example.test/manifests.git", ManifestsRepoRef: "main"}, + Cilium: CiliumConfig{TrafficInterface: "eth1", LoadBalancerStart: "192.168.45.19", LoadBalancerEnd: "192.168.45.30"}, + DemocraticCSI: DemocraticCSIConfig{TrueNASAPIKey: "api-key", TrueNASHost: "truenas.example.test", TargetPortal: "truenas.example.test:3260", ShareHost: "truenas.example.test", DatasetParentNFS: "pool/kubernetes/nfs/v", DatasetSnapshotsNFS: "pool/kubernetes/nfs/s", AllowedNetworks: "192.168.45.0/24", NameSuffix: "-test", PortalGroup: "1", InitiatorGroup: "1"}, + Talos: TalosConfig{ + RepoDirName: "talos", TerraformDir: "terraform", GeneratedDir: "generated", ConfigFileName: "terraform.tfvars", + Proxmox: TalosProxmoxConfig{APIURL: "https://proxmox.example.test:8006", APITokenID: "id", APITokenSecret: "secret"}, + Cluster: TalosClusterConfig{Name: "test-cluster", Domain: "example.test"}, + Image: TalosImageConfig{TalosVersion: "v1.13.6", KubernetesVersion: "v1.33.4", SchematicID: "abcdefghijkl"}, + Nodes: []TalosNode{{Name: "cp-01", VMID: 100, Role: "controlplane", Networks: []TalosNetwork{{IP: "192.168.45.3", CIDR: "192.168.45.0/28", Gateway: "192.168.45.1", VLANID: 45}, {IP: "192.168.45.18", CIDR: "192.168.45.16/28", VLANID: 451}}}}, + }, + } +} + +func TestResolveDefaultsWebhookEndpoint(t *testing.T) { + cfg, err := Resolve(validConfig(t)) + if err != nil { + t.Fatal(err) + } + if cfg.Delivery.WebhookURL() != "https://tekton.example.test/" { + t.Fatalf("WebhookURL() = %q", cfg.Delivery.WebhookURL()) + } +} + +func TestLoadRejectsUnknownFields(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte("workspaceDir: test\nunknown: value\n"), 0600); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil || !strings.Contains(err.Error(), "field unknown") { + t.Fatalf("Load() error = %v, want unknown field error", err) + } +} + +func TestLoadRawAllowsCompletionBeforeValidation(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte("workspaceDir: test\n"), 0600); err != nil { + t.Fatal(err) + } + if _, err := LoadRaw(path); err != nil { + t.Fatalf("LoadRaw() error = %v", err) + } +} + +func TestWriteRedactedOmitsCredentials(t *testing.T) { + cfg := validConfig(t) + path := filepath.Join(t.TempDir(), "resolved.yaml") + if err := WriteRedacted(path, cfg); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "token: token") || strings.Contains(string(data), "apiTokenSecret: secret") || strings.Contains(string(data), "truenasApiKey: api-key") { + t.Fatalf("redacted config contains credentials: %s", data) + } +} + +func TestValidateRejectsLoadBalancerAddressOnNode(t *testing.T) { + cfg := validConfig(t) + cfg.Talos.Nodes[0].Networks[1].IP = cfg.Cilium.LoadBalancerStart + if _, err := Resolve(cfg); err == nil { + t.Fatal("Resolve() succeeded with traffic node address in LoadBalancer range") + } +} diff --git a/internal/config/types.go b/internal/config/types.go index be9dfe1..ee14c2f 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -3,12 +3,51 @@ package config import "net/url" type Config struct { - WorkspaceDir string `yaml:"workspaceDir"` - Git GitConfig `yaml:"git"` - Flux FluxConfig `yaml:"flux"` - Talos TalosConfig `yaml:"talos"` - Templates TemplateConfig `yaml:"templates"` - Cilium CiliumConfig `yaml:"cilium"` + ClusterID string `yaml:"clusterId"` + WorkspaceDir string `yaml:"workspaceDir"` + Git GitConfig `yaml:"git"` + Flux FluxConfig `yaml:"flux"` + Talos TalosConfig `yaml:"talos"` + Templates TemplateConfig `yaml:"templates"` + Cilium CiliumConfig `yaml:"cilium"` + DemocraticCSI DemocraticCSIConfig `yaml:"democraticCsi"` + Delivery DeliveryConfig `yaml:"delivery"` + SOPS SOPSConfig `yaml:"sops"` +} + +type DemocraticCSIConfig struct { + TrueNASAPIKey string `yaml:"truenasApiKey"` + TrueNASHost string `yaml:"truenasHost"` + TargetPortal string `yaml:"targetPortal"` + ShareHost string `yaml:"shareHost"` + DatasetParentNFS string `yaml:"datasetParentNfs"` + DatasetSnapshotsNFS string `yaml:"datasetSnapshotsNfs"` + AllowedNetworks string `yaml:"allowedNetworks"` + NameSuffix string `yaml:"nameSuffix"` + PortalGroup string `yaml:"portalGroup"` + InitiatorGroup string `yaml:"initiatorGroup"` +} + +type DeliveryConfig struct { + AppName string `yaml:"appName"` + AppRepoURL string `yaml:"appRepoUrl"` + AppRepoRef string `yaml:"appRepoRef"` + ImageRepository string `yaml:"imageRepository"` + WebhookHostname string `yaml:"webhookHostname"` + WebhookPath string `yaml:"webhookPath"` +} + +func (c DeliveryConfig) WebhookURL() string { + return (&url.URL{Scheme: "https", Host: c.WebhookHostname, Path: c.WebhookPath}).String() +} + +type SOPSConfig struct { + AgeKeyPath string `yaml:"ageKeyPath"` + BootstrapSecretsPath string `yaml:"bootstrapSecretsPath,omitempty"` + OperationalSecretsPath string `yaml:"operationalSecretsPath"` + RecoveryRecipient string `yaml:"recoveryRecipient"` + RecoveryIdentityPath string `yaml:"recoveryIdentityPath"` + RecoveryBundlePath string `yaml:"recoveryBundlePath"` } type GitConfig struct { @@ -31,10 +70,12 @@ type FluxConfig struct { } type TemplateConfig struct { - TalosRepoURL string `yaml:"talosRepoUrl"` - TalosRepoRef string `yaml:"talosRepoRef"` - CICDRepoURL string `yaml:"cicdRepoUrl"` - CICDRepoRef string `yaml:"cicdRepoRef"` + TalosRepoURL string `yaml:"talosRepoUrl"` + TalosRepoRef string `yaml:"talosRepoRef"` + CICDRepoURL string `yaml:"cicdRepoUrl"` + CICDRepoRef string `yaml:"cicdRepoRef"` + ManifestsRepoURL string `yaml:"manifestsRepoUrl"` + ManifestsRepoRef string `yaml:"manifestsRepoRef"` } type CiliumConfig struct { @@ -73,21 +114,23 @@ type TalosProxmoxConfig struct { } type TalosClusterConfig struct { - Name string `yaml:"name"` - Domain string `yaml:"domain"` - ControlPlaneVIP string `yaml:"controlPlaneVip"` - DNSServers []string `yaml:"dnsServers"` - DiskStorage string `yaml:"diskStorage"` - AdditionalStorage string `yaml:"additionalStorage"` - CreateVLANInterface bool `yaml:"createVlanInterface"` + Name string `yaml:"name"` + Domain string `yaml:"domain"` + ControlPlaneVIP string `yaml:"controlPlaneVip"` + DNSServers []string `yaml:"dnsServers"` + DiskStorage string `yaml:"diskStorage"` + AdditionalStorage string `yaml:"additionalStorage"` + CreateVLANInterface bool `yaml:"createVlanInterface"` + ManageNetworkBridges bool `yaml:"manageNetworkBridges"` } type TalosImageConfig struct { - TalosVersion string `yaml:"talosVersion"` - SchematicID string `yaml:"schematicId"` - UpdateMode string `yaml:"updateMode"` - Storage string `yaml:"storage"` - Architecture string `yaml:"architecture"` + TalosVersion string `yaml:"talosVersion"` + KubernetesVersion string `yaml:"kubernetesVersion"` + SchematicID string `yaml:"schematicId"` + UpdateMode string `yaml:"updateMode"` + Storage string `yaml:"storage"` + Architecture string `yaml:"architecture"` } type TalosNode struct { diff --git a/internal/forgejo/repo.go b/internal/forgejo/repo.go index d060273..7dcc0ce 100644 --- a/internal/forgejo/repo.go +++ b/internal/forgejo/repo.go @@ -8,10 +8,9 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "time" - - "github.com/Pingu-Studio/MaidnCLI/internal/utils" ) type RepoManager struct { @@ -21,17 +20,49 @@ type RepoManager struct { Username string ManifestsRepoName string FluxRepoName string + Branch string + MigrationBranch string + MigrationPending bool + HTTPClient *http.Client } type createRepoRequest struct { - Name string `json:"name"` - Description string `json:"description"` - Private bool `json:"private"` - AutoInit bool `json:"auto_init"` + Name string `json:"name"` + Description string `json:"description"` + Private bool `json:"private"` + AutoInit bool `json:"auto_init"` DefaultBranch string `json:"default_branch"` } -func NewRepoManager(baseURL, token, owner, username, manifestsRepo, fluxRepo string) *RepoManager { +type pullRequestRequest struct { + Title string `json:"title"` + Head string `json:"head"` + Base string `json:"base"` +} + +type hook struct { + ID int64 `json:"id"` + URL string `json:"url"` +} + +type hookRequest struct { + Type string `json:"type,omitempty"` + Active bool `json:"active"` + AuthorizationHeader string `json:"authorization_header"` + Config map[string]string `json:"config"` + Events []string `json:"events"` +} + +type APIError struct { + StatusCode int + Status string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("forgejo returned %s", e.Status) +} + +func NewRepoManager(baseURL, token, owner, username, manifestsRepo, fluxRepo, branch, migrationBranch string) *RepoManager { return &RepoManager{ BaseURL: strings.TrimRight(baseURL, "/"), Token: token, @@ -39,6 +70,9 @@ func NewRepoManager(baseURL, token, owner, username, manifestsRepo, fluxRepo str Username: username, ManifestsRepoName: manifestsRepo, FluxRepoName: fluxRepo, + Branch: branch, + MigrationBranch: migrationBranch, + HTTPClient: &http.Client{Timeout: 15 * time.Second}, } } @@ -46,83 +80,240 @@ func (rm *RepoManager) InitializeAll(createRepo func(string) error, createFlux f if err := rm.ensureRepo(rm.ManifestsRepoName, "Centralized deployment manifests for Flux CD", createRepo); err != nil { return err } - if err := rm.ensureRepo(rm.FluxRepoName, "Flux CD cluster configurations", createFlux); err != nil { + return rm.ensureRepo(rm.FluxRepoName, "Flux CD cluster configurations", createFlux) +} + +func (rm *RepoManager) ensureRepo(name, description string, createStructure func(string) error) error { + exists, err := rm.repoExists(name) + if err != nil { return err } + if !exists { + if err := rm.createRepo(name, description); err != nil { + return err + } + } + return rm.setupRepository(CloneURL(rm.BaseURL, rm.Owner, name), name, exists, createStructure) +} + +func (rm *RepoManager) repoExists(name string) (bool, error) { + status, err := rm.apiRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/repos/%s/%s", rm.BaseURL, rm.Owner, name), nil) + if err != nil { + return false, err + } + switch status { + case http.StatusOK: + return true, nil + case http.StatusNotFound: + return false, nil + default: + return false, fmt.Errorf("unexpected Forgejo repository lookup status %d", status) + } +} + +func (rm *RepoManager) createRepo(name, description string) error { + createURL := fmt.Sprintf("%s/api/v1/orgs/%s/repos", rm.BaseURL, rm.Owner) + if rm.Owner == rm.Username { + createURL = fmt.Sprintf("%s/api/v1/user/repos", rm.BaseURL) + } + body, err := json.Marshal(createRepoRequest{Name: name, Description: description, Private: true, AutoInit: true, DefaultBranch: rm.Branch}) + if err != nil { + return err + } + status, err := rm.apiRequest(http.MethodPost, createURL, body) + if err != nil { + return err + } + if status != http.StatusCreated && status != http.StatusConflict { + return fmt.Errorf("unexpected Forgejo create repository status %d", status) + } return nil } -func (rm *RepoManager) ensureRepo(name, description string, createStructure func(string) error) error { - repoURL := fmt.Sprintf("%s/%s/%s.git", rm.BaseURL, rm.Owner, name) - apiURL := fmt.Sprintf("%s/api/v1/repos/%s/%s", rm.BaseURL, rm.Owner, name) - if err := rm.apiRequest(http.MethodGet, apiURL, nil); err != nil { - createURL := fmt.Sprintf("%s/api/v1/orgs/%s/repos", rm.BaseURL, rm.Owner) - if rm.Owner == rm.Username { - createURL = fmt.Sprintf("%s/api/v1/user/repos", rm.BaseURL) - } - body, _ := json.Marshal(createRepoRequest{Name: name, Description: description, Private: true, AutoInit: false, DefaultBranch: "main"}) - if apiErr := rm.apiRequest(http.MethodPost, createURL, body); apiErr != nil { - return apiErr - } - } - return rm.setupRepository(repoURL, name, createStructure) -} - -func (rm *RepoManager) setupRepository(repoURL, repoName string, createStructure func(string) error) error { +func (rm *RepoManager) setupRepository(repoURL, repoName string, existing bool, createStructure func(string) error) error { tempDir, err := os.MkdirTemp("", "repo-setup-*") if err != nil { return err } defer os.RemoveAll(tempDir) - if err := utils.RunCommand("git", "clone", repoURL, tempDir); err != nil { + cleanupAskPass, environment, err := rm.gitEnvironment(tempDir) + if err != nil { return err } - if err := utils.RunCommandInDir(tempDir, "git", "checkout", "-B", "main"); err != nil { + defer cleanupAskPass() + if err := runGit("", environment, "clone", "--branch", rm.Branch, repoURL, tempDir); err != nil { return err } + targetBranch := rm.Branch + if existing { + targetBranch = rm.MigrationBranch + if err := runGit(tempDir, environment, "checkout", "-B", targetBranch, "origin/"+rm.Branch); err != nil { + return err + } + } if err := createStructure(tempDir); err != nil { return err } - return commitAndPush(tempDir, repoName) + changed, err := commitAndPush(tempDir, repoName, targetBranch, environment) + if err != nil || !changed || !existing { + return err + } + rm.MigrationPending = true + return rm.createMigrationPullRequest(repoName, targetBranch) } -func commitAndPush(tempDir, repoName string) error { - utils.RunCommandInDir(tempDir, "git", "config", "user.name", "Maidn") - utils.RunCommandInDir(tempDir, "git", "config", "user.email", "maidn@free-maidn.com") - utils.RunCommandInDir(tempDir, "git", "add", ".") - statusCmd := exec.Command("git", "status", "--porcelain") - statusCmd.Dir = tempDir - output, _ := statusCmd.Output() - if len(output) == 0 { - return nil - } - if err := utils.RunCommandInDir(tempDir, "git", "commit", "-m", "feat: initialize repository structure for CI/CD"); err != nil { +func (rm *RepoManager) createMigrationPullRequest(repo, branch string) error { + body, err := json.Marshal(pullRequestRequest{Title: "feat: bootstrap Maidn CI/CD structure", Head: branch, Base: rm.Branch}) + if err != nil { return err } - if err := utils.RunCommandInDir(tempDir, "git", "push", "origin", "main"); err != nil { + status, err := rm.apiRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls", rm.BaseURL, rm.Owner, repo), body) + if err != nil { return err } - fmt.Printf("[SUCCESS] Changes pushed to %s successfully.\n", repoName) + if status != http.StatusCreated && status != http.StatusUnprocessableEntity { + return fmt.Errorf("unexpected Forgejo pull request status %d", status) + } return nil } -func (rm *RepoManager) apiRequest(method, endpoint string, body []byte) error { - client := http.Client{Timeout: 15 * time.Second} - request, err := http.NewRequest(method, endpoint, bytes.NewReader(body)) +func (rm *RepoManager) EnsureWebhook(repo, webhookURL, authorization string) error { + if webhookURL == "" || authorization == "" { + return fmt.Errorf("Forgejo webhook URL and authorization are required") + } + endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/hooks", rm.BaseURL, rm.Owner, repo) + var hooks []hook + status, err := rm.apiJSONRequest(http.MethodGet, endpoint, nil, &hooks) if err != nil { return err } + if status != http.StatusOK { + return fmt.Errorf("unexpected Forgejo webhook lookup status %d", status) + } + createRequest := hookRequest{ + Type: "forgejo", + Active: true, + AuthorizationHeader: authorization, + Config: map[string]string{"url": webhookURL, "content_type": "json"}, + Events: []string{"push", "pull_request"}, + } + for _, existing := range hooks { + if existing.URL != webhookURL { + continue + } + request, err := json.Marshal(hookRequest{Active: createRequest.Active, AuthorizationHeader: createRequest.AuthorizationHeader, Config: createRequest.Config, Events: createRequest.Events}) + if err != nil { + return err + } + status, err = rm.apiRequest(http.MethodPatch, fmt.Sprintf("%s/%d", endpoint, existing.ID), request) + if err != nil { + return err + } + if status != http.StatusOK { + return fmt.Errorf("unexpected Forgejo webhook update status %d", status) + } + return nil + } + request, err := json.Marshal(createRequest) + if err != nil { + return err + } + status, err = rm.apiRequest(http.MethodPost, endpoint, request) + if err != nil { + return err + } + if status != http.StatusCreated { + return fmt.Errorf("unexpected Forgejo webhook create status %d", status) + } + return nil +} + +func commitAndPush(tempDir, repoName, branch string, environment []string) (bool, error) { + for _, args := range [][]string{{"config", "user.name", "Maidn"}, {"config", "user.email", "maidn@free-maidn.com"}, {"add", "."}} { + if err := runGit(tempDir, environment, args...); err != nil { + return false, err + } + } + cmd := exec.Command("git", "status", "--porcelain") + cmd.Dir = tempDir + cmd.Env = environment + output, err := cmd.Output() + if err != nil { + return false, err + } + if len(output) == 0 { + return false, nil + } + if err := runGit(tempDir, environment, "commit", "-m", "feat: initialize repository structure for CI/CD"); err != nil { + return false, err + } + if err := runGit(tempDir, environment, "push", "origin", "HEAD:"+branch); err != nil { + return false, err + } + fmt.Printf("[SUCCESS] Changes pushed to %s successfully.\n", repoName) + return true, nil +} + +func (rm *RepoManager) gitEnvironment(tempDir string) (func(), []string, error) { + if rm.Token == "" { + return func() {}, append(os.Environ(), "GIT_TERMINAL_PROMPT=0"), nil + } + askPassDir, err := os.MkdirTemp("", "maidn-askpass-*") + if err != nil { + return nil, nil, err + } + path := filepath.Join(askPassDir, "maidn-askpass") + content := "#!/bin/sh\ncase \"$1\" in *Username*) printf '%s\\n' \"$MAIDN_GIT_USERNAME\" ;; *) printf '%s\\n' \"$MAIDN_GIT_TOKEN\" ;; esac\n" + if runtime.GOOS == "windows" { + path += ".cmd" + content = "@echo off\r\necho %~1 | findstr /I Username >nul\r\nif not errorlevel 1 (echo %MAIDN_GIT_USERNAME%) else (echo %MAIDN_GIT_TOKEN%)\r\n" + } + if err := os.WriteFile(path, []byte(content), 0700); err != nil { + _ = os.RemoveAll(askPassDir) + return nil, nil, err + } + environment := append(os.Environ(), "GIT_ASKPASS="+path, "GIT_TERMINAL_PROMPT=0", "MAIDN_GIT_USERNAME="+rm.Username, "MAIDN_GIT_TOKEN="+rm.Token) + return func() { _ = os.RemoveAll(askPassDir) }, environment, nil +} + +func runGit(dir string, environment []string, args ...string) error { + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = environment + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func (rm *RepoManager) apiRequest(method, endpoint string, body []byte) (int, error) { + return rm.apiJSONRequest(method, endpoint, body, nil) +} + +func (rm *RepoManager) apiJSONRequest(method, endpoint string, body []byte, result any) (int, error) { + client := rm.HTTPClient + if client == nil { + client = &http.Client{Timeout: 15 * time.Second} + } + request, err := http.NewRequest(method, endpoint, bytes.NewReader(body)) + if err != nil { + return 0, err + } request.Header.Set("Authorization", "token "+rm.Token) request.Header.Set("Content-Type", "application/json") response, err := client.Do(request) if err != nil { - return err + return 0, err } defer response.Body.Close() - if response.StatusCode >= 300 { - return fmt.Errorf("forgejo returned %s for %s", response.Status, endpoint) + if response.StatusCode >= 500 || response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden { + return 0, &APIError{StatusCode: response.StatusCode, Status: response.Status} } - return nil + if result != nil && response.StatusCode != http.StatusNoContent { + if err := json.NewDecoder(response.Body).Decode(result); err != nil { + return 0, err + } + } + return response.StatusCode, nil } func FluxSourceURL(baseURL, owner, repo string) string { @@ -132,7 +323,3 @@ func FluxSourceURL(baseURL, owner, repo string) string { func CloneURL(baseURL, owner, repo string) string { return fmt.Sprintf("%s/%s/%s.git", strings.TrimRight(baseURL, "/"), owner, repo) } - -func RepoPath(parent, dirName string) string { - return filepath.Join(parent, dirName) -} diff --git a/internal/forgejo/repo_test.go b/internal/forgejo/repo_test.go new file mode 100644 index 0000000..9ec4a02 --- /dev/null +++ b/internal/forgejo/repo_test.go @@ -0,0 +1,96 @@ +package forgejo + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestRepoExistsOnlyCreatesOnNotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + manager := NewRepoManager(server.URL, "token", "owner", "user", "manifests", "cluster", "main", "maidn/bootstrap-test") + manager.HTTPClient = server.Client() + if _, err := manager.repoExists("cluster"); err == nil { + t.Fatal("repoExists() accepted an unauthorized response") + } +} + +func TestEnsureWebhookUpdatesMatchingURL(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + requests++ + switch request.Method { + case http.MethodGet: + if request.URL.Path != "/api/v1/repos/owner/app/hooks" { + t.Fatalf("unexpected lookup path %q", request.URL.Path) + } + _ = json.NewEncoder(writer).Encode([]hook{{ID: 7, URL: "https://tekton.example.test/"}}) + case http.MethodPatch: + if request.URL.Path != "/api/v1/repos/owner/app/hooks/7" { + t.Fatalf("unexpected update path %q", request.URL.Path) + } + var body hookRequest + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Type != "" || body.AuthorizationHeader != "Bearer token" || len(body.Events) != 2 { + t.Fatalf("unexpected hook request: %#v", body) + } + writer.WriteHeader(http.StatusOK) + default: + t.Fatalf("unexpected method %q", request.Method) + } + })) + defer server.Close() + manager := NewRepoManager(server.URL, "token", "owner", "user", "manifests", "cluster", "main", "maidn/bootstrap-test") + manager.HTTPClient = server.Client() + if err := manager.EnsureWebhook("app", "https://tekton.example.test/", "Bearer token"); err != nil { + t.Fatal(err) + } + if requests != 2 { + t.Fatalf("requests = %d, want 2", requests) + } +} + +func TestEnsureWebhookCreatesMissingWebhook(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.Method { + case http.MethodGet: + _ = json.NewEncoder(writer).Encode([]hook{}) + case http.MethodPost: + var body hookRequest + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Type != "forgejo" || body.Config["content_type"] != "json" { + t.Fatalf("unexpected hook request: %#v", body) + } + writer.WriteHeader(http.StatusCreated) + default: + t.Fatalf("unexpected method %q", request.Method) + } + })) + defer server.Close() + manager := NewRepoManager(server.URL, "token", "owner", "user", "manifests", "cluster", "main", "maidn/bootstrap-test") + manager.HTTPClient = server.Client() + if err := manager.EnsureWebhook("app", "https://tekton.example.test/", "Bearer token"); err != nil { + t.Fatal(err) + } +} + +func TestRepoExistsReturnsFalseOnNotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + manager := NewRepoManager(server.URL, "token", "owner", "user", "manifests", "cluster", "main", "maidn/bootstrap-test") + manager.HTTPClient = server.Client() + exists, err := manager.repoExists("cluster") + if err != nil || exists { + t.Fatalf("repoExists() = (%t, %v), want (false, nil)", exists, err) + } +} diff --git a/internal/github/repository.go b/internal/github/repository.go index 45e5a28..c1446ca 100644 --- a/internal/github/repository.go +++ b/internal/github/repository.go @@ -11,18 +11,18 @@ import ( "github.com/Pingu-Studio/MaidnCLI/internal/utils" ) -func BuildFluxConfig(chartOwner, gitURL, manifestsRepo string) string { +func BuildFluxConfig(gitURL, manifestsRepo, branch string) string { return fmt.Sprintf(`--- apiVersion: source.toolkit.fluxcd.io/v1 kind: GitRepository metadata: - name: cicd-deployment-manifests + name: %s namespace: flux-system spec: interval: 1m0s url: %s ref: - branch: main + branch: %s secretRef: name: flux-system --- @@ -37,7 +37,11 @@ spec: prune: true sourceRef: kind: GitRepository - name: cicd-deployment-manifests + name: %s + decryption: + provider: sops + secretRef: + name: sops-age --- apiVersion: kustomize.toolkit.fluxcd.io/v1 kind: Kustomization @@ -52,7 +56,11 @@ spec: prune: true sourceRef: kind: GitRepository - name: cicd-deployment-manifests + name: %s + decryption: + provider: sops + secretRef: + name: sops-age --- apiVersion: kustomize.toolkit.fluxcd.io/v1 kind: Kustomization @@ -67,7 +75,11 @@ spec: prune: true sourceRef: kind: GitRepository - name: cicd-deployment-manifests + name: %s + decryption: + provider: sops + secretRef: + name: sops-age --- apiVersion: kustomize.toolkit.fluxcd.io/v1 kind: Kustomization @@ -82,8 +94,12 @@ spec: prune: true sourceRef: kind: GitRepository - name: cicd-deployment-manifests -`, gitURL) + name: %s + decryption: + provider: sops + secretRef: + name: sops-age +`, manifestsRepo, gitURL, branch, manifestsRepo, manifestsRepo, manifestsRepo, manifestsRepo) } type RepoManager struct { diff --git a/internal/openbao/bootstrap.go b/internal/openbao/bootstrap.go new file mode 100644 index 0000000..a3acb94 --- /dev/null +++ b/internal/openbao/bootstrap.go @@ -0,0 +1,274 @@ +package openbao + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +type status struct { + Initialized bool `json:"initialized"` + Sealed bool `json:"sealed"` +} + +type recovery struct { + UnsealKeysB64 []string `json:"unseal_keys_b64"` + UnsealThreshold int `json:"unseal_threshold"` + RootToken string `json:"root_token"` +} + +type operationalSecrets struct { + Secrets map[string]map[string]string `yaml:"secrets"` +} + +func EnsureRecoveryIdentity(identityPath string) (string, error) { + if _, err := os.Stat(identityPath); os.IsNotExist(err) { + if err := os.MkdirAll(filepath.Dir(identityPath), 0700); err != nil { + return "", err + } + if err := exec.Command("age-keygen", "-o", identityPath).Run(); err != nil { + return "", fmt.Errorf("create OpenBao recovery identity: %w", err) + } + } else if err != nil { + return "", err + } + recipient, err := exec.Command("age-keygen", "-y", identityPath).Output() + if err != nil { + return "", fmt.Errorf("derive OpenBao recovery recipient: %w", err) + } + if value := strings.TrimSpace(string(recipient)); value != "" { + return value, nil + } + return "", fmt.Errorf("OpenBao recovery recipient is empty") +} + +func Initialize(kubeconfig, recipient, identityPath, bundlePath, ageKeyPath, operationalSecretsPath string) (map[string]map[string]string, error) { + if err := validateRecoveryRecipient(recipient, bundlePath); err != nil { + return nil, err + } + if err := waitForPod(kubeconfig); err != nil { + return nil, err + } + current, err := getStatus(kubeconfig) + if err != nil { + return nil, err + } + var material recovery + if !current.Initialized { + output, err := execInPod(kubeconfig, nil, "bao", "operator", "init", "-format=json") + if err != nil { + return nil, fmt.Errorf("initialize OpenBao: %w", err) + } + if err := json.Unmarshal(output, &material); err != nil { + return nil, fmt.Errorf("parse OpenBao recovery material: %w", err) + } + if err := encryptRecovery(recipient, bundlePath, output); err != nil { + return nil, err + } + } else { + output, err := decryptRecovery(identityPath, bundlePath) + if err != nil { + return nil, err + } + if err := json.Unmarshal(output, &material); err != nil { + return nil, fmt.Errorf("parse encrypted OpenBao recovery material: %w", err) + } + } + if current.Sealed { + if err := unseal(kubeconfig, material); err != nil { + return nil, err + } + } + reviewerToken, err := kubectlOutput(kubeconfig, "-n", "openbao", "create", "token", "openbao-auth") + if err != nil { + return nil, fmt.Errorf("create OpenBao Kubernetes token reviewer token: %w", err) + } + if err := configureKubernetesAuth(kubeconfig, material.RootToken, string(bytes.TrimSpace(reviewerToken))); err != nil { + return nil, err + } + return seedOperationalSecrets(kubeconfig, material.RootToken, ageKeyPath, operationalSecretsPath) +} + +func seedOperationalSecrets(kubeconfig, rootToken, ageKeyPath, path string) (map[string]map[string]string, error) { + cmd := exec.Command("sops", "--decrypt", "--output-type", "yaml", path) + cmd.Env = append(os.Environ(), "SOPS_AGE_KEY_FILE="+ageKeyPath) + plaintext, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("decrypt operational SOPS secrets: %w", err) + } + var document operationalSecrets + if err := yaml.Unmarshal(plaintext, &document); err != nil { + return nil, fmt.Errorf("parse operational SOPS secrets: %w", err) + } + if len(document.Secrets) == 0 { + return nil, fmt.Errorf("operational SOPS secrets contains no secrets") + } + paths := make([]string, 0, len(document.Secrets)) + for secretPath := range document.Secrets { + paths = append(paths, secretPath) + } + sort.Strings(paths) + for _, secretPath := range paths { + values := document.Secrets[secretPath] + if err := writeSecret(kubeconfig, rootToken, secretPath, values); err != nil { + return nil, err + } + } + return document.Secrets, nil +} + +func validateRecoveryRecipient(recipient, bundlePath string) error { + if err := os.MkdirAll(filepath.Dir(bundlePath), 0700); err != nil { + return err + } + probe, err := os.CreateTemp(filepath.Dir(bundlePath), "age-recipient-*") + if err != nil { + return err + } + probePath := probe.Name() + if err := probe.Close(); err != nil { + return err + } + defer os.Remove(probePath) + cmd := exec.Command("age", "-r", recipient, "-o", probePath) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("validate OpenBao recovery recipient: %w: %s", err, bytes.TrimSpace(output)) + } + return nil +} + +func writeSecret(kubeconfig, rootToken, secretPath string, values map[string]string) error { + if !regexp.MustCompile(`^[a-z0-9][a-z0-9/_-]*$`).MatchString(secretPath) || len(values) == 0 { + return fmt.Errorf("invalid OpenBao secret path %q", secretPath) + } + keys := make([]string, 0, len(values)) + for key := range values { + if !regexp.MustCompile(`^[A-Za-z0-9_.-]+$`).MatchString(key) { + return fmt.Errorf("invalid OpenBao secret key %q", key) + } + keys = append(keys, key) + } + sort.Strings(keys) + arguments := make([]string, 0, len(keys)) + input := strings.Builder{} + input.WriteString(rootToken) + input.WriteByte('\n') + for _, key := range keys { + arguments = append(arguments, fmt.Sprintf("%s=\"$value%d\"", key, len(arguments))) + input.WriteString(values[key]) + input.WriteByte('\n') + } + reads := make([]string, 0, len(keys)) + for index := range keys { + reads = append(reads, fmt.Sprintf("read -r value%d", index)) + } + script := "read -r root_token\n" + strings.Join(reads, "\n") + "\nexport BAO_TOKEN=\"$root_token\"\nbao kv put secret/" + secretPath + " " + strings.Join(arguments, " ") + " >/dev/null" + _, err := execInPod(kubeconfig, []byte(input.String()), "sh", "-ec", script) + return err +} + +func waitForPod(kubeconfig string) error { + deadline := time.Now().Add(10 * time.Minute) + for time.Now().Before(deadline) { + if _, err := kubectlOutput(kubeconfig, "-n", "openbao", "get", "pod", "openbao-0"); err == nil { + if output, _ := execInPod(kubeconfig, nil, "bao", "status", "-format=json"); len(output) > 0 { + return nil + } + } + time.Sleep(2 * time.Second) + } + return fmt.Errorf("OpenBao pod did not become ready") +} + +func getStatus(kubeconfig string) (status, error) { + output, err := execInPod(kubeconfig, nil, "bao", "status", "-format=json") + if err != nil && len(output) == 0 { + return status{}, fmt.Errorf("get OpenBao status: %w", err) + } + var current status + if err := json.Unmarshal(output, ¤t); err != nil { + return status{}, fmt.Errorf("parse OpenBao status: %w", err) + } + return current, nil +} + +func unseal(kubeconfig string, material recovery) error { + threshold := material.UnsealThreshold + if threshold == 0 { + threshold = 3 + } + if len(material.UnsealKeysB64) < threshold { + return fmt.Errorf("OpenBao recovery bundle contains fewer than %d unseal keys", threshold) + } + for _, key := range material.UnsealKeysB64[:threshold] { + if _, err := execInPod(kubeconfig, []byte(key+"\n"), "sh", "-ec", "read -r key; bao operator unseal \"$key\" >/dev/null"); err != nil { + return fmt.Errorf("unseal OpenBao: %w", err) + } + } + return nil +} + +func configureKubernetesAuth(kubeconfig, rootToken, reviewerToken string) error { + const script = `read -r root_token +read -r reviewer_token +export BAO_TOKEN="$root_token" +bao secrets enable -path=secret kv-v2 >/dev/null 2>&1 || true +bao auth enable kubernetes >/dev/null 2>&1 || true +bao write auth/kubernetes/config token_reviewer_jwt="$reviewer_token" kubernetes_host="https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT}" kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt >/dev/null +cat >/tmp/external-secrets.hcl <<'EOF' +path "secret/data/*" { + capabilities = ["read"] +} +path "secret/metadata/*" { + capabilities = ["list", "read"] +} +EOF +bao policy write external-secrets /tmp/external-secrets.hcl >/dev/null +rm -f /tmp/external-secrets.hcl +bao write auth/kubernetes/role/external-secrets bound_service_account_names=external-secrets bound_service_account_namespaces=external-secrets policies=external-secrets ttl=1h >/dev/null` + input := []byte(rootToken + "\n" + reviewerToken + "\n") + _, err := execInPod(kubeconfig, input, "sh", "-ec", script) + return err +} + +func encryptRecovery(recipient, bundlePath string, plaintext []byte) error { + if err := os.MkdirAll(filepath.Dir(bundlePath), 0700); err != nil { + return err + } + cmd := exec.Command("age", "-r", recipient, "-o", bundlePath) + cmd.Stdin = bytes.NewReader(plaintext) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("encrypt OpenBao recovery material: %w: %s", err, bytes.TrimSpace(output)) + } + return os.Chmod(bundlePath, 0600) +} + +func decryptRecovery(identityPath, bundlePath string) ([]byte, error) { + cmd := exec.Command("age", "-d", "-i", identityPath, bundlePath) + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("decrypt OpenBao recovery material: %w", err) + } + return output, nil +} + +func execInPod(kubeconfig string, input []byte, args ...string) ([]byte, error) { + command := append([]string{"--kubeconfig", kubeconfig, "-n", "openbao", "exec", "-i", "openbao-0", "--"}, args...) + cmd := exec.Command("kubectl", command...) + cmd.Stdin = bytes.NewReader(input) + return cmd.CombinedOutput() +} + +func kubectlOutput(kubeconfig string, args ...string) ([]byte, error) { + command := append([]string{"--kubeconfig", kubeconfig}, args...) + return exec.Command("kubectl", command...).Output() +} diff --git a/internal/openbao/bootstrap_test.go b/internal/openbao/bootstrap_test.go new file mode 100644 index 0000000..b175ff6 --- /dev/null +++ b/internal/openbao/bootstrap_test.go @@ -0,0 +1,21 @@ +package openbao + +import ( + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestEnsureRecoveryIdentity(t *testing.T) { + if _, err := exec.LookPath("age-keygen"); err != nil { + t.Skip("age-keygen is required for OpenBao recovery setup") + } + recipient, err := EnsureRecoveryIdentity(filepath.Join(t.TempDir(), "recovery-key.txt")) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(recipient, "age1") { + t.Fatalf("invalid recovery recipient") + } +} diff --git a/internal/ui/wizard.go b/internal/ui/wizard.go index 4885aed..c0a2636 100644 --- a/internal/ui/wizard.go +++ b/internal/ui/wizard.go @@ -2,6 +2,9 @@ package ui import ( "bufio" + "encoding/base64" + "encoding/json" + "errors" "fmt" "os" "strconv" @@ -32,9 +35,11 @@ func RunBootstrapWizard(initial config.Config) (config.Config, error) { cfg.Flux.ClusterPath = "./clusters/maidn-cd-0" cfg.Templates.TalosRepoURL = prompt(reader, "Talos template repo URL", fallback(cfg.Templates.TalosRepoURL, "https://git.pingu.pw/Maidn/maidn-talos-proxmox.git")) cfg.Templates.CICDRepoURL = prompt(reader, "CI/CD template repo URL", fallback(cfg.Templates.CICDRepoURL, "https://git.pingu.pw/Maidn/maidn-cicd-cluster-template.git")) + cfg.Templates.ManifestsRepoURL = prompt(reader, "Manifests template repo URL", fallback(cfg.Templates.ManifestsRepoURL, "https://git.pingu.pw/Maidn/cicd-deployment-manifests-template.git")) fmt.Printf("Template repos are cloned under: %s\n", cfg.Git.CloneParent) cfg.Templates.TalosRepoRef = "main" cfg.Templates.CICDRepoRef = "main" + cfg.Templates.ManifestsRepoRef = "main" cfg.Talos.RepoDirName = "maidn-talos-proxmox" cfg.Talos.TerraformDir = "terraform" cfg.Talos.GeneratedDir = "generated" @@ -65,6 +70,7 @@ func RunBootstrapWizard(initial config.Config) (config.Config, error) { } cfg.Talos.Cluster.Name = prompt(reader, "Talos cluster name", fallback(cfg.Talos.Cluster.Name, "proxmox-talos-dev02-cluster")) + cfg.ClusterID = prompt(reader, "Cluster ID", fallback(cfg.ClusterID, cfg.Talos.Cluster.Name)) cfg.Talos.Cluster.Domain = prompt(reader, "Talos cluster domain", fallback(cfg.Talos.Cluster.Domain, "dev02.nid3.com")) cfg.Flux.ClusterDomain = cfg.Talos.Cluster.Domain cfg.Talos.Cluster.ControlPlaneVIP = prompt(reader, "Control plane VIP", fallback(cfg.Talos.Cluster.ControlPlaneVIP, "192.168.45.2")) @@ -72,6 +78,7 @@ func RunBootstrapWizard(initial config.Config) (config.Config, error) { cfg.Talos.Cluster.DiskStorage = prompt(reader, "Primary disk storage", fallback(cfg.Talos.Cluster.DiskStorage, firstStorageByCapability(discovered, storageSupportsDisk, "local-lvm"))) cfg.Talos.Cluster.AdditionalStorage = prompt(reader, "Additional disk storage", fallback(cfg.Talos.Cluster.AdditionalStorage, cfg.Talos.Cluster.DiskStorage)) cfg.Talos.Cluster.CreateVLANInterface = promptBool(reader, "Create VLAN interfaces", true) + cfg.Talos.Cluster.ManageNetworkBridges = promptBool(reader, "Terraform manages Proxmox bridges", cfg.Talos.Cluster.ManageNetworkBridges) latestTalos := talos.LatestVersion() cfg.Talos.Image.TalosVersion = talos.NormalizeVersion(prompt(reader, "Talos version", fallback(cfg.Talos.Image.TalosVersion, latestTalos))) @@ -79,6 +86,7 @@ func RunBootstrapWizard(initial config.Config) (config.Config, error) { return cfg, err } cfg.Talos.Image.Architecture = chooseArchitecture(reader, cfg.Talos.Image.Architecture) + cfg.Talos.Image.KubernetesVersion = prompt(reader, "Kubernetes version", fallback(cfg.Talos.Image.KubernetesVersion, "v1.33.4")) fmt.Printf("Talos factory: %s\n", talos.FactoryURL(cfg.Talos.Image.TalosVersion, fallback(cfg.Talos.Image.Architecture, "amd64"))) schematicID, err := talos.ResolveSchematicID() if err != nil { @@ -94,6 +102,7 @@ func RunBootstrapWizard(initial config.Config) (config.Config, error) { cfg.Cilium.TrafficInterface = prompt(reader, "Cilium traffic interface", fallback(cfg.Cilium.TrafficInterface, "eth1")) cfg.Cilium.LoadBalancerStart = prompt(reader, "Cilium LoadBalancer range start", cfg.Cilium.LoadBalancerStart) cfg.Cilium.LoadBalancerEnd = prompt(reader, "Cilium LoadBalancer range end", cfg.Cilium.LoadBalancerEnd) + cfg = PromptDemocraticCSI(cfg) cfg.Talos.BootstrapNode = cfg.Talos.Nodes[0].Networks[0].IP cfg.Talos.BootstrapEndpoint = cfg.Talos.BootstrapNode cfg.Talos.KubeconfigNode = cfg.Talos.Nodes[0].Networks[0].IP @@ -101,6 +110,48 @@ func RunBootstrapWizard(initial config.Config) (config.Config, error) { return cfg, nil } +func PromptDemocraticCSI(cfg config.Config) config.Config { + reader := bufio.NewReader(os.Stdin) + cfg.DemocraticCSI.TrueNASAPIKey = promptSecret(reader, "TrueNAS API key", cfg.DemocraticCSI.TrueNASAPIKey) + cfg.DemocraticCSI.TrueNASHost = prompt(reader, "TrueNAS host", cfg.DemocraticCSI.TrueNASHost) + cfg.DemocraticCSI.TargetPortal = prompt(reader, "TrueNAS target portal", fallback(cfg.DemocraticCSI.TargetPortal, cfg.DemocraticCSI.TrueNASHost+":3260")) + cfg.DemocraticCSI.ShareHost = prompt(reader, "TrueNAS NFS share host", fallback(cfg.DemocraticCSI.ShareHost, cfg.DemocraticCSI.TrueNASHost)) + cfg.DemocraticCSI.DatasetParentNFS = prompt(reader, "TrueNAS NFS dataset parent", cfg.DemocraticCSI.DatasetParentNFS) + cfg.DemocraticCSI.DatasetSnapshotsNFS = prompt(reader, "TrueNAS NFS snapshots dataset", cfg.DemocraticCSI.DatasetSnapshotsNFS) + cfg.DemocraticCSI.AllowedNetworks = prompt(reader, "TrueNAS allowed NFS network", cfg.DemocraticCSI.AllowedNetworks) + cfg.DemocraticCSI.NameSuffix = prompt(reader, "TrueNAS name suffix", fallback(cfg.DemocraticCSI.NameSuffix, "-"+cfg.ClusterID)) + cfg.DemocraticCSI.PortalGroup = prompt(reader, "TrueNAS portal group", fallback(cfg.DemocraticCSI.PortalGroup, "1")) + cfg.DemocraticCSI.InitiatorGroup = prompt(reader, "TrueNAS initiator group", fallback(cfg.DemocraticCSI.InitiatorGroup, "1")) + return cfg +} + +func PromptOperationalSecrets(cfg config.Config) (map[string]map[string]string, error) { + if cfg.Git.Username == "" || cfg.Git.Token == "" || cfg.Delivery.ImageRepository == "" { + return nil, errors.New("git username, token, and delivery imageRepository are required") + } + reader := bufio.NewReader(os.Stdin) + cloudflareAPIToken := promptSecret(reader, "Cloudflare API token", "") + cloudflareTunnelToken := promptSecret(reader, "Cloudflare Tunnel token", "") + if cloudflareAPIToken == "" || cloudflareTunnelToken == "" { + return nil, errors.New("Cloudflare API and Tunnel tokens are required") + } + registryHost := strings.Split(cfg.Delivery.ImageRepository, "/")[0] + dockerConfig, err := json.Marshal(map[string]map[string]map[string]string{ + "auths": { + registryHost: {"auth": base64.StdEncoding.EncodeToString([]byte(cfg.Git.Username + ":" + cfg.Git.Token))}, + }, + }) + if err != nil { + return nil, err + } + return map[string]map[string]string{ + "cicd/forgejo": {"username": cfg.Git.Username, "token": cfg.Git.Token}, + "cicd/forgejo-registry": {"dockerconfigjson": string(dockerConfig)}, + "platform/cloudflare": {"api-token": cloudflareAPIToken}, + "platform/cloudflare-tunnel": {"token": cloudflareTunnelToken}, + }, nil +} + func printNodes(nodes []proxmox.NodeInfo) { fmt.Println("Available Proxmox nodes:") for _, node := range nodes { @@ -124,7 +175,7 @@ func chooseArchitecture(reader *bufio.Reader, current string) string { func buildNodes(reader *bufio.Reader, cfg config.Config, discovered []proxmox.NodeInfo, preset string) []config.TalosNode { switch preset { case "ha": - return buildPresetNodes(reader, cfg, discovered, 3, 2) + return buildPresetNodes(reader, cfg, discovered, 3, 0) case "custom": count, _ := strconv.Atoi(prompt(reader, "Number of nodes", "3")) if count < 1 { @@ -165,6 +216,9 @@ func buildPresetNodes(reader *bufio.Reader, cfg config.Config, discovered []prox vmid, _ := strconv.Atoi(prompt(reader, fmt.Sprintf("Node %d VMID", i+1), fmt.Sprintf("12%02d", i))) memory, _ := strconv.Atoi(prompt(reader, fmt.Sprintf("Node %d memory MB", i+1), memoryDefault)) cores, _ := strconv.Atoi(prompt(reader, fmt.Sprintf("Node %d cores", i+1), coresDefault)) + trafficIP := prompt(reader, fmt.Sprintf("Node %d Cilium traffic IP", i+1), "") + trafficCIDR := prompt(reader, fmt.Sprintf("Node %d Cilium traffic CIDR", i+1), "192.168.45.16/28") + trafficVLAN, _ := strconv.Atoi(prompt(reader, fmt.Sprintf("Node %d Cilium traffic VLAN ID", i+1), "451")) result = append(result, config.TalosNode{ Name: name, VMID: vmid, @@ -174,13 +228,21 @@ func buildPresetNodes(reader *bufio.Reader, cfg config.Config, discovered []prox DiskSize: prompt(reader, fmt.Sprintf("Node %d disk size", i+1), diskDefault), Tags: []string{"talos", role}, ProxmoxNode: proxmoxNode, - Networks: []config.TalosNetwork{{ - MACAddress: prompt(reader, fmt.Sprintf("Node %d MAC", i+1), ""), - CIDR: cidr, - IP: ip, - Gateway: gateway, - VLANID: vlanID, - }}, + Networks: []config.TalosNetwork{ + { + MACAddress: prompt(reader, fmt.Sprintf("Node %d primary MAC", i+1), ""), + CIDR: cidr, + IP: ip, + Gateway: gateway, + VLANID: vlanID, + }, + { + MACAddress: prompt(reader, fmt.Sprintf("Node %d Cilium traffic MAC", i+1), ""), + CIDR: trafficCIDR, + IP: trafficIP, + VLANID: trafficVLAN, + }, + }, }) } return result diff --git a/internal/utils/helpers.go b/internal/utils/helpers.go index f29946f..1c0ee6b 100644 --- a/internal/utils/helpers.go +++ b/internal/utils/helpers.go @@ -37,8 +37,15 @@ func RunCommandQuiet(name string, args ...string) error { } func RunCommandInDir(dir string, name string, args ...string) error { + return RunCommandInDirEnv(dir, nil, name, args...) +} + +func RunCommandInDirEnv(dir string, environment []string, name string, args ...string) error { cmd := exec.Command(name, args...) cmd.Dir = dir + if environment != nil { + cmd.Env = append(os.Environ(), environment...) + } cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() @@ -73,6 +80,15 @@ func RunCommandOutputInDir(dir string, name string, args ...string) ([]byte, err return stdout.Bytes(), nil } +func RunCommandInputInDir(dir string, input []byte, name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Dir = dir + cmd.Stdin = bytes.NewReader(input) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + func RunCommandJSON(target any, name string, args ...string) error { output, err := RunCommandOutput(name, args...) if err != nil {