feat: harden platform operations #6
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -15,6 +15,14 @@ go run . bootstrap --config <private-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:
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
if err := utils.RunCommandInDir(dir, "git", "fetch", "origin"); err != nil {
|
||||
|
||||
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},
|
||||
}
|
||||
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) == "<redacted>" {
|
||||
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 == "<redacted>" || 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 == "<redacted>" {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
202
internal/bootstrap/template_revisions_test.go
Normal file
202
internal/bootstrap/template_revisions_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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 "<redacted>"
|
||||
}
|
||||
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 "<redacted>"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func Resolve(cfg Config) (Config, error) {
|
||||
applyDefaults(&cfg)
|
||||
return cfg, Validate(cfg)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue