package bootstrap import ( "bytes" "context" "crypto/rand" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/url" "os" "os/exec" "path/filepath" "reflect" "regexp" "sort" "strconv" "strings" "text/template" "time" "github.com/Pingu-Studio/MaidnCLI/internal/assets" "github.com/Pingu-Studio/MaidnCLI/internal/cloudflare" "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/proxmox" "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 Mode Mode ConfirmRebuild bool RegisterWebhook bool RefreshWebhookSecret bool EnableDelivery bool SkipDeliveryScaffolding bool AutoMergeBootstrapMigration bool DestroyDemocraticCSIStorage bool RefreshTemplateRevisions bool } type operationalSecrets struct { Secrets map[string]map[string]string `yaml:"secrets"` } var initializeOpenBao = openbao.Initialize var configureOpenBaoSecretGrants = openbao.ConfigureSecretGrants var readOpenBaoRecovery = openbao.ReadRecoveryMaterial var decryptGeneratedSOPS = decryptSOPSFile var writeGeneratedSOPS = writeSOPSEncryptedFile var readCloudflareOperationalSecrets = ReadOperationalSecrets var writeCloudflareOperationalSecrets = WriteOperationalSecrets var ensureForgejoWebhook = func(cfg config.Config, repo, webhookURL, authorization string) error { manager := forgejo.NewRepoManager(cfg.Git.BaseURL, cfg.Git.Token, cfg.Git.Owner, cfg.Git.Username, cfg.Flux.ManifestsRepo, cfg.Flux.RepoName, cfg.Flux.Branch, "maidn/bootstrap-"+cfg.ClusterID) return manager.EnsureWebhook(repo, webhookURL, authorization) } var readOperationalSecrets = ReadOperationalSecrets var runWebhookCommand = utils.RunCommandQuietOutputInDir var preflight = config.Preflight var runGit = func(dir string, args ...string) ([]byte, error) { command := exec.Command("git", args...) command.Dir = dir return command.Output() } var runGitEnvironment = func(dir string, environment []string, args ...string) ([]byte, error) { command := exec.Command("git", args...) command.Dir = dir command.Env = environment return command.Output() } var verifyTalosVMs = verifyConfiguredTalosVMs var terraformStateResources = listTerraformStateResources var runTerraformStateList = func(terraformDir string, environment []string) ([]byte, []byte, error) { command := exec.Command("terraform", "state", "list") command.Dir = terraformDir command.Env = append(os.Environ(), environment...) var stdout, stderr bytes.Buffer command.Stdout = &stdout command.Stderr = &stderr err := command.Run() return stdout.Bytes(), stderr.Bytes(), err } func listTerraformStateResources(terraformDir string, environment []string) ([]string, error) { state, stderr, err := runTerraformStateList(terraformDir, environment) if err != nil { if terraformNoStateFile(stderr) { return nil, nil } return nil, fmt.Errorf("list Terraform state: %w", err) } return strings.Fields(string(state)), nil } var ansiEscapeSequence = regexp.MustCompile(`\x1b\[[0-?]*[ -/]*[@-~]`) func terraformNoStateFile(stderr []byte) bool { return strings.Contains(strings.ToLower(ansiEscapeSequence.ReplaceAllString(string(stderr), "")), "no state file") } var destroyTalosVMs = func(terraformDir string, environment []string) error { command := exec.Command("terraform", "destroy", "-auto-approve", "-input=false", "-target=proxmox_virtual_environment_vm.vm") command.Dir = terraformDir command.Env = append(os.Environ(), environment...) var stderr bytes.Buffer command.Stdout = os.Stdout command.Stderr = io.MultiWriter(os.Stderr, &stderr) if err := command.Run(); err != nil { return fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String())) } return nil } var runTerraform = utils.RunCommandInDirEnv var destroyTalosVMRetryDelay = 10 * time.Second var webhookTargetTimeout = 70 * time.Minute var webhookTargetPollInterval = 2 * time.Second var templateBaseComponents = []string{"snapshot-crds", "democratic-csi", "cert-manager", "cluster-issuers", "gateway-api", "gateway", "monitoring", "openbao", "external-secrets", "cnpg", "cloudflare-tunnel", "external-dns", "tekton", "tekton-triggers"} var deliveryTemplateBaseComponents = map[string]bool{"gateway": true, "tekton": true, "tekton-triggers": true} var generatedTemplateFiles = map[string]map[string]bool{ "democratic-csi": {"secret.sops.yaml": true}, "openbao": {"unseal.sops.yaml": true}, } var requiredClusterKustomizations = []string{"snapshot-crds-kustomization.yaml", "democratic-csi-kustomization.yaml", "cert-manager-kustomization.yaml", "cluster-issuers-kustomization.yaml", "gateway-api-kustomization.yaml", "cilium-kustomization.yaml", "cilium-config-kustomization.yaml", "openbao-kustomization.yaml", "external-secrets-kustomization.yaml", "cnpg-kustomization.yaml", "gateway-kustomization.yaml", "external-dns-kustomization.yaml", "cloudflare-tunnel-kustomization.yaml", "monitoring-kustomization.yaml", "tekton-kustomization.yaml", "tekton-triggers-kustomization.yaml", "cicd-manifests-repo.yaml"} func (r Runner) Run() error { if r.SkipDeliveryScaffolding { r.Config.Delivery = config.DeliveryConfig{} } resolved, err := config.Resolve(r.Config) if err != nil { return err } r.Config = resolved if r.RegisterWebhook || r.EnableDelivery { resolvedDelivery, err := config.ResolveDelivery(r.Config) if err != nil { return err } r.Config = resolvedDelivery } if r.Mode, err = resolveLifecycleMode(r.Mode, r.ConfirmRebuild); err != nil { return err } if r.RefreshTemplateRevisions && r.Mode != Reconcile { return errors.New("--refresh-template-revisions requires --mode=reconcile") } if r.DestroyDemocraticCSIStorage && r.Mode != Rebuild { return errors.New("--destroy-democratic-csi-storage requires --mode=rebuild --yes") } if err := 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 == "" { return errors.New("SOPS recoveryRecipient is required") } } if r.RegisterWebhook { if r.RefreshTemplateRevisions { if err := RefreshTemplateRevisions(r.Config); err != nil { return err } } return r.reconcileWebhook(filepath.Join(r.Config.Git.CloneParent, r.Config.Talos.RepoDirName, r.Config.Talos.GeneratedDir)) } if r.Config.SOPS.RecoveryIdentityPath == "" || r.Config.SOPS.RecoveryBundlePath == "" { return errors.New("SOPS recoveryIdentityPath and recoveryBundlePath are required") } if _, err := os.Stat(r.Config.SOPS.RecoveryIdentityPath); err != nil { return fmt.Errorf("preflight OpenBao recovery identity: %w", err) } if _, err := os.Stat(r.Config.SOPS.RecoveryBundlePath); err != nil { return fmt.Errorf("preflight OpenBao recovery bundle: %w", err) } workspace := r.Config.WorkspaceDir if r.RefreshTemplateRevisions { if err := RefreshTemplateRevisions(r.Config); err != nil { return err } } else { if err := EnsureTemplateRevisions(r.Config); err != nil { return err } } catalogManager := forgejo.NewRepoManager(r.Config.Git.BaseURL, r.Config.Git.Token, r.Config.Git.Owner, r.Config.Git.Username, "", "", r.Config.Templates.TektonCatalogRepoRef, "") if _, err := catalogManager.EnsureRepositoryCopy(r.Config.Flux.TektonCatalogRepo, "User-managed Tekton pipeline catalog", r.Config.Templates.TektonCatalogRepoURL); err != nil { return fmt.Errorf("initialize Tekton catalog repository: %w", err) } if err := config.WriteRedacted(filepath.Join(workspace, "maidn-bootstrap.resolved.yaml"), r.Config); err != nil { return err } manifestsURL := forgejo.CloneURL(r.Config.Git.BaseURL, r.Config.Git.Owner, r.Config.Flux.ManifestsRepo) fluxConfig := ghrepo.BuildFluxConfig(manifestsURL, r.Config.Flux.ManifestsRepo, r.Config.Flux.Branch) cicdTemplateDir := filepath.Join(workspace, "maidn-cicd-cluster-template") manifestsTemplateDir := filepath.Join(workspace, "cicd-deployment-manifests-template") 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 { 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 := copyTemplateBaseComponents(cicdTemplateDir, dir, true); err != nil { return err } if err := copyClusterTemplate(filepath.Join(cicdTemplateDir, "clusters", "template"), clusterDir); err != nil { return err } for _, name := range []string{"external-secrets", "cnpg", "cloudflare-tunnel", "external-dns", "monitoring", "tekton"} { content, err := os.ReadFile(filepath.Join(cicdTemplateDir, "clusters", "template", name+"-kustomization.yaml")) if err != nil { return err } if err := os.WriteFile(filepath.Join(clusterDir, name+"-kustomization.yaml"), content, 0644); err != nil { return err } } csiKustomization, err := os.ReadFile(filepath.Join(cicdTemplateDir, "clusters", "template", "democratic-csi-kustomization.yaml")) if err != nil { return err } if err := os.WriteFile(filepath.Join(clusterDir, "democratic-csi-kustomization.yaml"), csiKustomization, 0644); err != nil { return err } if err := copyAndRenderCiliumBases(cicdTemplateDir, dir, r.Config); err != nil { return err } if err := copyAndRenderPlatformDeliveryBases(cicdTemplateDir, dir, 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 } openbaoDir := filepath.Join(dir, "base", "openbao") if err := writeOpenBaoUnsealSecret(filepath.Join(openbaoDir, "unseal.sops.yaml"), r.Config.SOPS.RecoveryIdentityPath, r.Config.SOPS.RecoveryBundlePath, r.Config.SOPS.AgeKeyPath); err != nil { return err } if err := ensureOpenBaoUnsealKustomization(filepath.Join(openbaoDir, "kustomization.yaml")); err != nil { return err } if err := ensureClusterKustomizations(clusterDir, true); err != nil { return err } return ghrepo.WriteFluxStructure(dir, r.Config.Flux.RepoName, r.Config.Flux.ClusterPath, fluxConfig) }, ); err != nil { return err } if err := r.reconcileBootstrapMigration(manager); err != nil { return err } if err := r.reconcileCloudflareTunnel(); err != nil { return err } repoDir := filepath.Join(r.Config.Git.CloneParent, r.Config.Talos.RepoDirName) terraformDir := filepath.Join(repoDir, r.Config.Talos.TerraformDir) generatedDir := filepath.Join(repoDir, r.Config.Talos.GeneratedDir) if err := os.MkdirAll(generatedDir, 0755); err != nil { return err } 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 r.DestroyDemocraticCSIStorage { if err := destroyDemocraticCSIStorage(r.Config.DemocraticCSI); err != nil { return fmt.Errorf("destroy Democratic CSI storage: %w", err) } } if err := r.reconcileTerraform(terraformDir); err != nil { return err } } if err := ensureTalosConfig(generatedDir, r.Config); err != nil { return err } if r.Config.Talos.AutoBootstrap { if err := applyTalosConfigs(generatedDir, r.Config); 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 } if err := waitForKubernetesAPI(generatedDir); err != nil { return err } } if r.Config.Talos.AutoBootstrapFlux { if err := installCilium(generatedDir, filepath.Join(cicdTemplateDir, "base", "cilium", "release.yaml"), r.Config); err != nil { return err } 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 } return r.completeFluxBootstrap(generatedDir) } return nil } func (r Runner) reconcileBootstrapMigration(manager *forgejo.RepoManager) error { if len(manager.MigrationRepositories) == 0 { return nil } if !r.AutoMergeBootstrapMigration { return fmt.Errorf("repository migration PRs created for %s; merge and rerun bootstrap before infrastructure changes", strings.Join(manager.MigrationRepositories, ", ")) } for _, repository := range manager.MigrationRepositories { if err := manager.MergePullRequest(repository, manager.MigrationBranch); err != nil { return fmt.Errorf("merge bootstrap migration PR for %s: %w", repository, err) } } return nil } func resolveLifecycleMode(mode Mode, confirmRebuild bool) (Mode, error) { if mode == "" { mode = Reconcile } if mode != Reconcile && mode != Rebuild { return "", fmt.Errorf("unsupported bootstrap mode %q", mode) } if mode == Rebuild && !confirmRebuild { return "", fmt.Errorf("rebuild is destructive; rerun with --mode=rebuild --yes") } return mode, nil } func (r Runner) reconcileCloudflareTunnel() error { secrets, err := readCloudflareOperationalSecrets(r.Config.SOPS.OperationalSecretsPath, r.Config.SOPS.AgeKeyPath) if err != nil { return fmt.Errorf("read encrypted Cloudflare operational state: %w", err) } tunnelState := secrets["platform/cloudflare-tunnel"] stored, present, legacy, err := cloudflare.ParseStoredTunnelState(tunnelState) if present { if !legacy { return nil } values, err := stored.Values() if err != nil { return err } secrets["platform/cloudflare-tunnel"] = values if err := writeCloudflareOperationalSecrets(r.Config.SOPS.OperationalSecretsPath, r.Config.SOPS.AgeKeyPath, secrets); err != nil { return errors.New("save normalized encrypted Cloudflare tunnel state") } return nil } if err == nil || cloudflare.IsLegacyRunTokenState(tunnelState) { return errors.New("Cloudflare tunnel credentials and config are not generated; run cicd-tool cloudflare-tunnel import --config --credentials-file ") } return err } func (r Runner) reconcileWebhook(generatedDir string) error { var ( operationalSecrets map[string]map[string]string err error ) if r.RefreshWebhookSecret { operationalSecrets, err = r.initializeOpenBaoForCluster(generatedDir) if err != nil { return fmt.Errorf("initialize OpenBao: %w", err) } } else { operationalSecrets, err = readOperationalSecrets(r.Config.SOPS.OperationalSecretsPath, r.Config.SOPS.AgeKeyPath) if err != nil { return fmt.Errorf("read encrypted webhook authorization: %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 := ensureForgejoWebhook(r.Config, r.Config.Delivery.AppName, r.Config.Delivery.WebhookURL(), authorization); err != nil { return fmt.Errorf("reconcile Forgejo webhook: %w", err) } return nil } func (r Runner) initializeOpenBaoForCluster(generatedDir string) (map[string]map[string]string, error) { kubeconfig := filepath.Join(generatedDir, "kubeconfig") secrets, err := initializeOpenBao(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 nil, err } if err := configureOpenBaoSecretGrants(kubeconfig, r.Config.SOPS.RecoveryIdentityPath, r.Config.SOPS.RecoveryBundlePath, r.Config.SecretGrants); err != nil { return nil, fmt.Errorf("configure OpenBao secret grants: %w", err) } return secrets, nil } // completeFluxBootstrap runs the post-Flux platform initialization only. func (r Runner) completeFluxBootstrap(generatedDir string) error { if _, err := r.initializeOpenBaoForCluster(generatedDir); err != nil { return fmt.Errorf("initialize OpenBao: %w", err) } return nil } func renderCiliumConfig(dir string, cfg config.Config) error { replacements := strings.NewReplacer( "${CILIUM_K8S_SERVICE_HOST}", cfg.Talos.KubeconfigEndpoint, "${CILIUM_TRAFFIC_INTERFACE}", cfg.Cilium.TrafficInterface, "${CILIUM_LB_START}", cfg.Cilium.LoadBalancerStart, "${CILIUM_LB_END}", cfg.Cilium.LoadBalancerEnd, ) 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 renderPlatformDeliveryConfig(dir string, cfg config.Config) error { replacements := strings.NewReplacer( "${FORGEJO_BASE_URL}", cfg.Git.BaseURL, "${CLUSTER_DOMAIN}", cfg.Flux.ClusterDomain, "${GIT_OWNER}", cfg.Git.Owner, "${FORGEJO_OWNER}", cfg.Git.Owner, "${TEKTON_CATALOG_REPO_URL}", forgejo.CloneURL(cfg.Git.BaseURL, cfg.Git.Owner, cfg.Flux.TektonCatalogRepo), "${TEKTON_CATALOG_REPO_REF}", cfg.Templates.TektonCatalogRepoRef, "${WEBHOOK_HOSTNAME}", "tekton."+cfg.Flux.ClusterDomain, "${WEBHOOK_PATH}", "/", ) 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 copyAndRenderPlatformDeliveryBases(templateDir, repoDir string, cfg config.Config) error { bases := []string{"gateway", "tekton", "tekton-triggers"} apps := filepath.Join(repoDir, "base", "tekton", "apps") backup := filepath.Join(repoDir, ".maidn-preserve-tekton-apps") if err := os.RemoveAll(backup); err != nil { return err } if _, err := os.Stat(apps); err == nil { if err := os.Rename(apps, backup); err != nil { return err } } else if !os.IsNotExist(err) { return err } for _, base := range bases { baseDir := filepath.Join(repoDir, "base", base) if err := copyDir(filepath.Join(templateDir, "base", base), baseDir, true); err != nil { return err } } if _, err := os.Stat(backup); err == nil { if err := os.RemoveAll(apps); err != nil { return err } if err := os.Rename(backup, apps); err != nil { return err } } else if !os.IsNotExist(err) { return err } for _, base := range bases { baseDir := filepath.Join(repoDir, "base", base) if err := renderPlatformDeliveryConfig(baseDir, cfg); err != nil { return err } } return writePreviewDeliveryConfig(filepath.Join(repoDir, "base", "tekton"), cfg) } func writePreviewDeliveryConfig(dir string, cfg config.Config) error { origin, err := canonicalForgejoOrigin(cfg.Git.BaseURL) if err != nil { return err } manifestsURL := forgejo.CloneURL(origin, cfg.Git.Owner, cfg.Flux.ManifestsRepo) content, err := yaml.Marshal(struct { APIVersion string `yaml:"apiVersion"` Kind string `yaml:"kind"` Metadata map[string]string `yaml:"metadata"` Data map[string]string `yaml:"data"` }{ APIVersion: "v1", Kind: "ConfigMap", Metadata: map[string]string{"name": "maidn-preview-delivery-config", "namespace": "tekton-pipelines"}, Data: map[string]string{ "forgejo-origin": origin, "manifests-url": manifestsURL, "manifests-branch": cfg.Flux.Branch, "tekton-dashboard-url": cfg.Delivery.TektonDashboardURL, }, }) if err != nil { return err } path := filepath.Join(dir, "kustomization.yaml") data, err := os.ReadFile(path) if err != nil { return err } if !strings.Contains("\n"+string(data), "\nresources:") { return errors.New("Tekton Kustomization must define resources before adding preview delivery configuration") } if err := os.WriteFile(filepath.Join(dir, "preview-delivery-config.yaml"), content, 0644); err != nil { return err } legacyPath := filepath.Join(dir, "maidn-preview-delivery-config.yaml") if err := os.Remove(legacyPath); err != nil && !os.IsNotExist(err) { return err } updated := strings.ReplaceAll(string(data), " - maidn-preview-delivery-config.yaml\n", "") if !strings.Contains(updated, "preview-delivery-config.yaml") { updated += " - preview-delivery-config.yaml\n" } return os.WriteFile(path, []byte(updated), 0644) } func removeDuplicateAppDeliverySource(dir, appName string) error { filename := appName + "-source.yaml" if err := os.Remove(filepath.Join(dir, filename)); err != nil && !os.IsNotExist(err) { return err } path := filepath.Join(dir, "kustomization.yaml") data, err := os.ReadFile(path) if err != nil { return err } updated := strings.ReplaceAll(string(data), " - "+filename+"\n", "") if updated == string(data) { return nil } return os.WriteFile(path, []byte(updated), 0644) } func canonicalForgejoOrigin(value string) (string, error) { parsed, err := url.Parse(value) if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawPath != "" || parsed.RawQuery != "" || parsed.Fragment != "" || strings.Trim(parsed.Path, "/") != "" { return "", errors.New("Forgejo base URL must be a credential-free HTTPS origin") } return parsed.Scheme + "://" + parsed.Host, nil } type appDeliveryTemplateConfig struct { AppName string AppRepository string AppRepoURL string AppRepoRef string ProductionBranch string ImageRepository string BuildStrategy string BuildOutputDirectory string BuildConfiguration string ForgejoBaseURL string ForgejoOwner string ManifestsURL string ManifestsRepo string ManifestsBranch string ClusterDomain string RuntimeHostnameSuffix string } var deliveryAppName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,45}[a-z0-9])?$`) func renderAppDelivery(cfg config.Config) ([]byte, error) { if !deliveryAppName.MatchString(cfg.Delivery.AppName) { return nil, errors.New("delivery appName must be a lowercase DNS label") } if cfg.Delivery.BuildStrategy != "static" && cfg.Delivery.BuildStrategy != "runtime" { return nil, errors.New("delivery buildStrategy must be static or runtime") } appRepository, err := deliveryRepository(cfg.Git.BaseURL, cfg.Delivery.AppRepoURL) if err != nil { return nil, err } origin, err := canonicalForgejoOrigin(cfg.Git.BaseURL) if err != nil { return nil, err } values := appDeliveryTemplateConfig{ AppName: cfg.Delivery.AppName, AppRepository: appRepository, AppRepoURL: cfg.Delivery.AppRepoURL, AppRepoRef: cfg.Delivery.AppRepoRef, ProductionBranch: cfg.Delivery.ProductionBranch, ImageRepository: cfg.Delivery.ImageRepository, BuildStrategy: cfg.Delivery.BuildStrategy, BuildOutputDirectory: cfg.Delivery.BuildOutputDirectory, BuildConfiguration: cfg.Delivery.BuildConfiguration, ForgejoBaseURL: origin, ForgejoOwner: cfg.Git.Owner, ManifestsURL: forgejo.CloneURL(origin, cfg.Git.Owner, cfg.Flux.ManifestsRepo), ManifestsRepo: cfg.Flux.ManifestsRepo, ManifestsBranch: cfg.Flux.Branch, ClusterDomain: cfg.Flux.ClusterDomain, RuntimeHostnameSuffix: deliveryRuntimeHostnameSuffix(cfg.Flux.ClusterDomain), } for name, value := range map[string]string{"appRepository": values.AppRepository, "appRepoUrl": values.AppRepoURL, "appRepoRef": values.AppRepoRef, "productionBranch": values.ProductionBranch, "imageRepository": values.ImageRepository, "buildOutputDirectory": values.BuildOutputDirectory, "buildConfiguration": values.BuildConfiguration, "forgejoBaseUrl": values.ForgejoBaseURL, "forgejoOwner": values.ForgejoOwner, "manifestsUrl": values.ManifestsURL, "manifestsRepo": values.ManifestsRepo, "manifestsBranch": values.ManifestsBranch, "clusterDomain": values.ClusterDomain, "runtimeHostnameSuffix": values.RuntimeHostnameSuffix} { if value == "" || strings.ContainsAny(value, "\r\n") || config.RedactURL(value) != value { return nil, fmt.Errorf("delivery %s cannot be empty or contain credentials", name) } } tmpl, err := template.New("delivery-pipeline").Funcs(template.FuncMap{"quote": strconv.Quote}).Parse(assets.DeliveryPipelineTmpl) if err != nil { return nil, err } var rendered bytes.Buffer if err := tmpl.Execute(&rendered, values); err != nil { return nil, err } return bytes.ReplaceAll(rendered.Bytes(), []byte("\r\n"), []byte("\n")), nil } func deliveryRuntimeHostnameSuffix(clusterDomain string) string { labels := strings.Split(clusterDomain, ".") if len(labels) < 3 { return "." + clusterDomain } return "-" + strings.Join(labels[:len(labels)-2], "-") + "." + strings.Join(labels[len(labels)-2:], ".") } func deliveryRepository(baseURL, repositoryURL string) (string, error) { base, err := canonicalForgejoOrigin(baseURL) if err != nil { return "", err } repository, err := url.Parse(repositoryURL) if err != nil || repository.Scheme != "https" || repository.Host == "" || repository.User != nil || repository.RawPath != "" || repository.RawQuery != "" || repository.Fragment != "" || !strings.EqualFold(repository.Scheme+"://"+repository.Host, base) { return "", errors.New("delivery appRepoUrl must be a credential-free HTTPS repository on the configured Forgejo origin") } parts := strings.Split(strings.Trim(repository.Path, "/"), "/") if len(parts) != 2 || !strings.HasSuffix(parts[1], ".git") { return "", errors.New("delivery appRepoUrl must identify one Forgejo owner/repository.git") } name := strings.TrimSuffix(parts[1], ".git") if !deliveryRepositoryPart(parts[0]) || !deliveryRepositoryPart(name) { return "", errors.New("delivery appRepoUrl has an invalid Forgejo owner or repository") } return parts[0] + "/" + name, nil } func deliveryRepositoryPart(value string) bool { return value != "" && !strings.Contains(value, "..") && regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`).MatchString(value) } func copyAndRenderCiliumBases(templateDir, repoDir string, cfg config.Config) error { for _, base := range []string{"cilium", "cilium-config"} { baseDir := filepath.Join(repoDir, "base", base) if err := copyDir(filepath.Join(templateDir, "base", base), baseDir, true); err != nil { return err } if err := renderCiliumConfig(baseDir, cfg); err != nil { return err } } return renderCiliumHubblePeerService(filepath.Join(repoDir, "base", "cilium", "release.yaml"), cfg.Flux.ClusterDomain) } func renderCiliumHubblePeerService(path, clusterDomain string) error { content, err := os.ReadFile(path) if err != nil { return err } decoder := yaml.NewDecoder(bytes.NewReader(content)) var document yaml.Node if err := decoder.Decode(&document); err != nil { return fmt.Errorf("parse Cilium HelmRelease: %w", err) } if err := decoder.Decode(&yaml.Node{}); !errors.Is(err, io.EOF) { return errors.New("Cilium HelmRelease must contain one YAML document") } if document.Kind != yaml.DocumentNode || len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode { return errors.New("Cilium HelmRelease must be a YAML mapping") } spec, err := requiredYAMLMapping(document.Content[0], "spec") if err != nil { return fmt.Errorf("Cilium HelmRelease: %w", err) } values, err := requiredYAMLMapping(spec, "values") if err != nil { return fmt.Errorf("Cilium HelmRelease spec: %w", err) } hubble, err := ensureYAMLMapping(values, "hubble") if err != nil { return fmt.Errorf("Cilium HelmRelease values: %w", err) } peerService, err := ensureYAMLMapping(hubble, "peerService") if err != nil { return fmt.Errorf("Cilium HelmRelease hubble: %w", err) } clusterDomainNode, err := yamlMappingValue(peerService, "clusterDomain") if err != nil { return fmt.Errorf("Cilium HelmRelease hubble peerService: %w", err) } if clusterDomainNode != nil { if clusterDomainNode.Kind != yaml.ScalarNode { return errors.New("Cilium HelmRelease hubble peerService clusterDomain must be a scalar") } if clusterDomainNode.Value == clusterDomain { return nil } clusterDomainNode.Value = clusterDomain } else { peerService.Content = append(peerService.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: "clusterDomain"}, &yaml.Node{Kind: yaml.ScalarNode, Value: clusterDomain}) } var rendered bytes.Buffer encoder := yaml.NewEncoder(&rendered) encoder.SetIndent(2) if err := encoder.Encode(&document); err != nil { return fmt.Errorf("render Cilium HelmRelease: %w", err) } return os.WriteFile(path, rendered.Bytes(), 0644) } func requiredYAMLMapping(node *yaml.Node, key string) (*yaml.Node, error) { value, err := yamlMappingValue(node, key) if err != nil { return nil, err } if value == nil || value.Kind != yaml.MappingNode { return nil, fmt.Errorf("%s must be a mapping", key) } return value, nil } func ensureYAMLMapping(node *yaml.Node, key string) (*yaml.Node, error) { value, err := yamlMappingValue(node, key) if err != nil { return nil, err } if value != nil { if value.Kind != yaml.MappingNode { return nil, fmt.Errorf("%s must be a mapping", key) } return value, nil } node.Content = append(node.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: key}, &yaml.Node{Kind: yaml.MappingNode}) return node.Content[len(node.Content)-1], nil } func yamlMappingValue(node *yaml.Node, key string) (*yaml.Node, error) { if node.Kind != yaml.MappingNode || len(node.Content)%2 != 0 { return nil, errors.New("must be a YAML mapping") } var value *yaml.Node for index := 0; index < len(node.Content); index += 2 { if node.Content[index].Kind != yaml.ScalarNode { return nil, errors.New("contains a non-scalar key") } if node.Content[index].Value == key { if value != nil { return nil, fmt.Errorf("contains duplicate %s", key) } value = node.Content[index+1] } } return value, nil } func writeDemocraticCSISecret(path string, csi config.DemocraticCSIConfig, ageKeyPath string) error { plaintext, err := renderDemocraticCSISecret(csi) if err != nil { return err } if existing, err := decryptSOPSFile(path, ageKeyPath); err == nil && sameYAML(existing, plaintext) { return nil } return writeSOPSEncryptedFile(path, ageKeyPath, plaintext) } func writeOpenBaoUnsealSecret(path, recoveryIdentityPath, recoveryBundlePath, ageKeyPath string) error { material, err := readOpenBaoRecovery(recoveryIdentityPath, recoveryBundlePath) if err != nil { return fmt.Errorf("read OpenBao recovery material: %w", err) } plaintext, err := renderOpenBaoUnsealSecret(material) if err != nil { return err } if _, err := os.Stat(path); err == nil { existing, err := decryptGeneratedSOPS(path, ageKeyPath) if err != nil { return fmt.Errorf("decrypt existing OpenBao unseal secret: %w", err) } if sameYAML(existing, plaintext) { return nil } } else if !os.IsNotExist(err) { return err } return writeGeneratedSOPS(path, ageKeyPath, plaintext) } func renderOpenBaoUnsealSecret(material openbao.RecoveryMaterial) ([]byte, error) { if material.UnsealThreshold < 1 || len(material.UnsealKeysB64) < material.UnsealThreshold { return nil, errors.New("OpenBao recovery material is incomplete") } data := make(map[string]string, len(material.UnsealKeysB64)) for index, share := range material.UnsealKeysB64 { if share == "" { return nil, errors.New("OpenBao recovery material contains an invalid unseal key") } data[fmt.Sprintf("unseal-%d", index+1)] = share } 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": "openbao-unseal", "namespace": "openbao"}, Type: "Opaque", StringData: data, }) } func ensureOpenBaoUnsealKustomization(path string) error { content, err := os.ReadFile(path) if err != nil { return err } var document struct { APIVersion string `yaml:"apiVersion"` Kind string `yaml:"kind"` Resources []string `yaml:"resources"` } decoder := yaml.NewDecoder(bytes.NewReader(content)) if err := decoder.Decode(&document); err != nil { return fmt.Errorf("parse OpenBao Kustomization: %w", err) } if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { return errors.New("OpenBao Kustomization must contain one YAML document") } if document.APIVersion != "kustomize.config.k8s.io/v1beta1" || document.Kind != "Kustomization" || len(document.Resources) == 0 { return errors.New("OpenBao Kustomization must define resources") } count := 0 for _, resource := range document.Resources { if resource == "" { return errors.New("OpenBao Kustomization contains an empty resource") } if resource == "unseal.sops.yaml" { count++ } } if count > 1 { return errors.New("OpenBao Kustomization references unseal.sops.yaml more than once") } if count == 1 { return nil } if !bytes.HasSuffix(content, []byte("\n")) { content = append(content, '\n') } return os.WriteFile(path, append(content, []byte(" - unseal.sops.yaml\n")...), 0644) } func WriteOperationalSecrets(path, ageKeyPath string, secrets map[string]map[string]string) error { plaintext, err := yaml.Marshal(operationalSecrets{Secrets: secrets}) if err != nil { return err } return writeSOPSEncrypted(path, ageKeyPath, plaintext, "^(secrets)$") } func UpsertOperationalSecret(path, ageKeyPath, secretPath, key, value string) error { secrets, err := ReadOperationalSecrets(path, ageKeyPath) if err != nil { return err } values := secrets[secretPath] if values == nil { values = map[string]string{} secrets[secretPath] = values } values[key] = value return WriteOperationalSecrets(path, ageKeyPath, secrets) } func ReadOperationalSecrets(path, ageKeyPath string) (map[string]map[string]string, error) { plaintext, err := decryptSOPSFile(path, ageKeyPath) if err != nil { return nil, fmt.Errorf("decrypt operational SOPS secrets: %w", err) } decoder := yaml.NewDecoder(bytes.NewReader(plaintext)) decoder.KnownFields(true) var document operationalSecrets if err := decoder.Decode(&document); err != nil { return nil, fmt.Errorf("parse operational SOPS secrets: %w", err) } if err := decoder.Decode(&operationalSecrets{}); !errors.Is(err, io.EOF) { return nil, errors.New("operational SOPS secrets must contain one YAML document") } if document.Secrets == nil { return nil, errors.New("operational SOPS secrets requires a secrets mapping") } for secretPath, values := range document.Secrets { if values == nil { return nil, fmt.Errorf("operational SOPS secret %q must be a mapping", secretPath) } } return document.Secrets, nil } func ForgejoRegistryDockerConfig(imageRepository, username, token string) (string, error) { registryHost := strings.Split(strings.TrimSpace(imageRepository), "/")[0] if registryHost == "" || username == "" || token == "" { return "", errors.New("delivery registry host, Forgejo username, and token are required") } config, err := json.Marshal(map[string]map[string]map[string]string{ "auths": { registryHost: {"auth": base64.StdEncoding.EncodeToString([]byte(username + ":" + token))}, }, }) if err != nil { return "", err } return string(config), nil } func InitializeOpenBao(cfg config.Config) error { kubeconfig := filepath.Join(cfg.Git.CloneParent, cfg.Talos.RepoDirName, cfg.Talos.GeneratedDir, "kubeconfig") if _, err := initializeOpenBao(kubeconfig, cfg.SOPS.RecoveryRecipient, cfg.SOPS.RecoveryIdentityPath, cfg.SOPS.RecoveryBundlePath, cfg.SOPS.AgeKeyPath, cfg.SOPS.OperationalSecretsPath); err != nil { return fmt.Errorf("initialize OpenBao: %w", err) } if err := configureOpenBaoSecretGrants(kubeconfig, cfg.SOPS.RecoveryIdentityPath, cfg.SOPS.RecoveryBundlePath, cfg.SecretGrants); err != nil { return fmt.Errorf("configure OpenBao secret grants: %w", err) } return nil } // ProvisionAppSecretIdentities rotates short-lived restricted tokens and // immediately reseeds encrypted operational state through OpenBao. func ProvisionAppSecretIdentities(cfg config.Config, app string) error { kubeconfig := filepath.Join(cfg.Git.CloneParent, cfg.Talos.RepoDirName, cfg.Talos.GeneratedDir, "kubeconfig") tokens, err := openbao.ProvisionAppSecretIdentities(kubeconfig, cfg.SOPS.RecoveryIdentityPath, cfg.SOPS.RecoveryBundlePath, app) if err != nil { return err } if err := UpsertOperationalSecret(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, "cicd/app-secret-admin", "token", tokens.Admin); err != nil { return errors.New("save app-secret admin token") } if err := UpsertOperationalSecret(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, "cicd/e2e-"+app, "token", tokens.E2E); err != nil { return errors.New("save E2E app-secret token") } return InitializeOpenBao(cfg) } 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 { return writeSOPSEncrypted(path, ageKeyPath, plaintext, "^(data|stringData)$") } func writeSOPSEncrypted(path, ageKeyPath string, plaintext []byte, encryptedRegex string) 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", encryptedRegex, "--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 SOPS file: %w", err) } return os.WriteFile(path, encrypted, 0600) } func decryptSOPSFile(path, ageKeyPath string) ([]byte, error) { command := exec.Command("sops", "--decrypt", "--output-type", "yaml", path) command.Env = append(os.Environ(), "SOPS_AGE_KEY_FILE="+ageKeyPath) return command.Output() } func sameYAML(left, right []byte) bool { var leftValue any var rightValue any return yaml.Unmarshal(left, &leftValue) == nil && yaml.Unmarshal(right, &rightValue) == nil && reflect.DeepEqual(leftValue, rightValue) } func renderDemocraticCSISecret(csi config.DemocraticCSIConfig) ([]byte, error) { return yaml.Marshal(struct { APIVersion string `yaml:"apiVersion"` Kind string `yaml:"kind"` Metadata map[string]any `yaml:"metadata"` Type string `yaml:"type"` StringData map[string]string `yaml:"stringData"` }{ APIVersion: "v1", Kind: "Secret", Metadata: map[string]any{ "name": "democratic-csi-secrets", "namespace": "democratic-storage", "labels": map[string]string{ "reconcile.fluxcd.io/watch": "Enabled", }, }, 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, _ config.Config) error { if err := waitForWebhookExternalSecret(dir); err != nil { return err } return nil } func waitForWebhookExternalSecret(dir string) error { deadline := time.Now().Add(webhookTargetTimeout) for { output, err := runWebhookCommand(dir, "kubectl", "--kubeconfig=kubeconfig", "-n", "tekton-pipelines", "get", "externalsecret/forgejo-webhook", "-o=jsonpath={.status.conditions[0].status}") if err == nil && strings.TrimSpace(string(output)) == "True" { return nil } if !time.Now().Before(deadline) { return errors.New("ExternalSecret forgejo-webhook did not become ready within the timeout; Forgejo webhook was not updated. Wait for External Secrets to recover, then safely rerun cicd-tool bootstrap --config --register-webhook") } time.Sleep(webhookTargetPollInterval) } } func ensureClusterKustomizations(clusterDir string, includeDelivery bool) 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) updated = strings.ReplaceAll(updated, " - bootstrap-secrets.sops.yaml\n", "") for _, resource := range requiredClusterKustomizations { if !strings.Contains(updated, resource) { updated += " - " + resource + "\n" } } if updated == string(content) { return nil } return os.WriteFile(path, []byte(updated), 0644) } func copyTemplateBaseComponents(templateDir, repoDir string, includeDelivery bool) error { for _, component := range templateBaseComponents { excluded := generatedTemplateFiles[component] if component == "tekton" { excluded = make(map[string]bool, len(excluded)+1) for path, skip := range generatedTemplateFiles[component] { excluded[path] = skip } excluded["apps"] = true } if err := copyDirExcept(filepath.Join(templateDir, "base", component), filepath.Join(repoDir, "base", component), true, excluded); err != nil { return err } } return nil } func copyClusterTemplate(source, destination string) error { if err := os.MkdirAll(destination, 0755); err != nil { return err } 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") content, err := os.ReadFile(path) if err == nil { if strings.Contains(string(content), "resources:") { continue } if err := os.WriteFile(path, append(content, []byte("resources:\n")...), 0644); err != nil { return err } 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, releasePath string, cfg config.Config) error { helmDir := filepath.Join(dir, ".helm") if err := os.MkdirAll(helmDir, 0755); err != nil { return err } // The generated HelmRelease is the sole Cilium chart-version authority. version, err := ciliumChartVersion(releasePath) if err != nil { return err } return utils.RunCommandInDir(dir, "helm", "upgrade", "--install", "cilium", "cilium", "--repo=https://helm.cilium.io", "--version="+version, "--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=envoy.enabled=true", "--set=gatewayAPI.enabled=true", "--set=hubble.enabled=true", "--set=hubble.relay.enabled=true", "--set=hubble.ui.enabled=true", "--set=l2announcements.enabled=true", "--set=rollOutCiliumPods=true", "--set=operator.replicas=1", "--set=operator.rollOutPods=true") } func ciliumChartVersion(path string) (string, error) { content, err := os.ReadFile(path) if err != nil { return "", err } var release struct { Spec struct { Chart struct { Spec struct { Version string `yaml:"version"` } `yaml:"spec"` } `yaml:"chart"` } `yaml:"spec"` } if err := yaml.Unmarshal(content, &release); err != nil { return "", fmt.Errorf("parse Cilium HelmRelease: %w", err) } if release.Spec.Chart.Spec.Version == "" { return "", errors.New("Cilium HelmRelease chart version is required") } return release.Spec.Chart.Spec.Version, nil } 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) } if _, err := os.Stat(target); err == nil && !overwrite { return nil } input, err := os.Open(path) if err != nil { return err } 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 } _, copyErr := io.Copy(output, input) closeInputErr := input.Close() closeOutputErr := output.Close() if copyErr != nil { return copyErr } if closeInputErr != nil { return closeInputErr } return closeOutputErr }) } 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 := runTalosctlOutput(dir, "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 := runTalosctlOutput(dir, "get", "machinestatus", "--insecure", "--nodes="+nodeAddress, "--endpoints="+nodeAddress, "--output=json"); maintenanceErr == nil { 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) } } else { if err := waitForTalosAPI(dir, cfg, nodeAddress); err != nil { return fmt.Errorf("determine Talos state for %s: %w", node.Name, err) } if err := utils.RunCommandInDir(dir, "talosctl", secureArgs...); err != nil { return fmt.Errorf("apply 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 := runTalosctlOutput(dir, "etcd", "status", "--talosconfig=./clusterconfig/talosconfig", "--endpoints="+cfg.Talos.BootstrapEndpoint, "--nodes="+cfg.Talos.BootstrapNode); err == nil { return nil } if err := waitForTalosAPI(dir, cfg, cfg.Talos.BootstrapNode); err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() command := exec.CommandContext(ctx, "talosctl", "bootstrap", "--talosconfig=./clusterconfig/talosconfig", "--endpoints="+cfg.Talos.BootstrapEndpoint, "--nodes="+cfg.Talos.BootstrapNode) command.Dir = dir command.Stdout = os.Stdout command.Stderr = os.Stderr if err := command.Run(); err != nil { if ctx.Err() != nil { return errors.New("etcd bootstrap request timed out") } return fmt.Errorf("bootstrap etcd: %w", err) } deadline := time.Now().Add(2 * time.Minute) for time.Now().Before(deadline) { if _, err := runTalosctlOutput(dir, "etcd", "status", "--talosconfig=./clusterconfig/talosconfig", "--endpoints="+cfg.Talos.BootstrapEndpoint, "--nodes="+cfg.Talos.BootstrapNode); err == nil { return nil } time.Sleep(2 * time.Second) } return errors.New("etcd did not become healthy after bootstrap") } func waitForTalosReboot(dir string, cfg config.Config, node string) error { return waitForTalosAPI(dir, cfg, node) } func waitForTalosAPI(dir string, cfg config.Config, node string) error { deadline := time.Now().Add(5 * time.Minute) for time.Now().Before(deadline) { if _, err := runTalosctlOutput(dir, "get", "machinestatus", "--output=json", "--talosconfig=./clusterconfig/talosconfig", "--endpoints="+node, "--nodes="+node); err == nil { return nil } time.Sleep(2 * time.Second) } return fmt.Errorf("Talos API did not return after applying its machine configuration") } func runTalosctlOutput(dir string, args ...string) ([]byte, error) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() command := exec.CommandContext(ctx, "talosctl", args...) command.Dir = dir return command.Output() } func waitForKubernetesAPI(dir string) error { deadline := time.Now().Add(5 * time.Minute) for time.Now().Before(deadline) { if _, err := utils.RunCommandQuietOutputInDir(dir, "kubectl", "--kubeconfig=kubeconfig", "get", "--raw=/readyz"); err == nil { return nil } time.Sleep(2 * time.Second) } return errors.New("Kubernetes API did not become ready after Talos bootstrap") } 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(filepath.Dir(terraformDir), cfg.Talos.GeneratedDir, ".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 := runTerraform(terraformDir, environment, "terraform", "init", "-input=false"); err != nil { return err } if err := importNetworkBridges(terraformDir, environment, r.Config); err != nil { return err } if r.Mode == Rebuild { if err := r.rebuildTalosVMs(terraformDir, environment); err != nil { return err } } else if err := r.importConfiguredTalosVMs(terraformDir, environment); err != nil { return err } planPath, err := terraformPlanPath(terraformDir, r.Config.ClusterID) if err != nil { return err } defer os.Remove(planPath) planArgs := []string{"plan", "-input=false"} if r.Mode == Reconcile { planArgs = append(planArgs, terraformReconcileTargets(r.Config)...) } planArgs = append(planArgs, "-out="+planPath) if err := runTerraform(terraformDir, environment, "terraform", planArgs...); err != nil { return err } return runTerraform(terraformDir, environment, "terraform", "apply", "-input=false", "-auto-approve", planPath) } func terraformReconcileTargets(cfg config.Config) []string { targets := []string{"-target=proxmox_virtual_environment_download_file.talos_iso", "-target=local_file.talconfig"} if cfg.Talos.Cluster.ManageNetworkBridges { targets = append(targets, "-target=proxmox_virtual_environment_network_linux_bridge.cluster_bridge") } return targets } func (r Runner) rebuildTalosVMs(terraformDir string, environment []string) error { if err := r.importConfiguredTalosVMs(terraformDir, environment); err != nil { var apiErr proxmox.APIError if errors.As(err, &apiErr) && (apiErr.StatusCode == 404 || apiErr.StatusCode == 500 && apiErr.Message == `{"data":null}` && strings.HasSuffix(apiErr.Path, "/config")) { return nil } return err } for attempt := 0; attempt < 3; attempt++ { err := destroyTalosVMs(terraformDir, environment) if err == nil || !strings.Contains(err.Error(), "context deadline exceeded") || attempt == 2 { return err } time.Sleep(destroyTalosVMRetryDelay) } return nil } func (r Runner) importConfiguredTalosVMs(terraformDir string, environment []string) error { if err := verifyTalosVMs(r.Config); err != nil { return err } resources, err := terraformStateResources(terraformDir, environment) if err != nil { return err } if err := verifyStateTalosVMs(resources, r.Config, false); err != nil { return err } if err := importTalosVMs(terraformDir, environment, r.Config); err != nil { return err } resources, err = terraformStateResources(terraformDir, environment) if err != nil { return err } if err := verifyStateTalosVMs(resources, r.Config, true); err != nil { return err } return nil } type terraformImport struct { Address string ID string } func importNetworkBridges(terraformDir string, environment []string, cfg config.Config) error { if !cfg.Talos.Cluster.ManageNetworkBridges { return nil } return importTerraformResources(terraformDir, environment, networkBridgeImports(cfg)) } func importTalosVMs(terraformDir string, environment []string, cfg config.Config) error { return importTerraformResources(terraformDir, environment, talosVMImports(cfg)) } func verifyConfiguredTalosVMs(cfg config.Config) error { client := proxmox.New(cfg.Talos.Proxmox.APIURL, cfg.Talos.Proxmox.APITokenID, cfg.Talos.Proxmox.APITokenSecret, cfg.Talos.Proxmox.Insecure) for _, node := range cfg.Talos.Nodes { vm, err := client.GetVM(node.ProxmoxNode, node.VMID) if err != nil { return fmt.Errorf("inspect configured Talos VM %d: %w", node.VMID, err) } if vm.Name != node.Name || vm.Description != expectedTalosVMDescription(node.Role) { return fmt.Errorf("configured Talos VM %d does not match the expected Maidn Talos VM", node.VMID) } } return nil } func verifyStateTalosVMs(resources []string, cfg config.Config, requireAll bool) error { configured := make(map[string]bool, len(cfg.Talos.Nodes)) for _, vm := range talosVMImports(cfg) { configured[vm.Address] = false } const vmAddress = "proxmox_virtual_environment_vm.vm" for _, resource := range resources { if resource == vmAddress || strings.HasPrefix(resource, vmAddress+"[") { if _, ok := configured[resource]; !ok { return fmt.Errorf("Terraform state contains unconfigured Talos VM address %q; refusing targeted destroy", resource) } configured[resource] = true } } if requireAll { for address, present := range configured { if !present { return fmt.Errorf("Terraform state is missing configured Talos VM address %q; refusing targeted destroy", address) } } } return nil } func expectedTalosVMDescription(role string) string { if role == "controlplane" { return "Talos Control Plane Node - Managed by Terraform" } return "Talos Worker Node - Managed by Terraform" } func importTerraformResources(terraformDir string, environment []string, imports []terraformImport) error { state, err := terraformStateResources(terraformDir, environment) if err != nil { return err } resources := make(map[string]bool, len(state)) for _, resource := range state { resources[resource] = true } for _, resource := range imports { if resources[resource.Address] { continue } if err := runTerraform(terraformDir, environment, "terraform", "import", "-input=false", resource.Address, resource.ID); err != nil { return fmt.Errorf("import Terraform resource: %w", err) } } return nil } func networkBridgeImports(cfg config.Config) []terraformImport { imports := map[string]terraformImport{} for _, node := range cfg.Talos.Nodes { for _, network := range node.Networks { key := fmt.Sprintf("%s-%d", node.ProxmoxNode, network.VLANID) imports[key] = terraformImport{Address: fmt.Sprintf(`proxmox_virtual_environment_network_linux_bridge.cluster_bridge["%s"]`, key), ID: fmt.Sprintf("%s:vmbr%d", node.ProxmoxNode, network.VLANID)} } } keys := make([]string, 0, len(imports)) for key := range imports { keys = append(keys, key) } sort.Strings(keys) result := make([]terraformImport, 0, len(keys)) for _, key := range keys { result = append(result, imports[key]) } return result } func talosVMImports(cfg config.Config) []terraformImport { imports := make([]terraformImport, 0, len(cfg.Talos.Nodes)) for _, node := range cfg.Talos.Nodes { imports = append(imports, terraformImport{Address: fmt.Sprintf(`proxmox_virtual_environment_vm.vm["%s"]`, node.Name), ID: fmt.Sprintf("%s/%d", node.ProxmoxNode, node.VMID)}) } return imports } func terraformPlanPath(terraformDir, clusterID string) (string, error) { return filepath.Abs(filepath.Join(terraformDir, clusterID+".tfplan")) } func installSOPSKey(dir string, cfg config.Config) error { ageKeyPath, err := filepath.Abs(cfg.SOPS.AgeKeyPath) if err != nil { return fmt.Errorf("resolve SOPS age identity: %w", err) } if _, err := os.Stat(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="+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) } type templateRevision struct { Repository string `yaml:"repository"` Ref string `yaml:"ref"` Commit string `yaml:"commit"` } type templateRevisionLock struct { Version int `yaml:"version"` CICD templateRevision `yaml:"cicd"` Manifests templateRevision `yaml:"manifests"` Talos templateRevision `yaml:"talos"` } type templateCheckout struct { Dir string Repository string Ref string GeneratedDir string } func ensureTemplateRevisions(workspace string, cfg config.Config) error { lockPath := filepath.Join(workspace, "maidn-template-revisions.yaml") checkouts := []templateCheckout{ {Dir: filepath.Join(workspace, "maidn-cicd-cluster-template"), Repository: cfg.Templates.CICDRepoURL, Ref: cfg.Templates.CICDRepoRef}, {Dir: filepath.Join(workspace, "cicd-deployment-manifests-template"), Repository: cfg.Templates.ManifestsRepoURL, Ref: cfg.Templates.ManifestsRepoRef}, {Dir: filepath.Join(cfg.Git.CloneParent, cfg.Talos.RepoDirName), Repository: cfg.Templates.TalosRepoURL, Ref: cfg.Templates.TalosRepoRef, GeneratedDir: cfg.Talos.GeneratedDir}, } lock, err := readTemplateRevisionLock(lockPath) if err == nil { revisions := []templateRevision{lock.CICD, lock.Manifests, lock.Talos} for index, checkout := range checkouts { if !sameTemplateSource(revisions[index], checkout) { return errors.New("configured template source or ref differs from its workspace revision lock; use a new empty workspaceDir to intentionally refresh templates") } if _, err := checkoutTemplateRevision(cfg, checkout, revisions[index].Commit); err != nil { return errors.New("locked template revision cannot be resolved; restore the locked commit or use a new empty workspaceDir to intentionally refresh templates") } } return nil } if !os.IsNotExist(err) { return errors.New("template revision lock is invalid; use a new empty workspaceDir to intentionally refresh templates") } revisions := make([]templateRevision, len(checkouts)) for index, checkout := range checkouts { if config.RedactURL(checkout.Repository) == "" { return errors.New("configured template source cannot be safely recorded; use a standard repository URL without embedded query credentials") } commit, err := checkoutTemplateRevision(cfg, checkout, "") if err != nil { return errors.New("configured template revision cannot be resolved; correct the template source or ref, then rerun bootstrap") } revisions[index] = templateRevision{Repository: config.RedactURL(checkout.Repository), Ref: checkout.Ref, Commit: commit} } return writeTemplateRevisionLock(lockPath, templateRevisionLock{Version: 1, CICD: revisions[0], Manifests: revisions[1], Talos: revisions[2]}) } // EnsureTemplateRevisions records or restores the workspace's template commits. func EnsureTemplateRevisions(cfg config.Config) error { if err := os.MkdirAll(cfg.WorkspaceDir, 0755); err != nil { return err } if err := os.MkdirAll(cfg.Git.CloneParent, 0755); err != nil { return err } return ensureTemplateRevisions(cfg.WorkspaceDir, cfg) } // RefreshTemplateRevisions replaces an existing template lock with configured ref heads. func RefreshTemplateRevisions(cfg config.Config) error { return refreshTemplateRevisions(cfg.WorkspaceDir, cfg) } func refreshTemplateRevisions(workspace string, cfg config.Config) error { lockPath := filepath.Join(workspace, "maidn-template-revisions.yaml") lock, err := readTemplateRevisionLock(lockPath) if err != nil { if os.IsNotExist(err) { return errors.New("template revision lock does not exist; run bootstrap without --refresh-template-revisions first") } return errors.New("template revision lock is invalid") } checkouts := []templateCheckout{ {Dir: filepath.Join(workspace, "maidn-cicd-cluster-template"), Repository: cfg.Templates.CICDRepoURL, Ref: cfg.Templates.CICDRepoRef}, {Dir: filepath.Join(workspace, "cicd-deployment-manifests-template"), Repository: cfg.Templates.ManifestsRepoURL, Ref: cfg.Templates.ManifestsRepoRef}, {Dir: filepath.Join(cfg.Git.CloneParent, cfg.Talos.RepoDirName), Repository: cfg.Templates.TalosRepoURL, Ref: cfg.Templates.TalosRepoRef, GeneratedDir: cfg.Talos.GeneratedDir}, } revisions := []templateRevision{lock.CICD, lock.Manifests, lock.Talos} for index, checkout := range checkouts { if !sameTemplateSource(revisions[index], checkout) { return errors.New("configured template source or ref differs from its workspace revision lock") } commit, err := checkoutTemplateRevision(cfg, checkout, "") if err != nil { return errors.New("configured template revision cannot be resolved; correct the template source or ref, then rerun bootstrap") } revisions[index].Commit = commit } if err := replaceTemplateRevisionLock(lockPath, templateRevisionLock{Version: 1, CICD: revisions[0], Manifests: revisions[1], Talos: revisions[2]}); err != nil { return errors.New("cannot update template revision lock") } return nil } func readTemplateRevisionLock(path string) (templateRevisionLock, error) { var lock templateRevisionLock data, err := os.ReadFile(path) if err != nil { return lock, err } decoder := yaml.NewDecoder(bytes.NewReader(data)) decoder.KnownFields(true) if err := decoder.Decode(&lock); err != nil { return lock, err } if err := decoder.Decode(&templateRevisionLock{}); !errors.Is(err, io.EOF) { return lock, errors.New("multiple YAML documents") } if lock.Version != 1 || !validTemplateRevision(lock.CICD) || !validTemplateRevision(lock.Manifests) || !validTemplateRevision(lock.Talos) { return lock, errors.New("invalid template revision lock") } return lock, nil } func writeTemplateRevisionLock(path string, lock templateRevisionLock) error { data, err := yaml.Marshal(lock) if err != nil { return err } file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) if err != nil { return err } if _, err := file.Write(data); err != nil { _ = file.Close() return err } return file.Close() } func replaceTemplateRevisionLock(path string, lock templateRevisionLock) error { data, err := yaml.Marshal(lock) if err != nil { return err } file, err := os.CreateTemp(filepath.Dir(path), ".maidn-template-revisions-") if err != nil { return err } temporaryPath := file.Name() defer os.Remove(temporaryPath) if _, err := file.Write(data); err != nil { _ = file.Close() return err } if err := file.Close(); err != nil { return err } return os.Rename(temporaryPath, path) } func sameTemplateSource(revision templateRevision, checkout templateCheckout) bool { return validTemplateRevision(revision) && revision.Repository == config.RedactURL(checkout.Repository) && revision.Ref == checkout.Ref } func validTemplateRevision(revision templateRevision) bool { if revision.Repository == "" || revision.Repository == "" || revision.Ref == "" || len(revision.Commit) < 40 || len(revision.Commit) > 64 { return false } for _, character := range revision.Commit { if !((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f')) { return false } } return true } func checkoutTemplateRevision(cfg config.Config, checkout templateCheckout, lockedCommit string) (string, error) { repository := config.RedactURL(checkout.Repository) if repository == "" { return "", errors.New("template source cannot be safely used") } run, cleanup, err := templateGitRunner(cfg, checkout.Repository, repository) if err != nil { return "", err } defer cleanup() if info, err := os.Stat(checkout.Dir); os.IsNotExist(err) { if _, err := run("", "clone", "--no-checkout", repository, checkout.Dir); err != nil { return "", err } } else if err != nil || !info.IsDir() { return "", errors.New("template checkout is not a directory") } else { inside, err := run(checkout.Dir, "rev-parse", "--is-inside-work-tree") if err != nil || strings.TrimSpace(string(inside)) != "true" { return "", errors.New("template checkout is not a Git work tree") } origin, err := run(checkout.Dir, "remote", "get-url", "origin") originValue := strings.TrimSpace(string(origin)) if err != nil || config.RedactURL(originValue) != originValue || originValue != repository { return "", errors.New("template checkout source does not match configuration") } status, err := run(checkout.Dir, "status", "--porcelain=v1", "--untracked-files=all", "-z") if err != nil || hasUnexpectedTemplateChanges(status, checkout.GeneratedDir) { return "", errors.New("template checkout has uncommitted changes") } } target := checkout.Ref if lockedCommit != "" { target = lockedCommit } if _, err := run(checkout.Dir, "fetch", "origin", target); err != nil { return "", err } commit, err := run(checkout.Dir, "rev-parse", "--verify", "FETCH_HEAD^{commit}") if err != nil { return "", err } commitID := strings.TrimSpace(string(commit)) if !validTemplateRevision(templateRevision{Repository: "source", Ref: "ref", Commit: commitID}) || (lockedCommit != "" && commitID != lockedCommit) { return "", errors.New("template ref did not resolve to the expected commit") } if _, err := run(checkout.Dir, "checkout", "--detach", commitID); err != nil { return "", err } head, err := run(checkout.Dir, "rev-parse", "--verify", "HEAD^{commit}") if err != nil || strings.TrimSpace(string(head)) != commitID { return "", errors.New("template checkout did not reach the expected commit") } return commitID, nil } func hasUnexpectedTemplateChanges(status []byte, generatedDir string) bool { generatedDir = filepath.ToSlash(filepath.Clean(generatedDir)) if generatedDir == "." || generatedDir == ".." || strings.HasPrefix(generatedDir, "../") || filepath.IsAbs(generatedDir) { return len(status) != 0 } if len(status) != 0 && status[len(status)-1] != 0 { return true } records := bytes.Split(status, []byte{0}) for index := 0; index < len(records)-1; index++ { record := records[index] if len(record) < 4 || record[2] != ' ' || !generatedTemplatePath(string(record[3:]), generatedDir) { return true } if record[0] == 'R' || record[0] == 'C' { index++ if index >= len(records)-1 || !generatedTemplatePath(string(records[index]), generatedDir) { return true } } } return false } func generatedTemplatePath(path, generatedDir string) bool { path = filepath.ToSlash(filepath.Clean(filepath.FromSlash(path))) return path == generatedDir || strings.HasPrefix(path, generatedDir+"/") } func templateGitRunner(cfg config.Config, source, repository string) (func(string, ...string) ([]byte, error), func(), error) { sourceURL, sourceErr := url.Parse(source) username, token := cfg.Git.Username, cfg.Git.Token hasSourceCredentials := false if sourceErr == nil && sourceURL.User != nil { if password, hasPassword := sourceURL.User.Password(); hasPassword { username, token = sourceURL.User.Username(), password hasSourceCredentials = true } } if !hasSourceCredentials { repositoryURL, repositoryErr := url.Parse(repository) gitURL, gitErr := url.Parse(config.RedactURL(cfg.Git.BaseURL)) if repositoryErr != nil || gitErr != nil || !strings.EqualFold(repositoryURL.Host, gitURL.Host) { return runGit, func() {}, nil } } if token == "" { return runGit, func() {}, nil } cleanup, environment, err := forgejo.GitEnvironment(username, token) if err != nil { return nil, nil, err } return func(dir string, args ...string) ([]byte, error) { return runGitEnvironment(dir, environment, args...) }, cleanup, nil } func ensureTalosConfig(generatedDir string, cfg config.Config) error { talhelperPath := "talhelper" secretPath := filepath.Join(generatedDir, "talsecret.yaml") if _, err := os.Stat(secretPath); os.IsNotExist(err) { legacyPath := filepath.Join(generatedDir, "talsecret.sops.yaml") if _, legacyErr := os.Stat(legacyPath); legacyErr == nil { secretPath = legacyPath } else { secrets, err := utils.RunCommandOutputInDir(generatedDir, talhelperPath, "gensecret") if err != nil { return err } if err := os.WriteFile(secretPath, secrets, 0600); err != nil { return err } } } 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_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 { isoStorage = "local" } isoFilename := fmt.Sprintf("talos-%s-%s-%s.iso", strings.TrimPrefix(cfg.Talos.Image.TalosVersion, "v"), cfg.Talos.Image.SchematicID[:12], cfg.Talos.Image.Architecture) builder.WriteString(fmt.Sprintf("talos_iso_file = %q\n", fmt.Sprintf("%s:iso/%s", isoStorage, isoFilename))) builder.WriteString(fmt.Sprintf("talos_image_update_mode = %q\n", cfg.Talos.Image.UpdateMode)) builder.WriteString(fmt.Sprintf("talos_image_storage = %q\n", isoStorage)) builder.WriteString(fmt.Sprintf("talos_image_architecture = %q\n", cfg.Talos.Image.Architecture)) builder.WriteString(fmt.Sprintf("disk_storage = %q\n", cfg.Talos.Cluster.DiskStorage)) builder.WriteString(fmt.Sprintf("additional_disk_storage = %q\n", cfg.Talos.Cluster.AdditionalStorage)) 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 := 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 := 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 { builder.WriteString(" {\n") builder.WriteString(fmt.Sprintf(" name = %q\n", node.Name)) builder.WriteString(fmt.Sprintf(" vmid = %d\n", node.VMID)) builder.WriteString(fmt.Sprintf(" role = %q\n", node.Role)) builder.WriteString(fmt.Sprintf(" cores = %d\n", node.Cores)) builder.WriteString(fmt.Sprintf(" memory = %d\n", node.Memory)) builder.WriteString(fmt.Sprintf(" disk_size = %q\n", node.DiskSize)) if node.AdditionalDiskSize != "" { builder.WriteString(fmt.Sprintf(" additional_disk_size = %q\n", node.AdditionalDiskSize)) } builder.WriteString(fmt.Sprintf(" tags = [%s]\n", quoteList(node.Tags))) builder.WriteString(fmt.Sprintf(" proxmox_node = %q\n", node.ProxmoxNode)) builder.WriteString(" networks = [\n") for _, net := range node.Networks { builder.WriteString(" {\n") builder.WriteString(fmt.Sprintf(" mac_address = %q\n", net.MACAddress)) builder.WriteString(fmt.Sprintf(" cidr = %q\n", net.CIDR)) if net.IP != "" { builder.WriteString(fmt.Sprintf(" ip = %q\n", net.IP)) } if net.Gateway != "" { builder.WriteString(fmt.Sprintf(" gateway = %q\n", net.Gateway)) } builder.WriteString(fmt.Sprintf(" vlan_id = %d\n", net.VLANID)) builder.WriteString(" },\n") } builder.WriteString(" ]\n") builder.WriteString(" },\n") } builder.WriteString("]\n") return builder.String() } func quoteList(values []string) string { quoted := make([]string, 0, len(values)) for _, value := range values { quoted = append(quoted, fmt.Sprintf("%q", value)) } 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 }