diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index ad141fb..be787b3 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -62,6 +62,9 @@ func runBootstrap(cmd *cobra.Command, args []string) error { if err != nil { return err } + if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil { + return err + } return createForgejoRegistryToken(cfg) } if bootstrapRotateWebhookAuthorization { @@ -89,6 +92,9 @@ func runBootstrap(cmd *cobra.Command, args []string) error { if err != nil { return err } + if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil { + return err + } return bootstrap.InitializeOpenBao(cfg) } if bootstrapMergeBootstrapPR { @@ -99,6 +105,9 @@ func runBootstrap(cmd *cobra.Command, args []string) error { if err != nil { return err } + if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil { + return err + } manager := forgejo.NewRepoManager(cfg.Git.BaseURL, cfg.Git.Token, cfg.Git.Owner, cfg.Git.Username, "", "", cfg.Flux.Branch, "") if err := manager.MergePullRequest(cfg.Flux.RepoName, "maidn/bootstrap-"+cfg.ClusterID); err != nil { return err @@ -112,6 +121,9 @@ func runBootstrap(cmd *cobra.Command, args []string) error { if err != nil { return err } + if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil { + return err + } branch, err := forgejo.CurrentBranch(bootstrapPublishAppFrom) if err != nil { return err diff --git a/docs/operations.md b/docs/operations.md index 574bdf1..d177efd 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -15,6 +15,14 @@ go run . bootstrap --config --mode=reconcile This is the only regular lifecycle command. Do not use direct `kubectl apply`, `flux reconcile`, Helm upgrades, or mutating `talosctl` commands. +Bootstrap records the CICD, manifests, and Talos template commits in the +workspace's `maidn-template-revisions.yaml` before it creates migration PRs or +touches infrastructure. Later runs require the same template sources and refs +and check out those commits, even when a configured branch advances. To accept +new template revisions intentionally, use a new empty `workspaceDir` (and a +fresh `cloneParent` when it is configured separately) and keep the prior +secret-bearing workspace intact for recovery. + ## Rebuild Use only when an authorized recovery requires recreating the Talos VM: diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index ad0d5b0..910dd44 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -52,6 +52,14 @@ var ensureForgejoWebhook = func(cfg config.Config, repo, webhookURL, authorizati 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 webhookTargetTimeout = 70 * time.Minute var webhookTargetPollInterval = 2 * time.Second @@ -66,7 +74,7 @@ func (r Runner) Run() error { return err } r.Config = resolved - if err := config.Preflight(r.Config); err != nil { + if err := preflight(r.Config); err != nil { return fmt.Errorf("preflight: %w", err) } if _, err := os.Stat(r.Config.SOPS.AgeKeyPath); err != nil { @@ -92,28 +100,17 @@ func (r Runner) Run() error { if r.RegisterWebhook { return r.reconcileWebhook(filepath.Join(r.Config.Git.CloneParent, r.Config.Talos.RepoDirName, r.Config.Talos.GeneratedDir)) } - workspace := r.Config.WorkspaceDir - if err := os.MkdirAll(workspace, 0755); err != nil { + if err := EnsureTemplateRevisions(r.Config); err != nil { return err } 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 { - 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") - if err := ensureRepo(cicdTemplateDir, r.Config.Templates.CICDRepoURL, r.Config.Templates.CICDRepoRef); err != nil { - return err - } 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 { @@ -183,9 +180,6 @@ func (r Runner) Run() error { } 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 { - return err - } terraformDir := filepath.Join(repoDir, r.Config.Talos.TerraformDir) generatedDir := filepath.Join(repoDir, r.Config.Talos.GeneratedDir) @@ -822,17 +816,175 @@ func configureFluxSOPS(dir string) error { 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) +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 +} + +func ensureTemplateRevisions(workspace string, cfg config.Config) error { + lockPath := filepath.Join(workspace, "maidn-template-revisions.yaml") + checkouts := []templateCheckout{ + {filepath.Join(workspace, "maidn-cicd-cluster-template"), cfg.Templates.CICDRepoURL, cfg.Templates.CICDRepoRef}, + {filepath.Join(workspace, "cicd-deployment-manifests-template"), cfg.Templates.ManifestsRepoURL, cfg.Templates.ManifestsRepoRef}, + {filepath.Join(cfg.Git.CloneParent, cfg.Talos.RepoDirName), cfg.Templates.TalosRepoURL, cfg.Templates.TalosRepoRef}, } - if err := utils.RunCommandInDir(dir, "git", "fetch", "origin"); err != nil { + 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(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(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 := utils.RunCommandInDir(dir, "git", "checkout", ref); err != nil { + if err := os.MkdirAll(cfg.Git.CloneParent, 0755); err != nil { return err } - return utils.RunCommandInDir(dir, "git", "pull", "--ff-only", "origin", ref) + return ensureTemplateRevisions(cfg.WorkspaceDir, cfg) +} + +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 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(checkout templateCheckout, lockedCommit string) (string, error) { + repository := config.RedactURL(checkout.Repository) + if repository == "" { + return "", errors.New("template source cannot be safely used") + } + if info, err := os.Stat(checkout.Dir); os.IsNotExist(err) { + if _, err := runGit("", "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 := runGit(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 := runGit(checkout.Dir, "remote", "get-url", "origin") + if err != nil || config.RedactURL(strings.TrimSpace(string(origin))) != repository { + return "", errors.New("template checkout source does not match configuration") + } + status, err := runGit(checkout.Dir, "status", "--porcelain") + if err != nil || strings.TrimSpace(string(status)) != "" { + return "", errors.New("template checkout has uncommitted changes") + } + } + + target := checkout.Ref + if lockedCommit != "" { + target = lockedCommit + } + if _, err := runGit(checkout.Dir, "fetch", "origin", target); err != nil { + return "", err + } + commit, err := runGit(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 := runGit(checkout.Dir, "checkout", "--detach", commitID); err != nil { + return "", err + } + head, err := runGit(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 ensureTalosConfig(generatedDir string, cfg config.Config) error { diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index 9c8a0cb..d970873 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -345,6 +345,73 @@ func TestWebhookTargetTimeoutExceedsExternalSecretRefreshInterval(t *testing.T) } } +func TestRunnerRegisterWebhookSkipsTemplateRevisions(t *testing.T) { + originalPreflight := preflight + originalGit := runGit + originalInitialize := initializeOpenBao + originalCommand := runWebhookCommand + originalWebhook := ensureForgejoWebhook + t.Cleanup(func() { + preflight = originalPreflight + runGit = originalGit + initializeOpenBao = originalInitialize + runWebhookCommand = originalCommand + ensureForgejoWebhook = originalWebhook + }) + + workspace := t.TempDir() + ageKeyPath := filepath.Join(workspace, "age-key.txt") + if err := os.WriteFile(ageKeyPath, nil, 0600); err != nil { + t.Fatal(err) + } + preflight = func(config.Config) error { return nil } + runGit = func(string, ...string) ([]byte, error) { + t.Fatal("webhook-only reconciliation must not access template repositories") + return nil, nil + } + authorization := "Bearer test-webhook-authorization" + initializeOpenBao = func(string, string, string, string, string, string) (map[string]map[string]string, error) { + return map[string]map[string]string{"cicd/forgejo-webhook": {"authorization": authorization}}, nil + } + runWebhookCommand = func(_ string, _ string, args ...string) ([]byte, error) { + if strings.Contains(strings.Join(args, " "), "secret/forgejo-webhook") { + return []byte(base64.StdEncoding.EncodeToString([]byte(authorization))), nil + } + return nil, nil + } + ensureForgejoWebhook = func(_ config.Config, _, _, observedAuthorization string) error { + if observedAuthorization != authorization { + t.Fatal("webhook reconciliation used the wrong authorization") + } + return nil + } + + cfg := config.Config{ + ClusterID: "test-cluster", + WorkspaceDir: workspace, + Git: config.GitConfig{Provider: "forgejo", BaseURL: "https://git.example.test", Username: "bot", Token: "token", Owner: "test-org", CloneParent: filepath.Join(workspace, "checkouts")}, + Flux: config.FluxConfig{RepoName: "cluster", Branch: "main", ClusterPath: "./clusters/test", ClusterDomain: "example.test", ManifestsRepo: "manifests"}, + Templates: config.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: config.CiliumConfig{TrafficInterface: "eth1", LoadBalancerStart: "192.168.45.19", LoadBalancerEnd: "192.168.45.30"}, + DemocraticCSI: config.DemocraticCSIConfig{TrueNASAPIKey: "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"}, + Delivery: config.DeliveryConfig{AppName: "app", AppRepoURL: "https://git.example.test/app.git", AppRepoRef: "main", ImageRepository: "registry.example.test/test/app", WebhookHostname: "tekton.example.test", WebhookPath: "/"}, + SOPS: config.SOPSConfig{AgeKeyPath: ageKeyPath}, + Talos: config.TalosConfig{ + RepoDirName: "talos", TerraformDir: "terraform", GeneratedDir: "generated", ConfigFileName: "terraform.tfvars", + Proxmox: config.TalosProxmoxConfig{APIURL: "https://proxmox.example.test:8006", APITokenID: "id", APITokenSecret: "secret"}, + Cluster: config.TalosClusterConfig{Name: "test-cluster", Domain: "example.test"}, + Image: config.TalosImageConfig{TalosVersion: "v1.13.6", KubernetesVersion: "v1.33.4", SchematicID: "abcdefghijkl"}, + Nodes: []config.TalosNode{{Name: "cp-01", VMID: 100, Role: "controlplane", Networks: []config.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}}}}, + }, + } + if err := (Runner{Config: cfg, RegisterWebhook: true}).Run(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(workspace, "maidn-template-revisions.yaml")); !os.IsNotExist(err) { + t.Fatal("webhook-only reconciliation created a template revision lock") + } +} + func TestReconcileWebhookWaitsForRefreshedTargetSecret(t *testing.T) { originalInitialize := initializeOpenBao originalCommand := runWebhookCommand diff --git a/internal/bootstrap/template_revisions_test.go b/internal/bootstrap/template_revisions_test.go new file mode 100644 index 0000000..531e5f3 --- /dev/null +++ b/internal/bootstrap/template_revisions_test.go @@ -0,0 +1,202 @@ +package bootstrap + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Pingu-Studio/MaidnCLI/internal/config" +) + +func TestEnsureTemplateRevisionsLocksFirstCheckout(t *testing.T) { + workspace, cfg, git := templateRevisionTestConfig(t) + original := runGit + runGit = git.run + t.Cleanup(func() { runGit = original }) + + if err := ensureTemplateRevisions(workspace, cfg); err != nil { + t.Fatal(err) + } + lock, err := readTemplateRevisionLock(filepath.Join(workspace, "maidn-template-revisions.yaml")) + if err != nil { + t.Fatal(err) + } + if lock.CICD.Commit != git.commits["cicd"] || lock.Manifests.Commit != git.commits["manifests"] || lock.Talos.Commit != git.commits["talos"] { + t.Fatalf("lock did not record checked-out commits: %#v", lock) + } + data, err := os.ReadFile(filepath.Join(workspace, "maidn-template-revisions.yaml")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "template-password") || strings.Contains(git.commands(), "template-password") { + t.Fatal("template credentials reached the lock or Git command arguments") + } +} + +func TestEnsureTemplateRevisionsReusesLockedCommitAfterBranchDrift(t *testing.T) { + workspace, cfg, git := templateRevisionTestConfig(t) + original := runGit + runGit = git.run + t.Cleanup(func() { runGit = original }) + + if err := ensureTemplateRevisions(workspace, cfg); err != nil { + t.Fatal(err) + } + locked := git.commits["cicd"] + git.commits["cicd"] = strings.Repeat("d", 40) + git.resetCalls() + if err := ensureTemplateRevisions(workspace, cfg); err != nil { + t.Fatal(err) + } + if git.fetched["cicd"] != locked || git.checkedOut["cicd"] != locked { + t.Fatalf("branch drift changed locked CICD revision: fetched %q, checked out %q", git.fetched["cicd"], git.checkedOut["cicd"]) + } + if strings.Contains(git.commands(), "fetch origin main") { + t.Fatal("later run fetched a mutable branch instead of the lock commit") + } +} + +func TestEnsureTemplateRevisionsRejectsChangedRefWithoutGit(t *testing.T) { + workspace, cfg, git := templateRevisionTestConfig(t) + original := runGit + runGit = git.run + t.Cleanup(func() { runGit = original }) + + if err := ensureTemplateRevisions(workspace, cfg); err != nil { + t.Fatal(err) + } + git.resetCalls() + cfg.Templates.CICDRepoRef = "release" + err := ensureTemplateRevisions(workspace, cfg) + if err == nil || !strings.Contains(err.Error(), "workspace revision lock") || !strings.Contains(err.Error(), "new empty workspaceDir") { + t.Fatalf("changed ref error was not safe and actionable: %v", err) + } + if git.commands() != "" { + t.Fatal("changed ref touched Git before rejecting the lock mismatch") + } +} + +func TestEnsureTemplateRevisionsHidesSourceWhenLockedCommitIsUnavailable(t *testing.T) { + workspace, cfg, git := templateRevisionTestConfig(t) + original := runGit + runGit = git.run + t.Cleanup(func() { runGit = original }) + + if err := ensureTemplateRevisions(workspace, cfg); err != nil { + t.Fatal(err) + } + git.failFetch = true + err := ensureTemplateRevisions(workspace, cfg) + if err == nil || !strings.Contains(err.Error(), "locked template revision cannot be resolved") || !strings.Contains(err.Error(), "new empty workspaceDir") || strings.Contains(err.Error(), "template-password") { + t.Fatalf("locked revision failure exposed source details or lacked recovery guidance: %v", err) + } +} + +func templateRevisionTestConfig(t *testing.T) (string, config.Config, *fakeTemplateGit) { + t.Helper() + workspace := t.TempDir() + cloneParent := filepath.Join(workspace, "checkouts") + if err := os.MkdirAll(cloneParent, 0755); err != nil { + t.Fatal(err) + } + git := &fakeTemplateGit{ + commits: map[string]string{ + "cicd": strings.Repeat("a", 40), + "manifests": strings.Repeat("b", 40), + "talos": strings.Repeat("c", 40), + }, + origins: map[string]string{}, + fetched: map[string]string{}, + checkedOut: map[string]string{}, + } + return workspace, config.Config{ + WorkspaceDir: workspace, + Git: config.GitConfig{CloneParent: cloneParent}, + Talos: config.TalosConfig{RepoDirName: "talos"}, + Templates: config.TemplateConfig{ + CICDRepoURL: "https://reader:template-password@git.example.test/templates/cicd.git", + CICDRepoRef: "main", + ManifestsRepoURL: "https://git.example.test/templates/manifests.git", + ManifestsRepoRef: "main", + TalosRepoURL: "https://git.example.test/templates/talos.git", + TalosRepoRef: "main", + }, + }, git +} + +type fakeTemplateGit struct { + commits map[string]string + origins map[string]string + fetched map[string]string + checkedOut map[string]string + calls []string + failFetch bool +} + +func (git *fakeTemplateGit) run(dir string, args ...string) ([]byte, error) { + git.calls = append(git.calls, strings.Join(args, " ")) + if len(args) == 0 { + return nil, errors.New("missing Git command") + } + switch args[0] { + case "clone": + dir = args[len(args)-1] + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, err + } + git.origins[dir] = args[len(args)-2] + return nil, nil + case "remote": + return []byte(git.origins[dir] + "\n"), nil + case "status": + return nil, nil + case "fetch": + if git.failFetch { + return nil, errors.New("not found") + } + target := args[len(args)-1] + name := git.templateName(dir) + if len(target) == 40 { + git.fetched[name] = target + } else { + git.fetched[name] = git.commits[name] + } + return nil, nil + case "checkout": + git.checkedOut[git.templateName(dir)] = args[len(args)-1] + return nil, nil + case "rev-parse": + if len(args) == 2 && args[1] == "--is-inside-work-tree" { + return []byte("true\n"), nil + } + target := args[len(args)-1] + if target == "FETCH_HEAD^{commit}" { + return []byte(git.fetched[git.templateName(dir)] + "\n"), nil + } + if target == "HEAD^{commit}" { + return []byte(git.checkedOut[git.templateName(dir)] + "\n"), nil + } + } + return nil, errors.New("unexpected Git command") +} + +func (git *fakeTemplateGit) templateName(dir string) string { + switch filepath.Base(dir) { + case "maidn-cicd-cluster-template": + return "cicd" + case "cicd-deployment-manifests-template": + return "manifests" + default: + return "talos" + } +} + +func (git *fakeTemplateGit) resetCalls() { + git.calls = nil +} + +func (git *fakeTemplateGit) commands() string { + return strings.Join(git.calls, "\n") +} diff --git a/internal/config/config.go b/internal/config/config.go index f84167d..c7ad921 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/netip" + "net/url" "os" "path/filepath" "regexp" @@ -55,8 +56,16 @@ func Save(path string, cfg Config) error { func WriteRedacted(path string, cfg Config) error { redacted := cfg redacted.Git.Token = "" + redacted.Git.BaseURL = RedactURL(redacted.Git.BaseURL) redacted.Talos.Proxmox.APITokenSecret = "" + redacted.Talos.Proxmox.APIURL = RedactURL(redacted.Talos.Proxmox.APIURL) redacted.DemocraticCSI.TrueNASAPIKey = "" + redacted.Templates.TalosRepoURL = RedactURL(redacted.Templates.TalosRepoURL) + redacted.Templates.CICDRepoURL = RedactURL(redacted.Templates.CICDRepoURL) + redacted.Templates.ManifestsRepoURL = RedactURL(redacted.Templates.ManifestsRepoURL) + redacted.Templates.TektonCatalogRepoURL = RedactURL(redacted.Templates.TektonCatalogRepoURL) + redacted.Delivery.AppRepoURL = RedactURL(redacted.Delivery.AppRepoURL) + redacted.Delivery.ImageRepository = RedactURL(redacted.Delivery.ImageRepository) data, err := yaml.Marshal(redacted) if err != nil { return err @@ -64,6 +73,28 @@ func WriteRedacted(path string, cfg Config) error { return os.WriteFile(path, data, 0600) } +// RedactURL removes credential-bearing URL components before persistent output. +func RedactURL(value string) string { + if strings.Contains(value, "://") { + parsed, err := url.Parse(value) + if err != nil { + return "" + } + parsed.User = nil + parsed.RawQuery = "" + parsed.ForceQuery = false + parsed.Fragment = "" + return parsed.String() + } + if at := strings.Index(value, "@"); at > 0 && !strings.Contains(value[:at], "/") && strings.Contains(value[at+1:], ":") { + return value[at+1:] + } + if strings.ContainsAny(value, "@?#") { + return "" + } + return value +} + func Resolve(cfg Config) (Config, error) { applyDefaults(&cfg) return cfg, Validate(cfg) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3f3e126..5d72fda 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -62,6 +62,9 @@ func TestLoadRawAllowsCompletionBeforeValidation(t *testing.T) { func TestWriteRedactedOmitsCredentials(t *testing.T) { cfg := validConfig(t) + cfg.Git.BaseURL = "https://reader:git-token@git.example.test" + cfg.Templates.CICDRepoURL = "https://reader:template-token@git.example.test/template.git?access_token=query-token" + cfg.Delivery.ImageRepository = "reader:registry-token@registry.example.test/team/app" path := filepath.Join(t.TempDir(), "resolved.yaml") if err := WriteRedacted(path, cfg); err != nil { t.Fatal(err) @@ -70,8 +73,8 @@ func TestWriteRedactedOmitsCredentials(t *testing.T) { 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) + if strings.Contains(string(data), "token: token") || strings.Contains(string(data), "apiTokenSecret: secret") || strings.Contains(string(data), "truenasApiKey: api-key") || strings.Contains(string(data), "git-token") || strings.Contains(string(data), "template-token") || strings.Contains(string(data), "query-token") || strings.Contains(string(data), "registry-token") { + t.Fatal("redacted config contains credentials") } }