diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..eae8351d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,23 @@ +* text=auto eol=lf + +# Go source must keep LF endings; gofmt rejects CRLF. +*.go text eol=lf + +# Go module / sum / config files. +go.mod text eol=lf +go.sum text eol=lf +*.yaml text eol=lf +*.yml text eol=lf +*.sh text eol=lf + +# Binaries +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary + +# pkg/generated re-exports the embedded chart map produced by `go +# embed`; the file is mechanical glue, not hand-edited. Marking it +# generated keeps it out of GitHub language stats and collapses it +# in PR diffs by default. +pkg/generated/** linguist-generated diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b9f6ec07..e9148530 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -22,6 +22,30 @@ jobs: - name: Run tests run: go test ./... + lint: + # Run golangci-lint on the same OS matrix as test:. The Windows + # runner is essential — secureperm_windows.go is build-tagged + # (//go:build windows) and never gets evaluated on a Linux/macOS + # host. Without a Windows lint pass, build-tagged files diverge + # from the rest of the tree silently. + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: stable + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v7 + with: + version: v2.12.2 + args: --timeout=5m + dco: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index feb70910..d3d615ee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ talm dist/ +.claude/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..22e96969 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,151 @@ +version: "2" + +linters: + default: all + disable: + - depguard + - exhaustruct + - gochecknoinits + - wsl + - lll + - errchkjson + - ireturn + - gocheckcompilerdirectives + # noinlineerr: this codebase wholesale uses 'if err := X(); err != nil' inline form + # — 219+ occurrences across packages. Converting to plain-assignment style would + # produce a noisy diff with zero correctness benefit AND create variable-scope leaks + # in many places where err is locally scoped to the check. + - noinlineerr + # gomodguard: deprecated in v2.12+ in favour of gomodguard_v2; we don't use + # either (no allow/blocklists configured), so disable to silence the warning. + - gomodguard + settings: + dupl: + threshold: 100 + goconst: + min-len: 2 + min-occurrences: 2 + gocritic: + disabled-checks: + - dupImport + - unnamedResult + enabled-tags: + - diagnostic + - experimental + - opinionated + - performance + - style + funlen: + lines: 60 + statements: 60 + gomoddirectives: + # The cozystack fork of Talos carries a downstream-only patch + # (siderolabs/talos#12652, --skip-verify) that upstream declined. + # Until that flag lands upstream, the replace directive is the + # only way to consume the fork — it is not generic dependency + # rewriting and must stay. + replace-allow-list: + - github.com/siderolabs/talos + - github.com/siderolabs/talos/pkg/machinery + gocyclo: + min-complexity: 15 + cyclop: + max-complexity: 15 + mnd: + ignored-numbers: + - "10" + - "100" + - "1000" + - "2" + - "60" + - "60.0" + - "64" + - "500" + nolintlint: + require-explanation: true + require-specific: true + allow-unused: false + varnamelen: + max-distance: 5 + min-name-length: 3 + check-receiver: false + check-return: false + ignore-type-assert-ok: false + ignore-map-index-ok: false + ignore-chan-recv-ok: false + ignore-decls: + - wg sync.WaitGroup + - wg *sync.WaitGroup + - mu sync.Mutex + - ok bool + ignore-names: + - i + - w + - r + - b + - c + - m + - n + - tt + - rw + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - generated\.go$ + - pkg/generated/ + - \.claude/ + rules: + - linters: + - funlen + - dupl + - gocognit + - gocyclo + - cyclop + - errcheck + - testableexamples + - testpackage + - forcetypeassert + - gocritic + - nlreturn + - wsl_v5 + - varnamelen + - unparam + - modernize + - gosec + - testifylint + - perfsprint + - paralleltest + - maintidx + # goconst on _test.go: tests intentionally repeat literals + # (IPs, CIDRs, MAC addresses, YAML keys) inside backtick raw + # strings that are EXPECTED template outputs, alongside Go + # string literals used as assertion values. Substituting the + # Go literal into a const desynchronises it from the + # backtick fixture, breaking the test silently. Empirically + # observed during the strict-lint adoption pass: every + # blanket goconst substitution in pkg/engine/contract_*.go + # broke at least one TestRender* case. Sub-agent attempts + # failed for the same reason. Disable goconst on test files. + - goconst + path: _test\.go + +formatters: + enable: + - gofmt + - gofumpt + - goimports + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - generated\.go$ + - pkg/generated/ + - \.claude/ diff --git a/charts/charts.go b/charts/charts.go index 9ab5e718..1c663fd3 100644 --- a/charts/charts.go +++ b/charts/charts.go @@ -6,8 +6,12 @@ import ( "path" "regexp" "strings" + + "github.com/cockroachdb/errors" ) +const presetGenericName = "generic" + //go:embed all:cozystack all:generic all:talm var embeddedCharts embed.FS @@ -16,83 +20,87 @@ var embeddedCharts embed.FS func PresetFiles() (map[string]string, error) { filesMap := make(map[string]string) regex := regexp.MustCompile(`(name|version): \S+`) - - err := fs.WalkDir(embeddedCharts, ".", func(filePath string, d fs.DirEntry, err error) error { + + err := fs.WalkDir(embeddedCharts, ".", func(filePath string, entry fs.DirEntry, err error) error { if err != nil { - return err + // WalkDir surfaces a plain *fs.PathError on failure; + // wrap with the offending path so a downstream caller + // reading just the error message can locate the bad file + // without re-running with extra logging. + return errors.Wrapf(err, "walking embedded charts at %q", filePath) } - - if d.IsDir() { + + if entry.IsDir() { return nil } - + // Skip talm subdirectories in preset charts (cozystack/charts/talm, generic/charts/talm) // but include files from the main talm chart (talm/templates/_helpers.tpl, etc.) - if strings.HasPrefix(filePath, "cozystack/charts/talm/") || - strings.HasPrefix(filePath, "generic/charts/talm/") { + if strings.HasPrefix(filePath, "cozystack/charts/talm/") || + strings.HasPrefix(filePath, "generic/charts/talm/") { return nil } - + // Read file content data, err := embeddedCharts.ReadFile(filePath) if err != nil { - return err + return errors.Wrapf(err, "reading embedded chart file %q", filePath) } - + content := string(data) - + // For Chart.yaml files, replace name and version with %s if path.Base(filePath) == "Chart.yaml" { content = regex.ReplaceAllString(content, "$1: %s") } - + // Use the file path as-is (relative to charts directory) filesMap[filePath] = content - + return nil }) - if err != nil { - return nil, err + return nil, errors.Wrap(err, "walking embedded charts") } - + return filesMap, nil } // AvailablePresets returns a list of available preset chart names. -// The "generic" preset is always first if it exists. +// The presetGenericName preset is always first if it exists. func AvailablePresets() ([]string, error) { - var presets []string - var hasGeneric bool - + var ( + presets []string + hasGeneric bool + ) + entries, err := embeddedCharts.ReadDir(".") if err != nil { - return nil, err + return nil, errors.Wrap(err, "reading embedded charts root") } - + for _, entry := range entries { if !entry.IsDir() { continue } - + name := entry.Name() // Skip talm as it's a library chart, not a preset if name == "talm" { continue } - - if name == "generic" { + + if name == presetGenericName { hasGeneric = true } else { presets = append(presets, name) } } - + // Put generic first if it exists if hasGeneric { - presets = append([]string{"generic"}, presets...) + presets = append([]string{presetGenericName}, presets...) } - + return presets, nil } - diff --git a/charts/charts_test.go b/charts/charts_test.go new file mode 100644 index 00000000..185900b8 --- /dev/null +++ b/charts/charts_test.go @@ -0,0 +1,75 @@ +package charts + +import ( + "strings" + "testing" +) + +// TestPresetFiles_ReturnsChartYamlsWithPlaceholders pins the success +// contract of PresetFiles. Every Chart.yaml in the embedded preset +// charts must come back with `name` and `version` rewritten to the +// `%s` placeholder so the init flow can fmt.Sprintf in the actual +// project name and version. A regression here would silently ship +// charts with a hardcoded `name: cozystack` and a fixed version — the +// init-time placeholder substitution would no-op. +func TestPresetFiles_ReturnsChartYamlsWithPlaceholders(t *testing.T) { + files, err := PresetFiles() + if err != nil { + t.Fatalf("PresetFiles: %v", err) + } + + if len(files) == 0 { + t.Fatal("PresetFiles returned empty map; embedded chart tree is missing") + } + + var foundChart bool + for path, content := range files { + if !strings.HasSuffix(path, "Chart.yaml") { + continue + } + + foundChart = true + if !strings.Contains(content, "name: %s") { + t.Errorf("%s missing `name: %%s` placeholder; init substitution would no-op", path) + } + if !strings.Contains(content, "version: %s") { + t.Errorf("%s missing `version: %%s` placeholder; init substitution would no-op", path) + } + } + + if !foundChart { + t.Error("no Chart.yaml entries in PresetFiles output; the regex rewrite path was never exercised") + } +} + +// TestPresetFiles_SkipsTalmSubdirectoriesUnderPresets pins the +// embedded-tree filtering rule: cozystack/charts/talm/** and +// generic/charts/talm/** are excluded (they're transitively re- +// embedded library copies that would shadow the canonical talm/ +// chart at init time), but talm/** itself stays. Without this filter, +// init's preset materialisation would write conflicting Chart.yaml +// files at multiple paths. +func TestPresetFiles_SkipsTalmSubdirectoriesUnderPresets(t *testing.T) { + files, err := PresetFiles() + if err != nil { + t.Fatalf("PresetFiles: %v", err) + } + + for path := range files { + if strings.HasPrefix(path, "cozystack/charts/talm/") || + strings.HasPrefix(path, "generic/charts/talm/") { + t.Errorf("file %q must be excluded; preset talm subdirectory leaks shadow the canonical talm/ chart", path) + } + } + + var sawTalmRoot bool + for path := range files { + if strings.HasPrefix(path, "talm/") { + sawTalmRoot = true + break + } + } + if !sawTalmRoot { + t.Error("expected at least one talm/ entry; the canonical talm chart was filtered out") + } +} diff --git a/go.mod b/go.mod index ac02ebf0..26f24958 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/cozystack/talm -go 1.26.2 +go 1.26.3 // Kubernetes dependencies sharing the same version. require ( @@ -96,7 +96,6 @@ require ( github.com/Masterminds/sprig/v3 v3.3.0 github.com/cockroachdb/errors v1.13.0 github.com/gobwas/glob v0.2.3 - github.com/pkg/errors v0.9.1 github.com/siderolabs/talos v1.12.6 helm.sh/helm/v3 v3.20.2 ) @@ -227,6 +226,7 @@ require ( github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pkg/xattr v0.4.12 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20250313105119-ba97887b0a25 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect diff --git a/main.go b/main.go index c4044789..3d5dd185 100644 --- a/main.go +++ b/main.go @@ -19,17 +19,45 @@ import ( "github.com/spf13/cobra" ) +const ( + // initSubcommandName is the cobra subcommand that creates the + // project Chart.yaml and the init-time prefix check in main() + // branches on. + initSubcommandName = "init" + // completionSubcommand is cobra's user-facing shell-completion + // subcommand (talm completion bash | zsh | fish). + completionSubcommand = "completion" + // completionInternal is cobra's reserved internal subcommand + // name driving Tab-key autocompletion. Constant because it + // appears both in skipConfigCommands and in cobra's exported + // API. + completionInternal = "__complete" +) + +// cmdNameTalm is the binary name used both as the cobra root +// command's Use field and via -X main.cmdNameTalm in build tooling. +const cmdNameTalm = "talm" + +// Version is the talm release tag baked at build time via ldflags +// (`-X main.Version=v0.27.0`); the literal value here is the local +// development fallback. +// +//nolint:gochecknoglobals // ldflags-injected build version, idiomatic for go release tooling. var Version = "dev" // skipConfigCommands lists commands that should not load Chart.yaml config. // - init: creates the config, so it doesn't exist yet // - completion: generates shell completion scripts -// - __complete: cobra's internal command for shell autocompletion (Tab key) -var skipConfigCommands = []string{"init", "completion", "__complete"} +// - __complete: cobra's internal command for shell autocompletion (Tab key). +// +//nolint:gochecknoglobals // immutable lookup table consulted by isCommandOrParent during PersistentPreRunE; init-time literal. +var skipConfigCommands = []string{initSubcommandName, completionSubcommand, completionInternal} // rootCmd represents the base command when called without any subcommands. +// +//nolint:gochecknoglobals // cobra root command; cobra's library design requires a stable package-level *Command. var rootCmd = &cobra.Command{ - Use: "talm", + Use: cmdNameTalm, Short: "Manage Talos the GitOps Way!", Long: ``, Version: Version, @@ -39,7 +67,8 @@ var rootCmd = &cobra.Command{ } func main() { - if err := Execute(); err != nil { + err := Execute() + if err != nil { os.Exit(1) } } @@ -72,14 +101,20 @@ func Execute() error { } errorString := err.Error() - // TODO: this is a nightmare, but arg-flag related validation returns simple `fmt.Errorf`, no way to distinguish - // these errors + //nolint:godox // tracked as #153-followup; cobra validation returns plain fmt.Errorf without a typed error, requires substring matching until cobra ships sentinel errors. + // FIXME(#153-followup): cobra arg/flag validation returns plain + // fmt.Errorf without a typed error; substring-matching the + // rendered message is the only way to distinguish those from + // our own errors today. Track a refactor to wrap cobra + // validation errors in a sentinel so this can become an + // errors.Is check. if strings.Contains(errorString, "arg(s)") || strings.Contains(errorString, "flag") || strings.Contains(errorString, "command") { fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, cmd.UsageString()) } } + //nolint:wrapcheck // cobra returns its own error chain; wrapping would change user-facing rendering and lose hints attached via cockroachdb/errors.WithHint inside command bodies. return err } @@ -93,21 +128,27 @@ func init() { // Add PersistentPreRunE to handle root detection and config loading originalPersistentPreRunE := rootCmd.PersistentPreRunE rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { - // Detect and set project root using fallback strategy - if err := commands.DetectAndSetRoot(cmd, args); err != nil { - return err + // Detect and set project root using fallback strategy. + // + err := commands.DetectAndSetRoot(cmd, args) + if err != nil { + return err //nolint:wrapcheck // DetectAndSetRoot already wraps with cockroachdb/errors.WithHint internally. } // Load config after root detection (skip for init and completion commands) if !isCommandOrParent(cmd, skipConfigCommands...) { configFile := filepath.Join(commands.Config.RootDir, "Chart.yaml") - if err := loadConfig(configFile); err != nil { - return fmt.Errorf("error loading configuration: %w", err) + + err := loadConfig(configFile) + if err != nil { + return errors.Wrap(err, "error loading configuration") } } // Ensure talosconfig path is set to project root if not explicitly set via flag // This is needed for all commands that use talosctl client (template, apply, etc.) + // + //nolint:nestif // resolution-order dispatch (--talosconfig set ? bypass : { GlobalArgs.Talosconfig set ? use it : Chart.yaml fallback ? "talosconfig" } -> abs/rel resolution); flattening would scatter the documented order across helpers. if !cmd.PersistentFlags().Changed("talosconfig") { var talosconfigPath string if commands.GlobalArgs.Talosconfig != "" { @@ -131,6 +172,7 @@ func init() { if originalPersistentPreRunE != nil { return originalPersistentPreRunE(cmd, args) } + return nil } } @@ -139,16 +181,19 @@ func initConfig() { if len(os.Args) < 2 { return } + cmdName := os.Args[1] + cmd, _, err := rootCmd.Find([]string{cmdName}) if err != nil || cmd == nil { return } + if cmd.HasParent() && cmd.Parent() != rootCmd { cmd = cmd.Parent() } - if strings.HasPrefix(cmd.Use, "init") { + if strings.HasPrefix(cmd.Use, initSubcommandName) { if strings.HasPrefix(Version, "v") { commands.Config.InitOptions.Version = strings.TrimPrefix(Version, `v`) } else { @@ -164,32 +209,51 @@ func isCommandOrParent(cmd *cobra.Command, names ...string) bool { return true } } + return false } func loadConfig(filename string) error { data, err := os.ReadFile(filename) if err != nil { - return fmt.Errorf("error reading configuration file: %w", err) + return errors.Wrap(err, "error reading configuration file") } - if err := yaml.Unmarshal(data, &commands.Config); err != nil { - return fmt.Errorf("error unmarshalling configuration: %w", err) + //nolint:musttag // commands.Config relies on default field-name matching for Chart.yaml; adding yaml tags everywhere would be a cross-package rename and an API change for chart authors. + err = yaml.Unmarshal(data, &commands.Config) + if err != nil { + return errors.Wrap(err, "error unmarshalling configuration") } + if commands.GlobalArgs.Talosconfig == "" { commands.GlobalArgs.Talosconfig = commands.Config.GlobalOptions.Talosconfig } + if commands.Config.TemplateOptions.KubernetesVersion == "" { commands.Config.TemplateOptions.KubernetesVersion = constants.DefaultKubernetesVersion } + + // Fill in the default-string path BEFORE parsing so both the + // "operator left timeout empty" and "operator supplied a value" + // branches end up with TimeoutDuration populated. The previous + // shape parsed only in the else branch, leaving TimeoutDuration + // at its zero value when the default kicked in — pre-existing + // on main since the original "fix loading defaults" landed in + // 2024. if commands.Config.ApplyOptions.Timeout == "" { commands.Config.ApplyOptions.Timeout = constants.ConfigTryTimeout.String() - } else { - var err error - commands.Config.ApplyOptions.TimeoutDuration, err = time.ParseDuration(commands.Config.ApplyOptions.Timeout) - if err != nil { - panic(err) - } } + + parsed, err := time.ParseDuration(commands.Config.ApplyOptions.Timeout) + if err != nil { + //nolint:wrapcheck // already wrapped via errors.Wrapf, WithHint adds operator-facing guidance + return errors.WithHint( + errors.Wrapf(err, "parsing applyOptions.timeout %q from %s", commands.Config.ApplyOptions.Timeout, filename), + "applyOptions.timeout in Chart.yaml must be a Go duration literal (e.g. \"30s\", \"2m\", \"1h\")", + ) + } + + commands.Config.ApplyOptions.TimeoutDuration = parsed + return nil } diff --git a/main_test.go b/main_test.go index 6a7f3807..0f7d2f16 100644 --- a/main_test.go +++ b/main_test.go @@ -1,8 +1,13 @@ package main import ( + "os" + "path/filepath" + "strings" "testing" + "github.com/cockroachdb/errors" + "github.com/cozystack/talm/pkg/commands" "github.com/spf13/cobra" ) @@ -69,6 +74,114 @@ func TestIsCommandOrParent(t *testing.T) { } } +// snapshotConfigState captures and restores the package-level +// commands.Config and commands.GlobalArgs that loadConfig mutates. +// loadConfig writes to Config (yaml.Unmarshal) AND to GlobalArgs +// (the Talosconfig-fallback assignment), so both must be saved to +// avoid cross-test leakage. +func snapshotConfigState(t *testing.T) { + t.Helper() + + savedConfig := commands.Config + savedArgs := commands.GlobalArgs + + t.Cleanup(func() { + commands.Config = savedConfig + commands.GlobalArgs = savedArgs + }) +} + +// TestLoadConfig_InvalidApplyTimeoutReturnsError pins that a +// malformed `applyOptions.timeout` in the project Chart.yaml +// surfaces as a regular error (with operator-facing hint), not a +// runtime panic. main used to call `panic(err)` here, which +// crashed the talm process before cobra could format the error; +// the error path lets the surrounding command runner print a +// cleanly-wrapped failure instead. +func TestLoadConfig_InvalidApplyTimeoutReturnsError(t *testing.T) { + dir := t.TempDir() + chartPath := filepath.Join(dir, "Chart.yaml") + body := "apiVersion: v2\nname: test\nversion: 0.1.0\napplyOptions:\n timeout: \"this-is-not-a-duration\"\n" + if err := os.WriteFile(chartPath, []byte(body), 0o644); err != nil { + t.Fatalf("write Chart.yaml: %v", err) + } + + snapshotConfigState(t) + + err := loadConfig(chartPath) + if err == nil { + t.Fatal("expected error for malformed applyOptions.timeout, got nil") + } + if !strings.Contains(err.Error(), "applyOptions.timeout") { + t.Errorf("error message must mention applyOptions.timeout (the bad field); got: %v", err) + } + if !strings.Contains(err.Error(), "this-is-not-a-duration") { + t.Errorf("error message must echo the bad value so the operator can correlate; got: %v", err) + } + + // The hint chain must keep an operator-actionable explanation + // of the expected format. Without it, the operator sees only + // "time: invalid duration" and has to guess the canonical form. + hints := errors.GetAllHints(err) + if len(hints) == 0 { + t.Errorf("expected at least one hint guiding the operator on the duration format; got bare error: %v", err) + } + combined := strings.Join(hints, "\n") + if !strings.Contains(combined, "duration") { + t.Errorf("hint chain must mention the duration format; got %q", combined) + } +} + +// TestLoadConfig_ValidApplyTimeoutParses pins the success path: +// a well-formed duration parses into TimeoutDuration. Guards +// against a refactor that flips the polarity of the error branch +// or stops storing the parsed duration. +func TestLoadConfig_ValidApplyTimeoutParses(t *testing.T) { + dir := t.TempDir() + chartPath := filepath.Join(dir, "Chart.yaml") + body := "apiVersion: v2\nname: test\nversion: 0.1.0\napplyOptions:\n timeout: \"45s\"\n" + if err := os.WriteFile(chartPath, []byte(body), 0o644); err != nil { + t.Fatalf("write Chart.yaml: %v", err) + } + + snapshotConfigState(t) + + if err := loadConfig(chartPath); err != nil { + t.Fatalf("loadConfig: %v", err) + } + if got := commands.Config.ApplyOptions.TimeoutDuration; got.String() != "45s" { + t.Errorf("TimeoutDuration = %v, want 45s", got) + } +} + +// TestLoadConfig_EmptyApplyTimeoutResolvesDefault pins that the +// default-string path also populates TimeoutDuration. Pre-existing +// on main: the parse used to live only in the else branch, so an +// empty applyOptions.timeout left TimeoutDuration at its zero +// value despite the Timeout string being filled with the default. +// The current shape parses unconditionally; this test guards a +// future refactor that splits the branches again. +func TestLoadConfig_EmptyApplyTimeoutResolvesDefault(t *testing.T) { + dir := t.TempDir() + chartPath := filepath.Join(dir, "Chart.yaml") + body := "apiVersion: v2\nname: test\nversion: 0.1.0\n" + if err := os.WriteFile(chartPath, []byte(body), 0o644); err != nil { + t.Fatalf("write Chart.yaml: %v", err) + } + + snapshotConfigState(t) + + if err := loadConfig(chartPath); err != nil { + t.Fatalf("loadConfig: %v", err) + } + if got := commands.Config.ApplyOptions.TimeoutDuration; got == 0 { + t.Errorf("TimeoutDuration is zero after loadConfig with empty applyOptions.timeout; the default-string path must parse the resolved default into the duration") + } + if got := commands.Config.ApplyOptions.Timeout; got == "" { + t.Errorf("Timeout string is empty after loadConfig; the default-string path must fill it from constants.ConfigTryTimeout") + } +} + func TestSkipConfigCommands(t *testing.T) { tests := []struct { name string diff --git a/pkg/age/age.go b/pkg/age/age.go index dcdf8c81..590f441c 100644 --- a/pkg/age/age.go +++ b/pkg/age/age.go @@ -25,11 +25,25 @@ import ( "time" "filippo.io/age" + "github.com/cockroachdb/errors" "gopkg.in/yaml.v3" "github.com/cozystack/talm/pkg/secureperm" ) +// ErrLeftoverRotationBackup is returned by RotateKeys when it detects +// `*.rotation-backup` files from a previous run still on disk. Callers +// can use errors.Is to recognise the unsafe-to-rotate state and offer +// targeted recovery guidance instead of a generic failure message. +var ErrLeftoverRotationBackup = errors.New("leftover rotation backup from a previous run (either interrupted, or successful with a failed cleanup step); inspect and remove (or restore) before retrying") + +// errInternalInvariant is the sentinel for the typed wrapper helpers +// (encryptYAMLMap / mergeAndEncryptYAMLMap) when their underlying +// recursive function returns a non-map for a map input. Reaching this +// branch means the recursive function violated its own postcondition; +// the wrap chain attached at the call site supplies the offending type. +var errInternalInvariant = errors.New("internal invariant violation: recursive helper returned wrong kind for top-level input") + const ( keyFileName = "talm.key" encryptedSecretsFile = "secrets.encrypted.yaml" @@ -38,29 +52,32 @@ const ( ageEncryptionSuffix = "]" ) -// GenerateKey generates a new age identity and saves it to talm.key file in age keygen format -// Returns true if a new key was created (not loaded from existing file) +// GenerateKey generates a new age identity and saves it to talm.key file in age keygen format. +// Returns true if a new key was created (not loaded from existing file). func GenerateKey(rootDir string) (*age.X25519Identity, bool, error) { keyFile := filepath.Join(rootDir, keyFileName) // Check if key already exists - if _, err := os.Stat(keyFile); err == nil { + _, statErr := os.Stat(keyFile) + if statErr == nil { // Key exists, load it identity, err := LoadKey(rootDir) if err != nil { - return nil, false, fmt.Errorf("failed to load existing key: %w", err) + return nil, false, errors.Wrap(err, "load existing key") } + return identity, false, nil } // Generate new key identity, err := age.GenerateX25519Identity() if err != nil { - return nil, false, fmt.Errorf("failed to generate age identity: %w", err) + return nil, false, errors.Wrap(err, "generate age identity") } - if err := secureperm.WriteFile(keyFile, []byte(formatKeyFile(identity, time.Now()))); err != nil { - return nil, false, fmt.Errorf("failed to write key file: %w", err) + writeErr := secureperm.WriteFile(keyFile, []byte(formatKeyFile(identity, time.Now()))) + if writeErr != nil { + return nil, false, errors.Wrap(writeErr, "write key file") } return identity, true, nil @@ -80,22 +97,26 @@ func formatKeyFile(identity *age.X25519Identity, now time.Time) string { ) } -// LoadKey loads age identity from talm.key file -// Supports both age keygen format (with comments) and plain format +// LoadKey loads age identity from talm.key file. +// Supports both age keygen format (with comments) and plain format. func LoadKey(rootDir string) (*age.X25519Identity, error) { keyFile := filepath.Join(rootDir, keyFileName) + keyData, err := os.ReadFile(keyFile) if err != nil { - return nil, fmt.Errorf("failed to read key file: %w", err) + return nil, errors.Wrap(err, "read key file") } // Find the secret key line (starts with AGE-SECRET-KEY) lines := strings.Split(string(keyData), "\n") + var secretKeyLine string + for _, line := range lines { line = strings.TrimSpace(line) if strings.HasPrefix(line, "AGE-SECRET-KEY-") { secretKeyLine = line + break } } @@ -107,29 +128,34 @@ func LoadKey(rootDir string) (*age.X25519Identity, error) { if strings.HasPrefix(trimmed, "AGE-SECRET-KEY-") { secretKeyLine = trimmed } else { - return nil, fmt.Errorf("no AGE-SECRET-KEY found in key file") + //nolint:wrapcheck // errors.WithHint is the project standard for attaching operator hints; the inner errors.New supplies the wrap chain. + return nil, errors.WithHint( + errors.New("no AGE-SECRET-KEY found in key file"), + "the key file must contain an AGE-SECRET-KEY-1... line, either alone (legacy plain format) or alongside age keygen comments", + ) } } identity, err := age.ParseX25519Identity(secretKeyLine) if err != nil { - return nil, fmt.Errorf("failed to parse age identity: %w", err) + return nil, errors.Wrap(err, "parse age identity") } return identity, nil } -// GetPublicKey returns the public key from an identity +// GetPublicKey returns the public key from an identity. func GetPublicKey(identity *age.X25519Identity) string { return identity.Recipient().String() } -// GetPublicKeyFromFile extracts the public key from talm.key file +// GetPublicKeyFromFile extracts the public key from talm.key file. func GetPublicKeyFromFile(rootDir string) (string, error) { keyFile := filepath.Join(rootDir, keyFileName) + keyData, err := os.ReadFile(keyFile) if err != nil { - return "", fmt.Errorf("failed to read key file: %w", err) + return "", errors.Wrap(err, "read key file") } // Find the public key line (starts with # public key:) @@ -144,349 +170,287 @@ func GetPublicKeyFromFile(rootDir string) (string, error) { // Fallback: load identity and get public key identity, err := LoadKey(rootDir) if err != nil { - return "", fmt.Errorf("failed to load key: %w", err) + return "", errors.Wrap(err, "load key") } + return identity.Recipient().String(), nil } -// EncryptSecretsFile encrypts secrets.yaml values and saves to secrets.encrypted.yaml -// Uses incremental encryption: only encrypts values that have changed +// EncryptSecretsFile encrypts secrets.yaml values and saves to secrets.encrypted.yaml. +// Uses incremental encryption: only encrypts values that have changed. func EncryptSecretsFile(rootDir string) error { - secretsFile := filepath.Join(rootDir, plainSecretsFile) - encryptedFile := filepath.Join(rootDir, encryptedSecretsFile) - - // Load plain secrets - secretsData, err := os.ReadFile(secretsFile) - if err != nil { - return fmt.Errorf("failed to read secrets file: %w", err) - } - - // Load or generate key - var identity *age.X25519Identity - keyFile := filepath.Join(rootDir, keyFileName) - if _, err := os.Stat(keyFile); os.IsNotExist(err) { - var keyCreated bool - identity, keyCreated, err = GenerateKey(rootDir) - if err != nil { - return fmt.Errorf("failed to generate key: %w", err) - } - _ = keyCreated // Not used in this context - } else { - identity, err = LoadKey(rootDir) - if err != nil { - return fmt.Errorf("failed to load key: %w", err) - } - } - - // Parse YAML - var secrets map[string]any - if err := yaml.Unmarshal(secretsData, &secrets); err != nil { - return fmt.Errorf("failed to parse secrets YAML: %w", err) - } - - // If encrypted file exists, load it and merge (preserve unchanged encrypted values) - var encryptedSecrets map[string]any - if _, err := os.Stat(encryptedFile); err == nil { - encryptedData, err := os.ReadFile(encryptedFile) - if err == nil { - if err := yaml.Unmarshal(encryptedData, &encryptedSecrets); err == nil { - // Merge: encrypt only changed values, preserve unchanged encrypted values - merged, err := mergeAndEncryptYAMLValues(secrets, encryptedSecrets, identity) - if err != nil { - return fmt.Errorf("failed to merge and encrypt: %w", err) - } - encryptedSecrets = merged.(map[string]any) - } else { - // If parsing fails, encrypt everything - encrypted, err := encryptYAMLValues(secrets, identity.Recipient()) - if err != nil { - return fmt.Errorf("failed to encrypt secrets: %w", err) - } - encryptedSecrets = encrypted.(map[string]any) - } - } else { - // If reading fails, encrypt everything - encrypted, err := encryptYAMLValues(secrets, identity.Recipient()) - if err != nil { - return fmt.Errorf("failed to encrypt secrets: %w", err) - } - encryptedSecrets = encrypted.(map[string]any) - } - } else { - // No encrypted file exists, encrypt everything - encrypted, err := encryptYAMLValues(secrets, identity.Recipient()) - if err != nil { - return fmt.Errorf("failed to encrypt secrets: %w", err) - } - encryptedSecrets = encrypted.(map[string]any) - } - - // Marshal encrypted YAML - encryptedData, err := yaml.Marshal(encryptedSecrets) - if err != nil { - return fmt.Errorf("failed to marshal encrypted secrets: %w", err) - } - - // Write encrypted file via secureperm.WriteFile so it lands at - // mode 0o600 (defense-in-depth — age encryption is the security - // layer, but world-readable secrets material on shared - // workstations invites mistakes). RotateKeys uses the same - // helper for the same file, keeping the on-disk mode invariant - // across every code path that writes secrets.encrypted.yaml. - if err := secureperm.WriteFile(encryptedFile, encryptedData); err != nil { - return fmt.Errorf("failed to write encrypted file: %w", err) - } - - return nil + return encryptYAMLPair(rootDir, plainSecretsFile, encryptedSecretsFile) } -// DecryptSecretsFile decrypts secrets.encrypted.yaml and saves to secrets.yaml +// DecryptSecretsFile decrypts secrets.encrypted.yaml and saves to secrets.yaml. func DecryptSecretsFile(rootDir string) error { - encryptedFile := filepath.Join(rootDir, encryptedSecretsFile) - secretsFile := filepath.Join(rootDir, plainSecretsFile) - - // Load encrypted secrets - encryptedData, err := os.ReadFile(encryptedFile) - if err != nil { - return fmt.Errorf("failed to read encrypted file: %w", err) - } - - // Load key - identity, err := LoadKey(rootDir) - if err != nil { - return fmt.Errorf("failed to load key: %w", err) - } - - // Parse YAML - var encryptedSecrets map[string]any - if err := yaml.Unmarshal(encryptedData, &encryptedSecrets); err != nil { - return fmt.Errorf("failed to parse encrypted YAML: %w", err) - } - - // Decrypt values - decryptedSecrets, err := decryptYAMLValues(encryptedSecrets, identity) - if err != nil { - return fmt.Errorf("failed to decrypt secrets: %w", err) - } - - // Marshal decrypted YAML - decryptedData, err := yaml.Marshal(decryptedSecrets) - if err != nil { - return fmt.Errorf("failed to marshal decrypted secrets: %w", err) - } - - // Write decrypted file with secure permissions - if err := secureperm.WriteFile(secretsFile, decryptedData); err != nil { - return fmt.Errorf("failed to write decrypted file: %w", err) - } - - return nil + return decryptYAMLPair(rootDir, encryptedSecretsFile, plainSecretsFile) } -// encryptYAMLValues recursively encrypts string values in YAML structure +// encryptYAMLValues recursively encrypts string values in YAML structure. func encryptYAMLValues(data any, recipient *age.X25519Recipient) (any, error) { - switch v := data.(type) { + switch typed := data.(type) { case map[string]any: result := make(map[string]any) - for key, value := range v { + + for key, value := range typed { encryptedValue, err := encryptYAMLValues(value, recipient) if err != nil { return nil, err } + result[key] = encryptedValue } + return result, nil case []any: - result := make([]any, len(v)) - for i, item := range v { + result := make([]any, len(typed)) + + for i, item := range typed { encryptedItem, err := encryptYAMLValues(item, recipient) if err != nil { return nil, err } + result[i] = encryptedItem } + return result, nil case string: // Encrypt string value - encrypted, err := encryptString(v, recipient) + encrypted, err := encryptString(typed, recipient) if err != nil { return nil, err } + return ageEncryptionPrefix + encrypted + ageEncryptionSuffix, nil default: - return v, nil + return typed, nil } } -// decryptYAMLValues recursively decrypts string values in YAML structure +// decryptYAMLValues recursively decrypts string values in YAML structure. func decryptYAMLValues(data any, identity *age.X25519Identity) (any, error) { - switch v := data.(type) { + switch typed := data.(type) { case map[string]any: result := make(map[string]any) - for key, value := range v { + + for key, value := range typed { decryptedValue, err := decryptYAMLValues(value, identity) if err != nil { return nil, err } + result[key] = decryptedValue } + return result, nil case []any: - result := make([]any, len(v)) - for i, item := range v { + result := make([]any, len(typed)) + + for i, item := range typed { decryptedItem, err := decryptYAMLValues(item, identity) if err != nil { return nil, err } + result[i] = decryptedItem } + return result, nil case string: // Check if it's an encrypted value in SOPS format: ENC[AGE,data:...] - if strings.HasPrefix(v, ageEncryptionPrefix) && strings.HasSuffix(v, ageEncryptionSuffix) { + if strings.HasPrefix(typed, ageEncryptionPrefix) && strings.HasSuffix(typed, ageEncryptionSuffix) { // Extract the encrypted data between ENC[AGE,data: and ] - encrypted := strings.TrimPrefix(v, ageEncryptionPrefix) + encrypted := strings.TrimPrefix(typed, ageEncryptionPrefix) encrypted = strings.TrimSuffix(encrypted, ageEncryptionSuffix) + decrypted, err := decryptString(encrypted, identity) if err != nil { return nil, err } + return decrypted, nil } - return v, nil + + return typed, nil default: - return v, nil + return typed, nil } } -// decryptYAMLValuesString decrypts a single encrypted string value (helper for mergeAndEncryptYAMLValues) +// decryptYAMLValuesString decrypts a single encrypted string value (helper for mergeAndEncryptYAMLValues). func decryptYAMLValuesString(encrypted string, identity *age.X25519Identity) (string, error) { if strings.HasPrefix(encrypted, ageEncryptionPrefix) && strings.HasSuffix(encrypted, ageEncryptionSuffix) { encryptedData := strings.TrimPrefix(encrypted, ageEncryptionPrefix) encryptedData = strings.TrimSuffix(encryptedData, ageEncryptionSuffix) + return decryptString(encryptedData, identity) } + return encrypted, nil } -// mergeAndEncryptYAMLValues merges plain and encrypted YAML, encrypting only changed values -// This ensures idempotency: unchanged values keep their encrypted form +// mergeAndEncryptYAMLValues merges plain and encrypted YAML, encrypting only changed values. +// This ensures idempotency: unchanged values keep their encrypted form. func mergeAndEncryptYAMLValues(plain, encrypted any, identity *age.X25519Identity) (any, error) { switch plainVal := plain.(type) { case map[string]any: - encryptedMap, ok := encrypted.(map[string]any) - if !ok { - // Type mismatch, encrypt everything - return encryptYAMLValues(plain, identity.Recipient()) - } + return mergeAndEncryptMap(plainVal, encrypted, identity) + case []any: + return mergeAndEncryptSlice(plainVal, encrypted, identity) + case string: + return mergeAndEncryptString(plainVal, encrypted, identity) + default: + return mergeAndEncryptScalar(plain, encrypted, identity) + } +} - result := make(map[string]any) - // Copy all keys from plain (to handle new keys) - for key, plainValue := range plainVal { - if encryptedValue, exists := encryptedMap[key]; exists { - // Key exists in both, recursively merge - merged, err := mergeAndEncryptYAMLValues(plainValue, encryptedValue, identity) - if err != nil { - return nil, err - } - result[key] = merged - } else { - // New key, encrypt it - encryptedValue, err := encryptYAMLValues(plainValue, identity.Recipient()) - if err != nil { - return nil, err - } - result[key] = encryptedValue - } - } - return result, nil +// mergeAndEncryptMap walks the plain map keyed against the encrypted +// map (when types align) and recurses for shared keys, full-encrypts +// for new keys. Type mismatch falls through to a full encrypt. +func mergeAndEncryptMap(plain map[string]any, encrypted any, identity *age.X25519Identity) (any, error) { + encryptedMap, ok := encrypted.(map[string]any) + if !ok { + return encryptYAMLValues(plain, identity.Recipient()) + } - case []any: - encryptedSlice, ok := encrypted.([]any) - if !ok || len(plainVal) != len(encryptedSlice) { - // Type or length mismatch, encrypt everything - return encryptYAMLValues(plain, identity.Recipient()) - } + result := make(map[string]any) - result := make([]any, len(plainVal)) - for i, plainItem := range plainVal { - merged, err := mergeAndEncryptYAMLValues(plainItem, encryptedSlice[i], identity) - if err != nil { - return nil, err - } - result[i] = merged + for key, plainValue := range plain { + merged, err := mergeAndEncryptMapEntry(plainValue, encryptedMap, key, identity) + if err != nil { + return nil, err } - return result, nil - case string: - encryptedStr, ok := encrypted.(string) - if !ok { - // Type mismatch, encrypt - return encryptYAMLValues(plain, identity.Recipient()) - } + result[key] = merged + } - // Check if encrypted value is already encrypted - if strings.HasPrefix(encryptedStr, ageEncryptionPrefix) && strings.HasSuffix(encryptedStr, ageEncryptionSuffix) { - // Decrypt existing value to compare - decrypted, err := decryptYAMLValuesString(encryptedStr, identity) - if err == nil && decrypted == plainVal { - // Values are the same, keep existing encrypted value (idempotent) - return encryptedStr, nil - } + return result, nil +} + +// mergeAndEncryptMapEntry encrypts a single map value: recurse when +// the key already exists on the encrypted side, full-encrypt when it +// is new. +func mergeAndEncryptMapEntry(plainValue any, encryptedMap map[string]any, key string, identity *age.X25519Identity) (any, error) { + if encryptedValue, exists := encryptedMap[key]; exists { + return mergeAndEncryptYAMLValues(plainValue, encryptedValue, identity) + } + + return encryptYAMLValues(plainValue, identity.Recipient()) +} + +// mergeAndEncryptSlice merges per-element when slice lengths match; +// otherwise full-encrypts (per-index merge requires a stable mapping +// that a length change invalidates). +func mergeAndEncryptSlice(plain []any, encrypted any, identity *age.X25519Identity) (any, error) { + encryptedSlice, ok := encrypted.([]any) + if !ok || len(plain) != len(encryptedSlice) { + return encryptYAMLValues(plain, identity.Recipient()) + } + + result := make([]any, len(plain)) + + for i, plainItem := range plain { + merged, err := mergeAndEncryptYAMLValues(plainItem, encryptedSlice[i], identity) + if err != nil { + return nil, err } - // Encrypt the new value (if decryption fails, values differ, or both are plain) + + result[i] = merged + } + + return result, nil +} + +// mergeAndEncryptString implements the per-string idempotent rule: +// when the prior ciphertext decrypts to the SAME plaintext, keep the +// existing ciphertext byte-for-byte; otherwise re-encrypt. +func mergeAndEncryptString(plain string, encrypted any, identity *age.X25519Identity) (any, error) { + encryptedStr, ok := encrypted.(string) + if !ok { return encryptYAMLValues(plain, identity.Recipient()) + } - default: - // For other types, compare directly - if plain == encrypted { - // Values are the same, if encrypted is already encrypted, keep it - if encryptedStr, ok := encrypted.(string); ok { - if strings.HasPrefix(encryptedStr, ageEncryptionPrefix) && strings.HasSuffix(encryptedStr, ageEncryptionSuffix) { - return encrypted, nil - } + if cipher, kept := tryReuseCiphertext(plain, encryptedStr, identity); kept { + return cipher, nil + } + + return encryptYAMLValues(plain, identity.Recipient()) +} + +// tryReuseCiphertext returns (encryptedStr, true) when encryptedStr is +// an envelope-wrapped ciphertext that decrypts to plain — the +// idempotency guard. Any decryption error or plaintext mismatch +// returns ("", false), telling the caller to fall through to a fresh +// encrypt. +func tryReuseCiphertext(plain, encryptedStr string, identity *age.X25519Identity) (string, bool) { + if !strings.HasPrefix(encryptedStr, ageEncryptionPrefix) || !strings.HasSuffix(encryptedStr, ageEncryptionSuffix) { + return "", false + } + + decrypted, err := decryptYAMLValuesString(encryptedStr, identity) + if err != nil || decrypted != plain { + return "", false + } + + return encryptedStr, true +} + +// mergeAndEncryptScalar handles non-map / non-slice / non-string YAML +// scalars (numbers, booleans, nil): keep the existing ciphertext when +// equality holds AND the encrypted side is an envelope; otherwise +// full-encrypt the plain value. +func mergeAndEncryptScalar(plain, encrypted any, identity *age.X25519Identity) (any, error) { + if plain == encrypted { + if encryptedStr, ok := encrypted.(string); ok { + if strings.HasPrefix(encryptedStr, ageEncryptionPrefix) && strings.HasSuffix(encryptedStr, ageEncryptionSuffix) { + return encrypted, nil } } - // Encrypt the value - return encryptYAMLValues(plain, identity.Recipient()) } + + return encryptYAMLValues(plain, identity.Recipient()) } -// encryptString encrypts a string using age +// encryptString encrypts a string using age. func encryptString(plaintext string, recipient *age.X25519Recipient) (string, error) { var buf bytes.Buffer + writer, err := age.Encrypt(&buf, recipient) if err != nil { - return "", fmt.Errorf("failed to create encrypt writer: %w", err) + return "", errors.Wrap(err, "create encrypt writer") } - if _, err := writer.Write([]byte(plaintext)); err != nil { - return "", fmt.Errorf("failed to write plaintext: %w", err) + _, writeErr := writer.Write([]byte(plaintext)) + if writeErr != nil { + return "", errors.Wrap(writeErr, "write plaintext") } - if err := writer.Close(); err != nil { - return "", fmt.Errorf("failed to close encrypt writer: %w", err) + closeErr := writer.Close() + if closeErr != nil { + return "", errors.Wrap(closeErr, "close encrypt writer") } // Encode to base64 for safe YAML storage return base64.StdEncoding.EncodeToString(buf.Bytes()), nil } -// decryptString decrypts a base64-encoded age-encrypted string +// decryptString decrypts a base64-encoded age-encrypted string. func decryptString(encryptedBase64 string, identity *age.X25519Identity) (string, error) { encrypted, err := base64.StdEncoding.DecodeString(encryptedBase64) if err != nil { - return "", fmt.Errorf("failed to decode base64: %w", err) + return "", errors.Wrap(err, "decode base64") } reader, err := age.Decrypt(bytes.NewReader(encrypted), identity) if err != nil { - return "", fmt.Errorf("failed to create decrypt reader: %w", err) + return "", errors.Wrap(err, "create decrypt reader") } decrypted, err := io.ReadAll(reader) if err != nil { - return "", fmt.Errorf("failed to read decrypted data: %w", err) + return "", errors.Wrap(err, "read decrypted data") } return string(decrypted), nil @@ -527,246 +491,409 @@ func RotateKeys(rootDir string) error { keyBackup := keyFile + ".rotation-backup" encryptedBackup := encryptedFile + ".rotation-backup" - // Refuse to start if leftover rotation backups exist — the - // operator must inspect them and decide what to keep before - // another rotation runs, otherwise this run would silently - // overwrite the recovery state. Both interrupted and - // successful-with-failed-cleanup states leave these files - // behind; the docstring above explains how to distinguish. - for _, p := range []string{keyBackup, encryptedBackup} { - if _, err := os.Stat(p); err == nil { - return fmt.Errorf("found leftover rotation backup %q from a previous run (either interrupted, or successful with a failed cleanup step); inspect and remove (or restore) before retrying", p) + // Phase 0: refuse if any leftover backup is still on disk. + err := refuseIfLeftoverBackups(keyBackup, encryptedBackup) + if err != nil { + return err + } + + // Phases 1+2: read+decrypt with old key, generate new identity, + // re-encrypt under it. All in memory; no disk mutation yet. + newIdentity, encryptedDataNew, err := prepareRotationCiphertext(rootDir, encryptedFile) + if err != nil { + return err + } + + // Phase 3: move originals aside as backups. + err = swapOriginalsAside(keyFile, encryptedFile, keyBackup, encryptedBackup) + if err != nil { + return err + } + + // restore is a recovery helper used when any later step fails: + // rename the backups back into place and return the original + // error wrapped with a recovery note. We never silently swallow + // the restore's own error — if recovery fails the operator + // needs to know. + restore := func(stage string, cause error) error { + return restoreFromBackups(stage, cause, keyFile, encryptedFile, keyBackup, encryptedBackup) + } + + // Phase 4: write new key, then new encrypted file. Both via + // secureperm.WriteFile (atomic + 0o600). On any failure the + // `restore` closure puts the originals back. + err = secureperm.WriteFile(keyFile, []byte(formatKeyFile(newIdentity, time.Now()))) + if err != nil { + return restore("write new key", err) + } + + err = secureperm.WriteFile(encryptedFile, encryptedDataNew) + if err != nil { + return restore("write new encrypted file", err) + } + + // Phase 5: rotation committed — remove backups. + return removeRotationBackups(keyBackup, encryptedBackup) +} + +// refuseIfLeftoverBackups returns ErrLeftoverRotationBackup (wrapped +// with the offending path) when either backup path already exists on +// disk. Both interrupted and successful-with-failed-cleanup states +// leave these files behind; the operator must decide what to keep +// before another rotation runs, otherwise this run would silently +// overwrite the recovery state. +func refuseIfLeftoverBackups(keyBackup, encryptedBackup string) error { + for _, backupPath := range []string{keyBackup, encryptedBackup} { + _, statErr := os.Stat(backupPath) + if statErr == nil { + return errors.Wrapf(ErrLeftoverRotationBackup, "leftover rotation-backup at %q", backupPath) + } + // Fail closed on anything that isn't "file does not exist": + // permission errors, sharing violations, transient I/O — any of + // those can mean the backup IS there but the stat failed to + // confirm it. Continuing would let rotation overwrite recovery + // artefacts under uncertainty. IsNotExist is the only "no + // leftover" answer. + if !os.IsNotExist(statErr) { + return errors.Wrapf(statErr, "checking for leftover rotation-backup at %q", backupPath) } } - // Phase 1: read and decrypt with old key, all in memory. + return nil +} + +// prepareRotationCiphertext loads talm.key, reads + parses the +// encrypted YAML, decrypts with the old key, generates a new +// identity, and re-encrypts under it. Everything stays in memory; no +// disk mutation yet. Returns the new identity (so the caller can +// write talm.key) and the marshalled new ciphertext (so the caller +// can write secrets.encrypted.yaml). +func prepareRotationCiphertext(rootDir, encryptedFile string) (*age.X25519Identity, []byte, error) { oldIdentity, err := LoadKey(rootDir) if err != nil { - return fmt.Errorf("failed to load old key: %w", err) + return nil, nil, errors.Wrap(err, "load old key") } + encryptedData, err := os.ReadFile(encryptedFile) if err != nil { - return fmt.Errorf("failed to read encrypted file: %w", err) + return nil, nil, errors.Wrap(err, "read encrypted file") } + var encryptedSecrets map[string]any - if err := yaml.Unmarshal(encryptedData, &encryptedSecrets); err != nil { - return fmt.Errorf("failed to parse encrypted YAML: %w", err) + + err = yaml.Unmarshal(encryptedData, &encryptedSecrets) + if err != nil { + return nil, nil, errors.Wrap(err, "parse encrypted YAML") } + decryptedSecrets, err := decryptYAMLValues(encryptedSecrets, oldIdentity) if err != nil { - return fmt.Errorf("failed to decrypt with old key: %w", err) + return nil, nil, errors.Wrap(err, "decrypt with old key") } - // Phase 2: generate new identity and encrypt new ciphertext — - // still all in memory. No disk mutation yet. newIdentity, err := age.GenerateX25519Identity() if err != nil { - return fmt.Errorf("failed to generate new identity: %w", err) + return nil, nil, errors.Wrap(err, "generate new identity") } + encryptedSecretsNew, err := encryptYAMLValues(decryptedSecrets, newIdentity.Recipient()) if err != nil { - return fmt.Errorf("failed to encrypt with new key: %w", err) + return nil, nil, errors.Wrap(err, "encrypt with new key") } + encryptedDataNew, err := yaml.Marshal(encryptedSecretsNew) if err != nil { - return fmt.Errorf("failed to marshal new encrypted secrets: %w", err) - } - - // Phase 3: move originals aside as backups. Atomic rename, so - // either both originals exist as `*.rotation-backup` after this - // block or neither move took effect (for the second rename: we - // undo the first if it errors). - if err := os.Rename(keyFile, keyBackup); err != nil { - return fmt.Errorf("failed to back up key file before rotation: %w", err) - } - if err := os.Rename(encryptedFile, encryptedBackup); err != nil { - // Roll back the key rename so the project is untouched. - // Capture the rollback error too — if it fails the - // operator is left with keyBackup but no keyFile, and the - // caller-facing error must say so explicitly (otherwise - // a Phase 0 refusal on retry would be the first sign of - // the partial state). - if rbErr := os.Rename(keyBackup, keyFile); rbErr != nil { - return fmt.Errorf("failed to back up encrypted file before rotation: %w; AND rollback of key-file rename failed: %v — manual recovery: rename %q -> %q", err, rbErr, keyBackup, keyFile) - } - return fmt.Errorf("failed to back up encrypted file before rotation: %w (key file rename rolled back)", err) + return nil, nil, errors.Wrap(err, "marshal new encrypted secrets") } - // restore is a recovery helper used when any later step fails: - // rename the backups back into place and return the original - // error wrapped with a recovery note. We never silently swallow - // the restore's own error — if recovery fails the operator - // needs to know. - restore := func(stage string, cause error) error { - _ = os.Remove(keyFile) // best-effort: remove half-written new key if any - _ = os.Remove(encryptedFile) // ditto for encrypted file - errKey := os.Rename(keyBackup, keyFile) - errEnc := os.Rename(encryptedBackup, encryptedFile) - if errKey != nil || errEnc != nil { - return fmt.Errorf("rotation failed at %s: %w; AND restore from backup partially failed (key: %v, encrypted: %v) — manual recovery: rename %q -> %q and %q -> %q", - stage, cause, errKey, errEnc, keyBackup, keyFile, encryptedBackup, encryptedFile) - } - return fmt.Errorf("rotation failed at %s: %w (originals restored)", stage, cause) + return newIdentity, encryptedDataNew, nil +} + +// swapOriginalsAside renames the two originals to `*.rotation-backup` +// atomically. If the second rename fails, the first is rolled back so +// the project is untouched. If THAT rollback also fails the caller +// gets an explicit recovery message — the alternative would be a +// Phase 0 refusal on retry as the first sign of the partial state. +func swapOriginalsAside(keyFile, encryptedFile, keyBackup, encryptedBackup string) error { + err := os.Rename(keyFile, keyBackup) + if err != nil { + return errors.Wrap(err, "back up key file before rotation") } - // Phase 4: write new key, then new encrypted file. Both via - // secureperm.WriteFile (atomic + 0o600). On any failure the - // `restore` closure puts the originals back. - if err := secureperm.WriteFile(keyFile, []byte(formatKeyFile(newIdentity, time.Now()))); err != nil { - return restore("write new key", err) + err = os.Rename(encryptedFile, encryptedBackup) + if err == nil { + return nil } - if err := secureperm.WriteFile(encryptedFile, encryptedDataNew); err != nil { - return restore("write new encrypted file", err) + + rbErr := os.Rename(keyBackup, keyFile) + if rbErr != nil { + //nolint:wrapcheck // errors.WithHintf wraps an already-wrapped chain (errors.Wrapf below); cockroachdb hint helpers are the project standard for operator-facing recovery instructions. + return errors.WithHintf( + errors.Wrapf(errors.WithSecondaryError(err, rbErr), + "back up encrypted file before rotation; AND rollback of key-file rename failed"), + "manual recovery: rename %q -> %q", + keyBackup, keyFile, + ) } - // Phase 5: rotation committed — remove backups. If removal - // fails we return an error so the caller (and a future - // RotateKeys run) sees an unambiguous "rotation succeeded but - // backups linger" signal. The new pair on disk is correct and - // usable; the leftover backups must be removed manually before - // the next rotation can run (Phase 0 will refuse otherwise). + return errors.Wrap(err, "back up encrypted file before rotation (key file rename rolled back)") +} + +// restoreFromBackups is the recovery path for Phase 4 failures: +// remove any half-written new files, rename the backups back into +// place, and return the original cause wrapped with a recovery note. +// If the restore itself errors, that is also attached so the operator +// learns about the partial state explicitly. +func restoreFromBackups(stage string, cause error, keyFile, encryptedFile, keyBackup, encryptedBackup string) error { + _ = os.Remove(keyFile) // best-effort: remove half-written new key if any + _ = os.Remove(encryptedFile) // ditto for encrypted file + + errKey := os.Rename(keyBackup, keyFile) + errEnc := os.Rename(encryptedBackup, encryptedFile) + + if errKey == nil && errEnc == nil { + return errors.Wrapf(cause, "rotation failed at %s (originals restored)", stage) + } + + combined := cause + if errKey != nil { + combined = errors.WithSecondaryError(combined, errors.Wrap(errKey, "restore key from backup")) + } + + if errEnc != nil { + combined = errors.WithSecondaryError(combined, errors.Wrap(errEnc, "restore encrypted file from backup")) + } + + //nolint:wrapcheck // errors.WithHintf wraps an already-wrapped chain (errors.Wrapf below); cockroachdb hint helpers are the project standard for operator-facing recovery instructions. + return errors.WithHintf( + errors.Wrapf(combined, "rotation failed at %s; AND restore from backup partially failed", stage), + "manual recovery: rename %q -> %q and %q -> %q", + keyBackup, keyFile, encryptedBackup, encryptedFile, + ) +} + +// removeRotationBackups is Phase 5: rotation committed, both new +// files are correct on disk; the only remaining work is to remove the +// `*.rotation-backup` files. If the removal fails the function +// returns a non-nil error so the caller (and a future RotateKeys +// run) sees an unambiguous "rotation succeeded but backups linger" +// signal — Phase 0 of the next run would refuse otherwise. +func removeRotationBackups(keyBackup, encryptedBackup string) error { var cleanupErrs []string - if err := os.Remove(keyBackup); err != nil { + + err := os.Remove(keyBackup) + if err != nil { cleanupErrs = append(cleanupErrs, fmt.Sprintf("%q: %v", keyBackup, err)) } - if err := os.Remove(encryptedBackup); err != nil { + + err = os.Remove(encryptedBackup) + if err != nil { cleanupErrs = append(cleanupErrs, fmt.Sprintf("%q: %v", encryptedBackup, err)) } - if len(cleanupErrs) > 0 { - return fmt.Errorf("rotation committed (new key and encrypted file are on disk) but cleanup of backup files failed: %s; remove these files manually before the next rotation", - strings.Join(cleanupErrs, "; ")) + + if len(cleanupErrs) == 0 { + return nil } - return nil + //nolint:wrapcheck // errors.WithHint wraps the errors.Newf below; cockroachdb hint helpers are the project standard for operator-facing recovery instructions. + return errors.WithHint( + errors.Newf("rotation committed (new key and encrypted file are on disk) but cleanup of backup files failed: %s", + strings.Join(cleanupErrs, "; ")), + "remove these files manually before the next rotation", + ) } -// EncryptYAMLFile encrypts a YAML file's values (keeping keys unencrypted) and saves to encrypted file -// Uses incremental encryption: only encrypts values that have changed +// EncryptYAMLFile encrypts a YAML file's values (keeping keys unencrypted) and saves to encrypted file. +// Uses incremental encryption: only encrypts values that have changed. func EncryptYAMLFile(rootDir, plainFile, encryptedFile string) error { + return encryptYAMLPair(rootDir, plainFile, encryptedFile) +} + +// encryptYAMLPair is the shared implementation for EncryptSecretsFile +// and EncryptYAMLFile. Both flows take a plain-YAML path and an +// encrypted-YAML destination under rootDir, load (or generate) the +// project's age key, and write the destination at mode 0o600 +// (defense-in-depth — age encryption is the security layer, but +// world-readable secrets material on shared workstations invites +// mistakes). The function does incremental encryption: an existing +// destination is loaded and used to keep ciphertext byte-stable for +// values whose plaintext did not change. If the existing destination +// cannot be read or parsed, the function falls back to a full +// encryption — the project still ends up in a consistent state. +func encryptYAMLPair(rootDir, plainFile, encryptedFile string) error { plainFilePath := filepath.Join(rootDir, plainFile) encryptedFilePath := filepath.Join(rootDir, encryptedFile) - // Load plain file plainData, err := os.ReadFile(plainFilePath) if err != nil { - return fmt.Errorf("failed to read plain file: %w", err) + return errors.Wrap(err, "read plain file") + } + + identity, err := loadOrGenerateIdentity(rootDir) + if err != nil { + return err + } + + var plain map[string]any + + err = yaml.Unmarshal(plainData, &plain) + if err != nil { + return errors.Wrap(err, "parse plain YAML") + } + + encryptedMap, err := incrementalEncryptMap(plain, encryptedFilePath, identity) + if err != nil { + return err + } + + encryptedData, err := yaml.Marshal(encryptedMap) + if err != nil { + return errors.Wrap(err, "marshal encrypted YAML") } - // Load or generate key - var identity *age.X25519Identity + err = secureperm.WriteFile(encryptedFilePath, encryptedData) + if err != nil { + return errors.Wrap(err, "write encrypted file") + } + + return nil +} + +// loadOrGenerateIdentity loads the project's age identity from +// `/talm.key` or creates one if the file does not exist. The +// load-or-create semantics are what `talm init` and the encrypt +// helpers rely on across init/apply/talosconfig flows. +func loadOrGenerateIdentity(rootDir string) (*age.X25519Identity, error) { keyFile := filepath.Join(rootDir, keyFileName) - if _, err := os.Stat(keyFile); os.IsNotExist(err) { - var keyCreated bool - identity, keyCreated, err = GenerateKey(rootDir) - if err != nil { - return fmt.Errorf("failed to generate key: %w", err) - } - _ = keyCreated // Not used in this context - } else { - identity, err = LoadKey(rootDir) + + _, statErr := os.Stat(keyFile) + if os.IsNotExist(statErr) { + identity, _, err := GenerateKey(rootDir) if err != nil { - return fmt.Errorf("failed to load key: %w", err) + return nil, errors.Wrap(err, "generate key") } + + return identity, nil + } + + identity, err := LoadKey(rootDir) + if err != nil { + return nil, errors.Wrap(err, "load key") } - // Parse YAML - var yamlData map[string]any - if err := yaml.Unmarshal(plainData, &yamlData); err != nil { - return fmt.Errorf("failed to parse YAML: %w", err) + return identity, nil +} + +// incrementalEncryptMap returns the encrypted map[string]any for plain +// using existing ciphertext at encryptedFilePath when available. The +// fall-through to full encryption mirrors the behaviour of the old +// inline blocks in EncryptSecretsFile / EncryptYAMLFile: a missing, +// unreadable, or unparseable destination drops to encryptYAMLMap on +// the full plain tree, so the project recovers from a corrupted +// destination on the next encrypt round. +func incrementalEncryptMap(plain map[string]any, encryptedFilePath string, identity *age.X25519Identity) (map[string]any, error) { + _, statErr := os.Stat(encryptedFilePath) + if statErr != nil { + return encryptYAMLMap(plain, identity.Recipient()) } - // If encrypted file exists, load it and merge (preserve unchanged encrypted values) - var encryptedYAML map[string]any - if _, err := os.Stat(encryptedFilePath); err == nil { - encryptedData, err := os.ReadFile(encryptedFilePath) - if err == nil { - if err := yaml.Unmarshal(encryptedData, &encryptedYAML); err == nil { - // Merge: encrypt only changed values, preserve unchanged encrypted values - merged, err := mergeAndEncryptYAMLValues(yamlData, encryptedYAML, identity) - if err != nil { - return fmt.Errorf("failed to merge and encrypt: %w", err) - } - encryptedYAML = merged.(map[string]any) - } else { - // If parsing fails, encrypt everything - encrypted, err := encryptYAMLValues(yamlData, identity.Recipient()) - if err != nil { - return fmt.Errorf("failed to encrypt YAML values: %w", err) - } - encryptedYAML = encrypted.(map[string]any) - } - } else { - // If reading fails, encrypt everything - encrypted, err := encryptYAMLValues(yamlData, identity.Recipient()) - if err != nil { - return fmt.Errorf("failed to encrypt YAML values: %w", err) - } - encryptedYAML = encrypted.(map[string]any) - } - } else { - // No encrypted file exists, encrypt everything - encrypted, err := encryptYAMLValues(yamlData, identity.Recipient()) - if err != nil { - return fmt.Errorf("failed to encrypt YAML values: %w", err) - } - encryptedYAML = encrypted.(map[string]any) + encryptedData, err := os.ReadFile(encryptedFilePath) + if err != nil { + return encryptYAMLMap(plain, identity.Recipient()) + } + + var existing map[string]any + + err = yaml.Unmarshal(encryptedData, &existing) + if err != nil { + return encryptYAMLMap(plain, identity.Recipient()) } - // Marshal encrypted YAML - encryptedData, err := yaml.Marshal(encryptedYAML) + return mergeAndEncryptYAMLMap(plain, existing, identity) +} + +// encryptYAMLMap is a typed wrapper around encryptYAMLValues that +// returns map[string]any directly. The wrapper exists so callers can +// avoid the unchecked any -> map[string]any assertion the linter +// rightly flags. encryptYAMLValues always returns the same kind it +// received at the top level, so a non-map result on a map input would +// indicate a bug rather than a runtime input shape. +func encryptYAMLMap(plain map[string]any, recipient *age.X25519Recipient) (map[string]any, error) { + encrypted, err := encryptYAMLValues(plain, recipient) if err != nil { - return fmt.Errorf("failed to marshal encrypted YAML: %w", err) + return nil, errors.Wrap(err, "encrypt YAML values") } - // Write encrypted file via secureperm.WriteFile (mode 0o600). - // Same defense-in-depth rationale as EncryptSecretsFile and - // RotateKeys — every code path that writes encrypted secrets - // material agrees on the same on-disk permission. - if err := secureperm.WriteFile(encryptedFilePath, encryptedData); err != nil { - return fmt.Errorf("failed to write encrypted file: %w", err) + out, ok := encrypted.(map[string]any) + if !ok { + return nil, errors.Wrapf(errInternalInvariant, "encryptYAMLValues returned %T", encrypted) } - return nil + return out, nil } -// DecryptYAMLFile decrypts an encrypted YAML file's values and saves to plain file +// mergeAndEncryptYAMLMap is a typed wrapper around +// mergeAndEncryptYAMLValues. Same rationale as encryptYAMLMap — both +// helpers exist to confine the any -> map[string]any boundary to one +// site that emits an explicit invariant-violation error rather than +// panicking. +func mergeAndEncryptYAMLMap(plain, encrypted map[string]any, identity *age.X25519Identity) (map[string]any, error) { + merged, err := mergeAndEncryptYAMLValues(plain, encrypted, identity) + if err != nil { + return nil, errors.Wrap(err, "merge and encrypt") + } + + out, ok := merged.(map[string]any) + if !ok { + return nil, errors.Wrapf(errInternalInvariant, "mergeAndEncryptYAMLValues returned %T", merged) + } + + return out, nil +} + +// DecryptYAMLFile decrypts an encrypted YAML file's values and saves to plain file. func DecryptYAMLFile(rootDir, encryptedFile, plainFile string) error { + return decryptYAMLPair(rootDir, encryptedFile, plainFile) +} + +// decryptYAMLPair is the shared implementation for DecryptSecretsFile +// and DecryptYAMLFile. Both flows take an encrypted-YAML path and a +// plain-YAML destination under rootDir, load the project's age key, +// and write the destination with secure permissions. +func decryptYAMLPair(rootDir, encryptedFile, plainFile string) error { encryptedFilePath := filepath.Join(rootDir, encryptedFile) plainFilePath := filepath.Join(rootDir, plainFile) - // Load encrypted file encryptedData, err := os.ReadFile(encryptedFilePath) if err != nil { - return fmt.Errorf("failed to read encrypted file: %w", err) + return errors.Wrap(err, "read encrypted file") } - // Load key identity, err := LoadKey(rootDir) if err != nil { - return fmt.Errorf("failed to load key: %w", err) + return errors.Wrap(err, "load key") } - // Parse YAML var encryptedYAML map[string]any - if err := yaml.Unmarshal(encryptedData, &encryptedYAML); err != nil { - return fmt.Errorf("failed to parse encrypted YAML: %w", err) + + err = yaml.Unmarshal(encryptedData, &encryptedYAML) + if err != nil { + return errors.Wrap(err, "parse encrypted YAML") } - // Decrypt values decryptedYAML, err := decryptYAMLValues(encryptedYAML, identity) if err != nil { - return fmt.Errorf("failed to decrypt YAML values: %w", err) + return errors.Wrap(err, "decrypt YAML values") } - // Marshal decrypted YAML decryptedData, err := yaml.Marshal(decryptedYAML) if err != nil { - return fmt.Errorf("failed to marshal decrypted YAML: %w", err) + return errors.Wrap(err, "marshal decrypted YAML") } - // Write decrypted file with secure permissions - if err := secureperm.WriteFile(plainFilePath, decryptedData); err != nil { - return fmt.Errorf("failed to write decrypted file: %w", err) + err = secureperm.WriteFile(plainFilePath, decryptedData) + if err != nil { + return errors.Wrap(err, "write decrypted file") } return nil diff --git a/pkg/age/contract_internals_test.go b/pkg/age/contract_internals_test.go index bc40705f..2f0d9b09 100644 --- a/pkg/age/contract_internals_test.go +++ b/pkg/age/contract_internals_test.go @@ -28,6 +28,7 @@ import ( "strings" "testing" + "github.com/cockroachdb/errors" "gopkg.in/yaml.v3" ) @@ -98,19 +99,23 @@ func TestContract_DecryptYAMLValuesString_CorruptedEnvelope(t *testing.T) { // ciphertext. func TestContract_IncrementalEncrypt_NewKeyEncryptedOldKeyStable(t *testing.T) { dir := t.TempDir() - if err := writeYAML(dir, "secrets.yaml", "a: alpha\n"); err != nil { + err := writeYAML(dir, "secrets.yaml", "a: alpha\n") + if err != nil { t.Fatal(err) } - if err := EncryptSecretsFile(dir); err != nil { + err = EncryptSecretsFile(dir) + if err != nil { t.Fatal(err) } first := readYAML(t, dir, "secrets.encrypted.yaml") // Add a new key b. - if err := writeYAML(dir, "secrets.yaml", "a: alpha\nb: beta\n"); err != nil { + err = writeYAML(dir, "secrets.yaml", "a: alpha\nb: beta\n") + if err != nil { t.Fatal(err) } - if err := EncryptSecretsFile(dir); err != nil { + err = EncryptSecretsFile(dir) + if err != nil { t.Fatal(err) } second := readYAML(t, dir, "secrets.encrypted.yaml") @@ -139,10 +144,12 @@ func TestContract_IncrementalEncrypt_NestedChangeLocalised(t *testing.T) { inner1: original inner2: keep-me-too ` - if err := writeYAML(dir, "secrets.yaml", initial); err != nil { + err := writeYAML(dir, "secrets.yaml", initial) + if err != nil { t.Fatal(err) } - if err := EncryptSecretsFile(dir); err != nil { + err = EncryptSecretsFile(dir) + if err != nil { t.Fatal(err) } first := readYAML(t, dir, "secrets.encrypted.yaml") @@ -154,10 +161,12 @@ func TestContract_IncrementalEncrypt_NestedChangeLocalised(t *testing.T) { inner1: NEW inner2: keep-me-too ` - if err := writeYAML(dir, "secrets.yaml", changed); err != nil { + err = writeYAML(dir, "secrets.yaml", changed) + if err != nil { t.Fatal(err) } - if err := EncryptSecretsFile(dir); err != nil { + err = EncryptSecretsFile(dir) + if err != nil { t.Fatal(err) } second := readYAML(t, dir, "secrets.encrypted.yaml") @@ -185,18 +194,22 @@ func TestContract_IncrementalEncrypt_NestedChangeLocalised(t *testing.T) { func TestContract_IncrementalEncrypt_TypeChangeFallsBackToFullEncrypt(t *testing.T) { dir := t.TempDir() // Encrypted file holds scalar at k. - if err := writeYAML(dir, "secrets.yaml", "k: scalar-value\n"); err != nil { + err := writeYAML(dir, "secrets.yaml", "k: scalar-value\n") + if err != nil { t.Fatal(err) } - if err := EncryptSecretsFile(dir); err != nil { + err = EncryptSecretsFile(dir) + if err != nil { t.Fatal(err) } // Plaintext now has k as a map. - if err := writeYAML(dir, "secrets.yaml", "k:\n nested: value\n"); err != nil { + err = writeYAML(dir, "secrets.yaml", "k:\n nested: value\n") + if err != nil { t.Fatal(err) } - if err := EncryptSecretsFile(dir); err != nil { + err = EncryptSecretsFile(dir) + if err != nil { t.Fatalf("re-encrypt with type change: %v", err) } @@ -216,18 +229,22 @@ func TestContract_IncrementalEncrypt_TypeChangeFallsBackToFullEncrypt(t *testing // which a length change invalidates. func TestContract_IncrementalEncrypt_ListLengthChangeFullReencrypt(t *testing.T) { dir := t.TempDir() - if err := writeYAML(dir, "secrets.yaml", "items:\n - one\n - two\n"); err != nil { + err := writeYAML(dir, "secrets.yaml", "items:\n - one\n - two\n") + if err != nil { t.Fatal(err) } - if err := EncryptSecretsFile(dir); err != nil { + err = EncryptSecretsFile(dir) + if err != nil { t.Fatal(err) } first := readYAML(t, dir, "secrets.encrypted.yaml") - if err := writeYAML(dir, "secrets.yaml", "items:\n - one\n - two\n - three\n"); err != nil { + err = writeYAML(dir, "secrets.yaml", "items:\n - one\n - two\n - three\n") + if err != nil { t.Fatal(err) } - if err := EncryptSecretsFile(dir); err != nil { + err = EncryptSecretsFile(dir) + if err != nil { t.Fatal(err) } second := readYAML(t, dir, "secrets.encrypted.yaml") @@ -255,18 +272,22 @@ func TestContract_IncrementalEncrypt_ListLengthChangeFullReencrypt(t *testing.T) // stay byte-stable. func TestContract_IncrementalEncrypt_ListSameLengthLocalised(t *testing.T) { dir := t.TempDir() - if err := writeYAML(dir, "secrets.yaml", "items:\n - alpha\n - bravo\n - charlie\n"); err != nil { + err := writeYAML(dir, "secrets.yaml", "items:\n - alpha\n - bravo\n - charlie\n") + if err != nil { t.Fatal(err) } - if err := EncryptSecretsFile(dir); err != nil { + err = EncryptSecretsFile(dir) + if err != nil { t.Fatal(err) } first := readYAML(t, dir, "secrets.encrypted.yaml") - if err := writeYAML(dir, "secrets.yaml", "items:\n - alpha\n - DELTA\n - charlie\n"); err != nil { + err = writeYAML(dir, "secrets.yaml", "items:\n - alpha\n - DELTA\n - charlie\n") + if err != nil { t.Fatal(err) } - if err := EncryptSecretsFile(dir); err != nil { + err = EncryptSecretsFile(dir) + if err != nil { t.Fatal(err) } second := readYAML(t, dir, "secrets.encrypted.yaml") @@ -287,7 +308,11 @@ func TestContract_IncrementalEncrypt_ListSameLengthLocalised(t *testing.T) { // === helpers === func writeYAML(dir, name, body string) error { - return os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600) + err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600) + if err != nil { + return errors.Wrap(err, "write YAML test fixture") + } + return nil } func readYAML(t *testing.T, dir, name string) map[string]any { @@ -297,7 +322,8 @@ func readYAML(t *testing.T, dir, name string) map[string]any { t.Fatalf("read %s: %v", name, err) } var out map[string]any - if err := yaml.Unmarshal(data, &out); err != nil { + err = yaml.Unmarshal(data, &out) + if err != nil { t.Fatalf("unmarshal %s: %v", name, err) } return out diff --git a/pkg/age/contract_test.go b/pkg/age/contract_test.go index dfa4c29b..368aa1f7 100644 --- a/pkg/age/contract_test.go +++ b/pkg/age/contract_test.go @@ -40,6 +40,12 @@ import ( "gopkg.in/yaml.v3" ) +// goosWindows is runtime.GOOS's value on Windows. Hoisted to a +// constant because three Mode0600 contract tests skip themselves on +// the platform (NTFS does not honour Unix permission bits) — and +// goconst rightly notes the duplication. +const goosWindows = "windows" + // === GenerateKey + LoadKey === // Contract: GenerateKey on an empty directory creates a fresh @@ -131,7 +137,8 @@ func TestContract_Age_LoadKey_AcceptsPlainFormat(t *testing.T) { } plainSecret := id.String() + "\n" // Overwrite with plain format (no comments). - if err := os.WriteFile(plainFile, []byte(plainSecret), 0o600); err != nil { + err = os.WriteFile(plainFile, []byte(plainSecret), 0o600) + if err != nil { t.Fatal(err) } loaded, err := age.LoadKey(dir) @@ -150,10 +157,11 @@ func TestContract_Age_LoadKey_AcceptsPlainFormat(t *testing.T) { func TestContract_Age_LoadKey_RejectsMalformedKeyFile(t *testing.T) { dir := t.TempDir() malformed := filepath.Join(dir, "talm.key") - if err := os.WriteFile(malformed, []byte("# this is not a key\nrandom garbage\n"), 0o600); err != nil { + err := os.WriteFile(malformed, []byte("# this is not a key\nrandom garbage\n"), 0o600) + if err != nil { t.Fatal(err) } - _, err := age.LoadKey(dir) + _, err = age.LoadKey(dir) if err == nil { t.Fatal("expected error for malformed key file") } @@ -220,7 +228,8 @@ func TestContract_Age_GetPublicKeyFromFile_FallsBackToLoadKey(t *testing.T) { } // Strip the comment lines. plain := id.String() + "\n" - if err := os.WriteFile(filepath.Join(dir, "talm.key"), []byte(plain), 0o600); err != nil { + err = os.WriteFile(filepath.Join(dir, "talm.key"), []byte(plain), 0o600) + if err != nil { t.Fatal(err) } got, err := age.GetPublicKeyFromFile(dir) @@ -247,17 +256,21 @@ nested: k2: deeply-nested-value `) plainFile := filepath.Join(dir, "secrets.yaml") - if err := os.WriteFile(plainFile, plain, 0o600); err != nil { + err := os.WriteFile(plainFile, plain, 0o600) + if err != nil { t.Fatal(err) } - if err := age.EncryptSecretsFile(dir); err != nil { + err = age.EncryptSecretsFile(dir) + if err != nil { t.Fatalf("Encrypt: %v", err) } // Remove plaintext to prove decrypt restores from encrypted file. - if err := os.Remove(plainFile); err != nil { + err = os.Remove(plainFile) + if err != nil { t.Fatal(err) } - if err := age.DecryptSecretsFile(dir); err != nil { + err = age.DecryptSecretsFile(dir) + if err != nil { t.Fatalf("Decrypt: %v", err) } got, err := os.ReadFile(plainFile) @@ -266,10 +279,12 @@ nested: } // Compare semantically — YAML round-trip may reorder keys. var origMap, gotMap map[string]any - if err := yaml.Unmarshal(plain, &origMap); err != nil { + err = yaml.Unmarshal(plain, &origMap) + if err != nil { t.Fatal(err) } - if err := yaml.Unmarshal(got, &gotMap); err != nil { + err = yaml.Unmarshal(got, &gotMap) + if err != nil { t.Fatal(err) } if !mapsEqual(origMap, gotMap) { @@ -284,10 +299,12 @@ nested: func TestContract_Age_SecretsFile_EnvelopeFormat(t *testing.T) { dir := t.TempDir() plain := []byte("secret_value: hello-world\n") - if err := os.WriteFile(filepath.Join(dir, "secrets.yaml"), plain, 0o600); err != nil { + err := os.WriteFile(filepath.Join(dir, "secrets.yaml"), plain, 0o600) + if err != nil { t.Fatal(err) } - if err := age.EncryptSecretsFile(dir); err != nil { + err = age.EncryptSecretsFile(dir) + if err != nil { t.Fatal(err) } encrypted, err := os.ReadFile(filepath.Join(dir, "secrets.encrypted.yaml")) @@ -320,17 +337,20 @@ func TestContract_Age_SecretsFile_EnvelopeFormat(t *testing.T) { func TestContract_Age_SecretsFile_IncrementalReencryption(t *testing.T) { dir := t.TempDir() plain := []byte("a: alpha\nb: bravo\n") - if err := os.WriteFile(filepath.Join(dir, "secrets.yaml"), plain, 0o600); err != nil { + err := os.WriteFile(filepath.Join(dir, "secrets.yaml"), plain, 0o600) + if err != nil { t.Fatal(err) } - if err := age.EncryptSecretsFile(dir); err != nil { + err = age.EncryptSecretsFile(dir) + if err != nil { t.Fatal(err) } first, err := os.ReadFile(filepath.Join(dir, "secrets.encrypted.yaml")) if err != nil { t.Fatal(err) } - if err := age.EncryptSecretsFile(dir); err != nil { + err = age.EncryptSecretsFile(dir) + if err != nil { t.Fatal(err) } second, err := os.ReadFile(filepath.Join(dir, "secrets.encrypted.yaml")) @@ -349,10 +369,12 @@ func TestContract_Age_SecretsFile_IncrementalReencryption(t *testing.T) { // per-value encryption). func TestContract_Age_SecretsFile_ChangedValueLocalizedDiff(t *testing.T) { dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "secrets.yaml"), []byte("a: alpha\nb: bravo\n"), 0o600); err != nil { + err := os.WriteFile(filepath.Join(dir, "secrets.yaml"), []byte("a: alpha\nb: bravo\n"), 0o600) + if err != nil { t.Fatal(err) } - if err := age.EncryptSecretsFile(dir); err != nil { + err = age.EncryptSecretsFile(dir) + if err != nil { t.Fatal(err) } first, err := os.ReadFile(filepath.Join(dir, "secrets.encrypted.yaml")) @@ -361,10 +383,12 @@ func TestContract_Age_SecretsFile_ChangedValueLocalizedDiff(t *testing.T) { } // Change b's value, leave a alone. - if err := os.WriteFile(filepath.Join(dir, "secrets.yaml"), []byte("a: alpha\nb: charlie\n"), 0o600); err != nil { + err = os.WriteFile(filepath.Join(dir, "secrets.yaml"), []byte("a: alpha\nb: charlie\n"), 0o600) + if err != nil { t.Fatal(err) } - if err := age.EncryptSecretsFile(dir); err != nil { + err = age.EncryptSecretsFile(dir) + if err != nil { t.Fatal(err) } second, err := os.ReadFile(filepath.Join(dir, "secrets.encrypted.yaml")) @@ -398,10 +422,12 @@ func TestContract_Age_SecretsFile_ChangedValueLocalizedDiff(t *testing.T) { func TestContract_Age_RotateKeys_ReplacesKeyAndPreservesPlaintext(t *testing.T) { dir := t.TempDir() plain := []byte("secret: rotate-me\n") - if err := os.WriteFile(filepath.Join(dir, "secrets.yaml"), plain, 0o600); err != nil { + err := os.WriteFile(filepath.Join(dir, "secrets.yaml"), plain, 0o600) + if err != nil { t.Fatal(err) } - if err := age.EncryptSecretsFile(dir); err != nil { + err = age.EncryptSecretsFile(dir) + if err != nil { t.Fatal(err) } oldPub, err := age.GetPublicKeyFromFile(dir) @@ -409,7 +435,8 @@ func TestContract_Age_RotateKeys_ReplacesKeyAndPreservesPlaintext(t *testing.T) t.Fatal(err) } - if err := age.RotateKeys(dir); err != nil { + err = age.RotateKeys(dir) + if err != nil { t.Fatalf("RotateKeys: %v", err) } @@ -423,10 +450,12 @@ func TestContract_Age_RotateKeys_ReplacesKeyAndPreservesPlaintext(t *testing.T) } // Decrypt with whatever key is on disk now — plaintext must round-trip. - if err := os.Remove(filepath.Join(dir, "secrets.yaml")); err != nil { + err = os.Remove(filepath.Join(dir, "secrets.yaml")) + if err != nil { t.Fatal(err) } - if err := age.DecryptSecretsFile(dir); err != nil { + err = age.DecryptSecretsFile(dir) + if err != nil { t.Fatalf("Decrypt after rotation: %v", err) } got, err := os.ReadFile(filepath.Join(dir, "secrets.yaml")) @@ -434,10 +463,12 @@ func TestContract_Age_RotateKeys_ReplacesKeyAndPreservesPlaintext(t *testing.T) t.Fatal(err) } var orig, after map[string]any - if err := yaml.Unmarshal(plain, &orig); err != nil { + err = yaml.Unmarshal(plain, &orig) + if err != nil { t.Fatal(err) } - if err := yaml.Unmarshal(got, &after); err != nil { + err = yaml.Unmarshal(got, &after) + if err != nil { t.Fatal(err) } if !mapsEqual(orig, after) { @@ -459,17 +490,20 @@ func TestContract_Age_RotateKeys_ReplacesKeyAndPreservesPlaintext(t *testing.T) // that exercises the equivalent contract via the platform's // security descriptor APIs. func TestContract_Age_RotateKeys_BothFilesMode0600(t *testing.T) { - if runtime.GOOS == "windows" { + if runtime.GOOS == goosWindows { t.Skip("Unix permission bits are not honoured on NTFS; secureperm has a Windows-side DACL test that covers the equivalent contract") } dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "secrets.yaml"), []byte("secret: x\n"), 0o600); err != nil { + err := os.WriteFile(filepath.Join(dir, "secrets.yaml"), []byte("secret: x\n"), 0o600) + if err != nil { t.Fatal(err) } - if err := age.EncryptSecretsFile(dir); err != nil { + err = age.EncryptSecretsFile(dir) + if err != nil { t.Fatal(err) } - if err := age.RotateKeys(dir); err != nil { + err = age.RotateKeys(dir) + if err != nil { t.Fatal(err) } for _, name := range []string{"talm.key", "secrets.encrypted.yaml"} { @@ -489,14 +523,16 @@ func TestContract_Age_RotateKeys_BothFilesMode0600(t *testing.T) { // same defense-in-depth permission. Skipped on Windows for the // same NTFS reason as BothFilesMode0600. func TestContract_Age_EncryptSecretsFile_Mode0600(t *testing.T) { - if runtime.GOOS == "windows" { + if runtime.GOOS == goosWindows { t.Skip("Unix permission bits are not honoured on NTFS; secureperm has a Windows-side DACL test that covers the equivalent contract") } dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "secrets.yaml"), []byte("secret: x\n"), 0o600); err != nil { + err := os.WriteFile(filepath.Join(dir, "secrets.yaml"), []byte("secret: x\n"), 0o600) + if err != nil { t.Fatal(err) } - if err := age.EncryptSecretsFile(dir); err != nil { + err = age.EncryptSecretsFile(dir) + if err != nil { t.Fatal(err) } info, err := os.Stat(filepath.Join(dir, "secrets.encrypted.yaml")) @@ -513,14 +549,16 @@ func TestContract_Age_EncryptSecretsFile_Mode0600(t *testing.T) { // EncryptSecretsFile — the function is the generic kubeconfig / // arbitrary-YAML variant of the secrets-encrypt path. func TestContract_Age_EncryptYAMLFile_Mode0600(t *testing.T) { - if runtime.GOOS == "windows" { + if runtime.GOOS == goosWindows { t.Skip("Unix permission bits are not honoured on NTFS; secureperm has a Windows-side DACL test that covers the equivalent contract") } dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "kubeconfig.yaml"), []byte("k: v\n"), 0o600); err != nil { + err := os.WriteFile(filepath.Join(dir, "kubeconfig.yaml"), []byte("k: v\n"), 0o600) + if err != nil { t.Fatal(err) } - if err := age.EncryptYAMLFile(dir, "kubeconfig.yaml", "kubeconfig.encrypted.yaml"); err != nil { + err = age.EncryptYAMLFile(dir, "kubeconfig.yaml", "kubeconfig.encrypted.yaml") + if err != nil { t.Fatal(err) } info, err := os.Stat(filepath.Join(dir, "kubeconfig.encrypted.yaml")) @@ -538,17 +576,21 @@ func TestContract_Age_EncryptYAMLFile_Mode0600(t *testing.T) { // would clutter the project and confuse subsequent runs. func TestContract_Age_RotateKeys_NoLeftoverBackups(t *testing.T) { dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "secrets.yaml"), []byte("secret: x\n"), 0o600); err != nil { + err := os.WriteFile(filepath.Join(dir, "secrets.yaml"), []byte("secret: x\n"), 0o600) + if err != nil { t.Fatal(err) } - if err := age.EncryptSecretsFile(dir); err != nil { + err = age.EncryptSecretsFile(dir) + if err != nil { t.Fatal(err) } - if err := age.RotateKeys(dir); err != nil { + err = age.RotateKeys(dir) + if err != nil { t.Fatal(err) } for _, name := range []string{"talm.key.rotation-backup", "secrets.encrypted.yaml.rotation-backup"} { - if _, err := os.Stat(filepath.Join(dir, name)); err == nil { + _, statErr := os.Stat(filepath.Join(dir, name)) + if statErr == nil { t.Errorf("rotation left backup file %q on disk", name) } } @@ -578,10 +620,12 @@ func TestContract_Age_RotateKeys_RefusesWhenDirectoryBlocksBackupPath(t *testing t.Skip("running as root — directory permissions and os.Remove behaviour differ") } dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "secrets.yaml"), []byte("secret: x\n"), 0o600); err != nil { + err := os.WriteFile(filepath.Join(dir, "secrets.yaml"), []byte("secret: x\n"), 0o600) + if err != nil { t.Fatal(err) } - if err := age.EncryptSecretsFile(dir); err != nil { + err = age.EncryptSecretsFile(dir) + if err != nil { t.Fatal(err) } @@ -590,7 +634,8 @@ func TestContract_Age_RotateKeys_RefusesWhenDirectoryBlocksBackupPath(t *testing // nil error for directories) and refuses with the leftover // message. The originals are not touched. dirAtBackupPath := filepath.Join(dir, "talm.key.rotation-backup") - if err := os.MkdirAll(dirAtBackupPath, 0o755); err != nil { + err = os.MkdirAll(dirAtBackupPath, 0o755) + if err != nil { t.Fatal(err) } defer func() { _ = os.RemoveAll(dirAtBackupPath) }() @@ -604,7 +649,8 @@ func TestContract_Age_RotateKeys_RefusesWhenDirectoryBlocksBackupPath(t *testing t.Fatal(err) } - if err := age.RotateKeys(dir); err == nil { + err = age.RotateKeys(dir) + if err == nil { t.Fatal("expected RotateKeys to fail when a directory blocks the backup path") } gotKey, err := os.ReadFile(filepath.Join(dir, "talm.key")) @@ -632,20 +678,23 @@ func TestContract_Age_RotateKeys_RefusesWhenDirectoryBlocksBackupPath(t *testing // from being silently overwritten by the second run. func TestContract_Age_RotateKeys_RefusesOnLeftoverBackup(t *testing.T) { dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "secrets.yaml"), []byte("secret: x\n"), 0o600); err != nil { + err := os.WriteFile(filepath.Join(dir, "secrets.yaml"), []byte("secret: x\n"), 0o600) + if err != nil { t.Fatal(err) } - if err := age.EncryptSecretsFile(dir); err != nil { + err = age.EncryptSecretsFile(dir) + if err != nil { t.Fatal(err) } // Simulate an interrupted previous rotation by creating a // dangling `talm.key.rotation-backup`. leftover := filepath.Join(dir, "talm.key.rotation-backup") - if err := os.WriteFile(leftover, []byte("orphaned"), 0o600); err != nil { + err = os.WriteFile(leftover, []byte("orphaned"), 0o600) + if err != nil { t.Fatal(err) } - err := age.RotateKeys(dir) + err = age.RotateKeys(dir) if err == nil { t.Fatal("expected RotateKeys to refuse with leftover backup present") } @@ -658,7 +707,8 @@ func TestContract_Age_RotateKeys_RefusesOnLeftoverBackup(t *testing.T) { t.Errorf("error must mention both 'interrupted' and 'cleanup' as possible origins, got: %v", err) } // The leftover must still be on disk — refusal must not delete it. - if _, statErr := os.Stat(leftover); statErr != nil { + _, statErr := os.Stat(leftover) + if statErr != nil { t.Errorf("leftover backup was removed despite refusal: %v", statErr) } } @@ -674,16 +724,20 @@ func TestContract_Age_GenericYAMLFile_RoundTrip(t *testing.T) { plain := []byte("kubeconfig:\n server: https://api.example.com:6443\n token: abc123\n") plainName := "kubeconfig.yaml" encName := "kubeconfig.encrypted.yaml" - if err := os.WriteFile(filepath.Join(dir, plainName), plain, 0o600); err != nil { + err := os.WriteFile(filepath.Join(dir, plainName), plain, 0o600) + if err != nil { t.Fatal(err) } - if err := age.EncryptYAMLFile(dir, plainName, encName); err != nil { + err = age.EncryptYAMLFile(dir, plainName, encName) + if err != nil { t.Fatalf("EncryptYAMLFile: %v", err) } - if err := os.Remove(filepath.Join(dir, plainName)); err != nil { + err = os.Remove(filepath.Join(dir, plainName)) + if err != nil { t.Fatal(err) } - if err := age.DecryptYAMLFile(dir, encName, plainName); err != nil { + err = age.DecryptYAMLFile(dir, encName, plainName) + if err != nil { t.Fatalf("DecryptYAMLFile: %v", err) } got, err := os.ReadFile(filepath.Join(dir, plainName)) @@ -691,10 +745,12 @@ func TestContract_Age_GenericYAMLFile_RoundTrip(t *testing.T) { t.Fatal(err) } var origMap, gotMap map[string]any - if err := yaml.Unmarshal(plain, &origMap); err != nil { + err = yaml.Unmarshal(plain, &origMap) + if err != nil { t.Fatal(err) } - if err := yaml.Unmarshal(got, &gotMap); err != nil { + err = yaml.Unmarshal(got, &gotMap) + if err != nil { t.Fatal(err) } if !mapsEqual(origMap, gotMap) { diff --git a/pkg/commands/apply.go b/pkg/commands/apply.go index c72b6043..df688b2b 100644 --- a/pkg/commands/apply.go +++ b/pkg/commands/apply.go @@ -34,8 +34,21 @@ import ( "github.com/siderolabs/talos/pkg/machinery/constants" ) +// parentDir is the path element that escapes the current directory +// when it appears at the start of a cleaned relative path. Hoisted to +// a const so the goconst gate sees a single canonical reference. +const parentDir = ".." + +// applyCommandName labels this subcommand inside engine.Options for +// FailIfMultiNodes error wording. Centralised so the template +// rendering options block and any test asserting against the field +// share a single canonical value. +const applyCommandName = "talm apply" + +//nolint:gochecknoglobals // cobra command flag struct, idiomatic for cobra-based CLIs var applyCmdFlags struct { helpers.Mode + certFingerprints []string insecure bool configFiles []string // -f/--files @@ -52,50 +65,56 @@ var applyCmdFlags struct { endpointsFromArgs bool } +//nolint:gochecknoglobals // cobra command, idiomatic for cobra-based CLIs var applyCmd = &cobra.Command{ Use: "apply", Short: "Apply config to a Talos node", Long: ``, Args: cobra.NoArgs, - PreRunE: func(cmd *cobra.Command, args []string) error { + PreRunE: func(cmd *cobra.Command, _ []string) error { if !cmd.Flags().Changed("talos-version") { applyCmdFlags.talosVersion = Config.TemplateOptions.TalosVersion } + if !cmd.Flags().Changed("with-secrets") { applyCmdFlags.withSecrets = Config.TemplateOptions.WithSecrets } + if !cmd.Flags().Changed("kubernetes-version") { applyCmdFlags.kubernetesVersion = Config.TemplateOptions.KubernetesVersion } + if !cmd.Flags().Changed("debug") { applyCmdFlags.debug = Config.TemplateOptions.Debug } + if !cmd.Flags().Changed("preserve") { applyCmdFlags.preserve = Config.UpgradeOptions.Preserve } + if !cmd.Flags().Changed("stage") { applyCmdFlags.stage = Config.UpgradeOptions.Stage } + if !cmd.Flags().Changed("force") { applyCmdFlags.force = Config.UpgradeOptions.Force } + applyCmdFlags.nodesFromArgs = len(GlobalArgs.Nodes) > 0 applyCmdFlags.endpointsFromArgs = len(GlobalArgs.Endpoints) > 0 // Set dummy endpoint to avoid errors on building client if len(GlobalArgs.Endpoints) == 0 { - GlobalArgs.Endpoints = append(GlobalArgs.Endpoints, "127.0.0.1") + GlobalArgs.Endpoints = append(GlobalArgs.Endpoints, defaultLocalEndpoint) } return nil }, - RunE: func(cmd *cobra.Command, args []string) error { - return apply(args) + RunE: func(_ *cobra.Command, _ []string) error { + return apply() }, } -func apply(args []string) error { - ctx := context.Background() - +func apply() error { // Expand directories to YAML files expandedFiles, err := ExpandFilePaths(applyCmdFlags.configFiles) if err != nil { @@ -103,164 +122,222 @@ func apply(args []string) error { } // Detect root from files if specified, otherwise fallback to cwd - if err := DetectAndSetRootFromFiles(expandedFiles); err != nil { + err = DetectAndSetRootFromFiles(expandedFiles) + if err != nil { return err } for _, configFile := range expandedFiles { - modelineTemplates, err := processModelineAndUpdateGlobals(configFile, applyCmdFlags.nodesFromArgs, applyCmdFlags.endpointsFromArgs, true) + err = applyOneFile(configFile) if err != nil { return err } - // Resolve secrets.yaml path relative to project root if not absolute - withSecretsPath := ResolveSecretsPath(applyCmdFlags.withSecrets) - - if len(modelineTemplates) > 0 { - // Template rendering path: render templates online per node and - // apply the rendered config plus the node file overlay. See - // applyTemplatesPerNode for why the loop is mandatory. - opts := buildApplyRenderOptions(modelineTemplates, withSecretsPath) - nodes := append([]string(nil), GlobalArgs.Nodes...) - fmt.Printf("- talm: file=%s, nodes=%s, endpoints=%s\n", configFile, nodes, GlobalArgs.Endpoints) - - applyClosure := func(ctx context.Context, c *client.Client, data []byte) error { - // ctx is shaped for ApplyConfiguration on every apply path: - // the auth branch sets `nodes` (plural, one element) via - // openClientPerNodeAuth so apid resolves a single backend - // and helpers.ForEachResource can read the plural key from - // inside template lookups; the insecure branch carries no - // node metadata at all and the maintenance client dials a - // single endpoint per call. - // - // The COSI preflight needs a different context shape: - // Talos's apid director rejects every COSI method whose - // ctx carries the plural "nodes" key, regardless of slice - // length (its COSI guard is unconditional). cosiVersionReader - // swallows errors and returns ok=false on rejection, so the - // preflight would silently no-op on the auth path — defeating - // the whole point of the version-mismatch warning that - // preflightCheckTalosVersion exists to surface. - // cosiPreflightContext rebuilds ctx with the singular "node" - // key so the COSI router accepts the call; ApplyConfiguration - // keeps the original ctx unchanged. - cosiCtx, err := cosiPreflightContext(ctx) - if err != nil { - return err - } - preflightCheckTalosVersion(cosiCtx, cosiVersionReader(c), applyCmdFlags.talosVersion, os.Stderr) - - resp, err := c.ApplyConfiguration(ctx, &machineapi.ApplyConfigurationRequest{ - Data: data, - Mode: applyCmdFlags.Mode.Mode, - DryRun: applyCmdFlags.dryRun, - TryModeTimeout: durationpb.New(applyCmdFlags.configTryTimeout), - }) - if err != nil { - return errors.Wrap(annotateApplyConfigError(err), "applying new configuration") - } - helpers.PrintApplyResults(resp) - return nil - } - if applyCmdFlags.insecure { - openClient := openClientPerNodeMaintenance(applyCmdFlags.certFingerprints, WithClientMaintenance) - if err := applyTemplatesPerNode(opts, configFile, nodes, openClient, engine.Render, applyClosure); err != nil { - return err - } - } else { - if err := withApplyClientBare(func(parentCtx context.Context, c *client.Client) error { - resolved := resolveAuthTemplateNodes(nodes, c) - openClient := openClientPerNodeAuth(parentCtx, c) - return applyTemplatesPerNode(opts, configFile, resolved, openClient, engine.Render, applyClosure) - }); err != nil { - return err - } - } - } else { - // Direct patch path: apply config file as patch against empty bundle - opts := buildApplyPatchOptions(withSecretsPath) - patches := []string{"@" + configFile} - configBundle, machineType, err := engine.FullConfigProcess(ctx, opts, patches) - if err != nil { - return errors.WithHint( - errors.Wrap(err, "full config processing"), - "the chart did not render or could not be combined with the supplied patches; check that the chart in scope and the patches reference fields that exist", - ) - } + resetGlobalArgsBetweenFiles(applyCmdFlags.nodesFromArgs, applyCmdFlags.endpointsFromArgs) + } - result, err := engine.SerializeConfiguration(configBundle, machineType) - if err != nil { - return errors.WithHint( - errors.Wrap(err, "serializing configuration"), - "the merged config bundle could not be encoded back to YAML; this is internal — file an issue if reproducible", - ) - } + return nil +} + +// resetGlobalArgsBetweenFiles wipes the per-file GlobalArgs.Nodes / +// GlobalArgs.Endpoints state between iterations of a multi-file apply +// or template command. Each iteration's modeline rewrites these +// values in-place via processModelineAndUpdateGlobals; without a +// reset between files, the previous file's modeline-supplied values +// would leak into a subsequent file whose modeline omits them. +// +// The empty-slice reset on Endpoints is deliberate. The talos client +// (cmd/talosctl/pkg/talos/global/client.go) registers +// client.WithConfig(cfg) first and then layers client.WithEndpoints +// on top only when len(c.Endpoints) > 0. An empty GlobalArgs.Endpoints +// therefore falls back to the talosconfig context endpoints — the +// behavior an operator expects when their `talosconfig` already names +// the cluster. Re-seeding defaultLocalEndpoint here would override +// the talosconfig context with loopback on every file past the first +// whose modeline omits endpoints, silently mis-routing applies to +// 127.0.0.1. +// +// PreRunE seeds defaultLocalEndpoint once before the loop runs as a +// belt-and-braces guard against zero-endpoint client construction on +// the very first file. From the second file onward the talosconfig +// fallback is the intended behavior. +func resetGlobalArgsBetweenFiles(nodesFromArgs, endpointsFromArgs bool) { + if !nodesFromArgs { + GlobalArgs.Nodes = []string{} + } + + if !endpointsFromArgs { + GlobalArgs.Endpoints = []string{} + } +} + +// applyOneFile dispatches a single config file through either the +// template-rendering path (when its modeline references templates) +// or the direct-patch path (otherwise). Splitting the per-file work +// out of the outer apply loop keeps the cognitive complexity of +// either half within the linter's gate. +func applyOneFile(configFile string) error { + modelineTemplates, err := processModelineAndUpdateGlobals(configFile, applyCmdFlags.nodesFromArgs, applyCmdFlags.endpointsFromArgs, true) + if err != nil { + return err + } + // Resolve secrets.yaml path relative to project root if not absolute + withSecretsPath := ResolveSecretsPath(applyCmdFlags.withSecrets) + + if len(modelineTemplates) > 0 { + return applyOneFileTemplateMode(configFile, modelineTemplates, withSecretsPath) + } + + return applyOneFileDirectPatchMode(configFile, withSecretsPath) +} + +// applyOneFileTemplateMode runs the template-rendering apply path for +// a single configFile: renders templates per node, overlays the node +// file body, and ApplyConfigurations the result. See +// applyTemplatesPerNode for why the loop is mandatory. +func applyOneFileTemplateMode(configFile string, modelineTemplates []string, withSecretsPath string) error { + opts := buildApplyRenderOptions(modelineTemplates, withSecretsPath) + + nodes := append([]string(nil), GlobalArgs.Nodes...) + //nolint:forbidigo // CLI progress line surfaces the file-to-target mapping for the operator + fmt.Printf("- talm: file=%s, nodes=%s, endpoints=%s\n", configFile, nodes, GlobalArgs.Endpoints) + + applyClosure := buildApplyClosure() + + if applyCmdFlags.insecure { + openClient := openClientPerNodeMaintenance(applyCmdFlags.certFingerprints, WithClientMaintenance) + + return applyTemplatesPerNode(opts, configFile, nodes, openClient, engine.Render, applyClosure) + } + + return withApplyClientBare(func(parentCtx context.Context, c *client.Client) error { + resolved := resolveAuthTemplateNodes(nodes, c) + openClient := openClientPerNodeAuth(parentCtx, c) + + return applyTemplatesPerNode(opts, configFile, resolved, openClient, engine.Render, applyClosure) + }) +} + +// buildApplyClosure builds the per-node apply step used by every +// template-rendering mode. ctx is shaped for ApplyConfiguration on +// every apply path: the auth branch sets `nodes` (plural, one +// element) via openClientPerNodeAuth so apid resolves a single +// backend and helpers.ForEachResource can read the plural key from +// inside template lookups; the insecure branch carries no node +// metadata at all and the maintenance client dials a single endpoint +// per call. +// +// The COSI preflight needs a different context shape: Talos's apid +// director rejects every COSI method whose ctx carries the plural +// "nodes" key, regardless of slice length (its COSI guard is +// unconditional). cosiVersionReader swallows errors and returns +// ok=false on rejection, so the preflight would silently no-op on +// the auth path — defeating the whole point of the version-mismatch +// warning that preflightCheckTalosVersion exists to surface. +// cosiPreflightContext rebuilds ctx with the singular "node" key so +// the COSI router accepts the call; ApplyConfiguration keeps the +// original ctx unchanged. +func buildApplyClosure() applyFunc { + return func(ctx context.Context, c *client.Client, data []byte) error { + cosiCtx, err := cosiPreflightContext(ctx) + if err != nil { + return err + } + + preflightCheckTalosVersion(cosiCtx, cosiVersionReader(c), applyCmdFlags.talosVersion, os.Stderr) + + resp, err := c.ApplyConfiguration(ctx, &machineapi.ApplyConfigurationRequest{ + Data: data, + Mode: applyCmdFlags.Mode.Mode, + DryRun: applyCmdFlags.dryRun, + TryModeTimeout: durationpb.New(applyCmdFlags.configTryTimeout), + }) + if err != nil { + return errors.Wrap(annotateApplyConfigError(err), "applying new configuration") + } + + helpers.PrintApplyResults(resp) + + return nil + } +} + +// applyOneFileDirectPatchMode runs the direct-patch apply path for a +// single configFile: renders the chart against an empty bundle with +// the file as a patch, then ApplyConfigurations the merged result. +// COSI does not support multi-node proxying — apid's director +// rejects every /cosi.* method whose ctx carries the plural "nodes" +// key, regardless of slice length. The rule lives in +// internal/app/apid/pkg/director/director.go (search for the +// "one-2-many proxying is not supported" guard). Run preflight per +// node with a single-target context. +// +// client.WithNode (singular) here is intentional and unrelated to +// the auth template-rendering apply path's switch from WithNode to +// WithNodes (openClientPerNodeAuth) — preflight performs a direct +// COSI Get against one resource, not a helpers.ForEachResource walk +// that reads the plural "nodes" metadata key. apid's COSI router +// accepts the singular "node" key for single-target addressing (and +// rejects the plural "nodes" key for any COSI method, regardless of +// slice length — see cosiPreflightContext for the auth path's +// workaround that has to scope ctx back to "node" before calling the +// same COSI preflight). +func applyOneFileDirectPatchMode(configFile, withSecretsPath string) error { + opts := buildApplyPatchOptions(withSecretsPath) + patches := []string{"@" + configFile} + + configBundle, machineType, err := engine.FullConfigProcess(opts, patches) + if err != nil { + //nolint:wrapcheck // already wrapped via errors.Wrap, WithHint adds operator-facing guidance + return errors.WithHint( + errors.Wrap(err, "full config processing"), + "the chart did not render or could not be combined with the supplied patches; check that the chart in scope and the patches reference fields that exist", + ) + } + + result, err := engine.SerializeConfiguration(configBundle, machineType) + if err != nil { + //nolint:wrapcheck // already wrapped via errors.Wrap, WithHint adds operator-facing guidance + return errors.WithHint( + errors.Wrap(err, "serializing configuration"), + "the merged config bundle could not be encoded back to YAML; this is internal — file an issue if reproducible", + ) + } - if err := withApplyClient(func(ctx context.Context, c *client.Client) error { - // wrapWithNodeContext fills ctx via client.WithNodes from - // talosconfig when --nodes is omitted, but does not mutate - // GlobalArgs.Nodes. Mirror its resolution here so the log line - // and the per-node preflight loop see the actual targets. - targetNodes := append([]string(nil), GlobalArgs.Nodes...) - if len(targetNodes) == 0 { - if cfg := c.GetConfigContext(); cfg != nil { - targetNodes = append(targetNodes, cfg.Nodes...) - } - } - fmt.Printf("- talm: file=%s, nodes=%s, endpoints=%s\n", configFile, targetNodes, GlobalArgs.Endpoints) - - // COSI does not support multi-node proxying — apid's - // director rejects every /cosi.* method whose ctx - // carries the plural "nodes" key, regardless of slice - // length. The rule lives in - // internal/app/apid/pkg/director/director.go (search - // for the "one-2-many proxying is not supported" - // guard). Run preflight per node with a single-target - // context. - // - // client.WithNode (singular) here is intentional and unrelated - // to the auth template-rendering apply path's switch from - // WithNode to WithNodes (openClientPerNodeAuth) — preflight - // performs a direct COSI Get against one resource, not a - // helpers.ForEachResource walk that reads the plural "nodes" - // metadata key. apid's COSI router accepts the singular - // "node" key for single-target addressing (and rejects the - // plural "nodes" key for any COSI method, regardless of - // slice length — see cosiPreflightContext for the auth - // path's workaround that has to scope ctx back to "node" - // before calling the same COSI preflight). - read := cosiVersionReader(c) - for _, node := range targetNodes { - preflightCheckTalosVersion(client.WithNode(ctx, node), read, applyCmdFlags.talosVersion, os.Stderr) - } - - resp, err := c.ApplyConfiguration(ctx, &machineapi.ApplyConfigurationRequest{ - Data: result, - Mode: applyCmdFlags.Mode.Mode, - DryRun: applyCmdFlags.dryRun, - TryModeTimeout: durationpb.New(applyCmdFlags.configTryTimeout), - }) - if err != nil { - return errors.Wrap(annotateApplyConfigError(err), "applying new configuration") - } - - helpers.PrintApplyResults(resp) - - return nil - }); err != nil { - return err + return withApplyClient(func(ctx context.Context, c *client.Client) error { + // wrapWithNodeContext fills ctx via client.WithNodes from + // talosconfig when --nodes is omitted, but does not mutate + // GlobalArgs.Nodes. Mirror its resolution here so the log line + // and the per-node preflight loop see the actual targets. + targetNodes := append([]string(nil), GlobalArgs.Nodes...) + if len(targetNodes) == 0 { + if cfg := c.GetConfigContext(); cfg != nil { + targetNodes = append(targetNodes, cfg.Nodes...) } } - // Reset args - if !applyCmdFlags.nodesFromArgs { - GlobalArgs.Nodes = []string{} + //nolint:forbidigo // CLI progress line surfaces the file-to-target mapping for the operator + fmt.Printf("- talm: file=%s, nodes=%s, endpoints=%s\n", configFile, targetNodes, GlobalArgs.Endpoints) + + read := cosiVersionReader(c) + for _, node := range targetNodes { + preflightCheckTalosVersion(client.WithNode(ctx, node), read, applyCmdFlags.talosVersion, os.Stderr) } - if !applyCmdFlags.endpointsFromArgs { - GlobalArgs.Endpoints = []string{} + + resp, err := c.ApplyConfiguration(ctx, &machineapi.ApplyConfigurationRequest{ + Data: result, + Mode: applyCmdFlags.Mode.Mode, + DryRun: applyCmdFlags.dryRun, + TryModeTimeout: durationpb.New(applyCmdFlags.configTryTimeout), + }) + if err != nil { + return errors.Wrap(annotateApplyConfigError(err), "applying new configuration") } - } - return nil + + helpers.PrintApplyResults(resp) + + return nil + }) } // withApplyClient creates a Talos client appropriate for the current apply @@ -269,33 +346,35 @@ func apply(args []string) error { // GlobalArgs.Nodes (when set) or the talosconfig context's Nodes (when not). // Used by the direct-patch branch where multi-node fan-out happens at the // gRPC layer inside ApplyConfiguration. -func withApplyClient(f func(ctx context.Context, c *client.Client) error) error { - return withApplyClientBare(wrapWithNodeContext(f)) +func withApplyClient(action func(ctx context.Context, c *client.Client) error) error { + return withApplyClientBare(wrapWithNodeContext(action)) } // withApplyClientBare connects to Talos for the current apply mode but does // NOT inject node metadata into the context — leaving that decision to the // caller. Used by the template-rendering path (see applyTemplatesPerNode for // the rationale). -func withApplyClientBare(f func(ctx context.Context, c *client.Client) error) error { +func withApplyClientBare(action func(ctx context.Context, c *client.Client) error) error { if applyCmdFlags.insecure { // Maintenance mode reads its endpoints directly from // GlobalArgs.Nodes — gRPC node metadata is not consulted. - return WithClientMaintenance(applyCmdFlags.certFingerprints, f) + return WithClientMaintenance(applyCmdFlags.certFingerprints, action) } if GlobalArgs.SkipVerify { - return WithClientSkipVerify(f) + return WithClientSkipVerify(action) } - return WithClientNoNodes(f) + return WithClientNoNodes(action) } // renderFunc, applyFunc and openClientFunc are injection points for // applyTemplatesPerNode so unit tests can drive the loop with fakes instead // of a real Talos client. -type renderFunc func(ctx context.Context, c *client.Client, opts engine.Options) ([]byte, error) -type applyFunc func(ctx context.Context, c *client.Client, data []byte) error +type ( + renderFunc func(ctx context.Context, c *client.Client, opts engine.Options) ([]byte, error) + applyFunc func(ctx context.Context, c *client.Client, data []byte) error +) // openClientFunc opens a Talos client suitable for a single node and runs // action with it. Authenticated mode reuses one parent client and rotates @@ -321,6 +400,8 @@ type openClientFunc func(node string, action func(ctx context.Context, c *client // across the endpoint list and most nodes never see the config. // // Both modes share this loop via openClient. +// +//nolint:gocritic // opts taken by value to keep the test-injection signature stable; engine.Options is treated as a value type elsewhere func applyTemplatesPerNode( opts engine.Options, configFile string, @@ -330,6 +411,7 @@ func applyTemplatesPerNode( apply applyFunc, ) error { if len(nodes) == 0 { + //nolint:wrapcheck // sentinel constructed in-place; WithHint attaches operator guidance return errors.WithHint( errors.New("nodes are not set for the command"), "set the targets via --nodes, a `# talm: nodes=[...]` modeline at the top of the node file, or the talosconfig context", @@ -343,9 +425,11 @@ func applyTemplatesPerNode( if len(nodes) > 1 { hasOverlay, err := engine.NodeFileHasOverlay(configFile) if err != nil { - return err + return errors.Wrapf(err, "checking %q for per-node body overlay", configFile) } + if hasOverlay { + //nolint:wrapcheck // sentinel constructed in-place; WithHintf attaches operator guidance return errors.WithHintf( errors.Newf("node file %q targets %d nodes (%v) but carries a non-empty per-node body", configFile, len(nodes), nodes), "split %q into one file per node, or remove the per-node fields if you want the rendered template alone applied to each", @@ -353,13 +437,16 @@ func applyTemplatesPerNode( ) } } + for _, node := range nodes { - if err := openClient(node, func(ctx context.Context, c *client.Client) error { + err := openClient(node, func(ctx context.Context, c *client.Client) error { return renderMergeAndApply(ctx, c, opts, configFile, render, apply) - }); err != nil { + }) + if err != nil { return errors.Wrapf(err, "node %s", node) } } + return nil } @@ -391,8 +478,10 @@ type maintenanceClientFunc func(fingerprints []string, action func(ctx context.C func openClientPerNodeMaintenance(fingerprints []string, mkClient maintenanceClientFunc) openClientFunc { return func(node string, action func(ctx context.Context, c *client.Client) error) error { savedNodes := append([]string(nil), GlobalArgs.Nodes...) + GlobalArgs.Nodes = []string{node} defer func() { GlobalArgs.Nodes = savedNodes }() + return mkClient(fingerprints, action) } } @@ -447,6 +536,7 @@ func cosiPreflightContext(ctx context.Context) (context.Context, error) { if !ok { return ctx, nil } + nodes := md.Get("nodes") switch len(nodes) { case 0: @@ -454,6 +544,7 @@ func cosiPreflightContext(ctx context.Context) (context.Context, error) { case 1: return client.WithNode(ctx, nodes[0]), nil default: + //nolint:wrapcheck // sentinel constructed in-place; WithHint attaches operator guidance return nil, errors.WithHint( errors.Newf("cosiPreflightContext: refusing to scope ctx with %d nodes; expected exactly one", len(nodes)), "applyTemplatesPerNode iterates one node at a time, so a multi-element plural slice at this point indicates a broken caller", @@ -473,29 +564,37 @@ func resolveAuthTemplateNodes(cliNodes []string, c *client.Client) []string { if len(cliNodes) > 0 { return cliNodes } + if c == nil { return nil } + cfg := c.GetConfigContext() if cfg == nil { return nil } + return append([]string(nil), cfg.Nodes...) } // renderMergeAndApply is the per-node body shared by every apply mode. +// +//nolint:gocritic // opts taken by value to mirror applyTemplatesPerNode's test-injection signature func renderMergeAndApply(ctx context.Context, c *client.Client, opts engine.Options, configFile string, render renderFunc, apply applyFunc) error { rendered, err := render(ctx, c, opts) if err != nil { + //nolint:wrapcheck // already wrapped via errors.Wrap, WithHint adds operator-facing guidance return errors.WithHint( errors.Wrap(err, "template rendering"), "the chart did not render against the current node's discovery state; verify the templates referenced in the modeline exist and the node is reachable", ) } + merged, err := engine.MergeFileAsPatch(rendered, configFile) if err != nil { return errors.Wrapf(err, "merging node file %q as patch", configFile) } + return apply(ctx, c, merged) } @@ -505,6 +604,7 @@ func renderMergeAndApply(ctx context.Context, c *client.Client, opts engine.Opti // client and passes it to engine.Render together with these options. func buildApplyRenderOptions(modelineTemplates []string, withSecretsPath string) engine.Options { resolvedTemplates := resolveTemplatePaths(modelineTemplates, Config.RootDir) + return engine.Options{ TalosVersion: applyCmdFlags.talosVersion, WithSecrets: withSecretsPath, @@ -513,7 +613,7 @@ func buildApplyRenderOptions(modelineTemplates []string, withSecretsPath string) Full: true, Root: Config.RootDir, TemplateFiles: resolvedTemplates, - CommandName: "talm apply", + CommandName: applyCommandName, } } @@ -532,7 +632,7 @@ func buildApplyPatchOptions(withSecretsPath string) engine.Options { // attempts to resolve nodes from the client's config context. // This function does not mutate GlobalArgs. It reads GlobalArgs.Nodes at // invocation time (not at wrapper creation time) and makes a defensive copy. -func wrapWithNodeContext(f func(ctx context.Context, c *client.Client) error) func(ctx context.Context, c *client.Client) error { +func wrapWithNodeContext(action func(ctx context.Context, c *client.Client) error) func(ctx context.Context, c *client.Client) error { return func(ctx context.Context, c *client.Client) error { nodes := append([]string(nil), GlobalArgs.Nodes...) if len(nodes) < 1 { @@ -542,6 +642,7 @@ func wrapWithNodeContext(f func(ctx context.Context, c *client.Client) error) fu "this code path requires a Talos client; if you reached it from a flow that did not open one, check the call site", ) } + configContext := c.GetConfigContext() if configContext == nil { return errors.WithHint( @@ -549,21 +650,23 @@ func wrapWithNodeContext(f func(ctx context.Context, c *client.Client) error) fu "the talosconfig has no active context; pick one with `talosctl config context ` or pass --talosconfig", ) } + nodes = configContext.Nodes } ctx = client.WithNodes(ctx, nodes...) - return f(ctx, c) + + return action(ctx, c) } } // isOutsideRoot reports whether a cleaned relative path escapes the -// project root. A HasPrefix(".." ) test would misclassify sibling +// project root. A HasPrefix(parentDir) test would misclassify sibling // directories whose first path element merely starts with "..", such // as "..templates/controlplane.yaml"; we match a full path element // instead. func isOutsideRoot(relPath string) bool { - return relPath == ".." || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) + return relPath == parentDir || strings.HasPrefix(relPath, parentDir+string(filepath.Separator)) } // resolveTemplatePaths resolves template file paths relative to the project root, @@ -583,13 +686,16 @@ func resolveTemplatePaths(templates []string, rootDir string) []string { for i, p := range templates { resolved[i] = engine.NormalizeTemplatePath(p) } + return resolved } + absRootDir, rootErr := filepath.Abs(rootDir) if rootErr != nil { for i, p := range templates { resolved[i] = engine.NormalizeTemplatePath(p) } + return resolved } @@ -601,19 +707,25 @@ func resolveTemplatePaths(templates []string, rootDir string) []string { // Resolve relative paths against rootDir, not CWD absTemplatePath = filepath.Join(absRootDir, templatePath) } + relPath, relErr := filepath.Rel(absRootDir, absTemplatePath) if relErr != nil { resolved[i] = engine.NormalizeTemplatePath(templatePath) + continue } + relPath = filepath.Clean(relPath) if isOutsideRoot(relPath) { // Path goes outside project root — use original path as-is resolved[i] = engine.NormalizeTemplatePath(templatePath) + continue } + resolved[i] = engine.NormalizeTemplatePath(relPath) } + return resolved } diff --git a/pkg/commands/apply_test.go b/pkg/commands/apply_test.go index a3bbce18..eaff2d66 100644 --- a/pkg/commands/apply_test.go +++ b/pkg/commands/apply_test.go @@ -2,7 +2,6 @@ package commands import ( "context" - "fmt" "os" "path/filepath" "slices" @@ -15,6 +14,11 @@ import ( "google.golang.org/grpc/metadata" ) +// errSimulatedApplyFailure is a sentinel error used by the +// restore-on-error coverage to drive failingMaintenance into the +// error branch without touching real Talos infrastructure. +var errSimulatedApplyFailure = errors.New("simulated apply failure") + func TestBuildApplyRenderOptions(t *testing.T) { origTalosVersion := applyCmdFlags.talosVersion origKubeVersion := applyCmdFlags.kubernetesVersion @@ -27,14 +31,14 @@ func TestBuildApplyRenderOptions(t *testing.T) { Config.RootDir = origRootDir }() - applyCmdFlags.talosVersion = "v1.12" - applyCmdFlags.kubernetesVersion = "1.31.0" + applyCmdFlags.talosVersion = testTalosVersion + applyCmdFlags.kubernetesVersion = testKubernetesVersion applyCmdFlags.debug = false - Config.RootDir = "/project" + Config.RootDir = testProjectRoot opts := buildApplyRenderOptions( - []string{"templates/controlplane.yaml"}, - "/project/secrets.yaml", + []string{testTemplateControlplaneRel}, + testProjectRoot+"/secrets.yaml", ) if !opts.Full { @@ -43,25 +47,25 @@ func TestBuildApplyRenderOptions(t *testing.T) { if opts.Offline { t.Error("expected Offline=false for online template rendering path") } - if opts.Root != "/project" { - t.Errorf("expected Root=/project, got %s", opts.Root) + if opts.Root != testProjectRoot { + t.Errorf("expected Root=%q, got %s", testProjectRoot, opts.Root) } - if opts.TalosVersion != "v1.12" { - t.Errorf("expected TalosVersion=v1.12, got %s", opts.TalosVersion) + if opts.TalosVersion != testTalosVersion { + t.Errorf("expected TalosVersion=%q, got %s", testTalosVersion, opts.TalosVersion) } - if opts.WithSecrets != "/project/secrets.yaml" { - t.Errorf("expected WithSecrets=/project/secrets.yaml, got %s", opts.WithSecrets) + if opts.WithSecrets != testProjectRoot+"/secrets.yaml" { + t.Errorf("expected WithSecrets=%s/secrets.yaml, got %s", testProjectRoot, opts.WithSecrets) } - if len(opts.TemplateFiles) != 1 || opts.TemplateFiles[0] != "templates/controlplane.yaml" { + if len(opts.TemplateFiles) != 1 || opts.TemplateFiles[0] != testTemplateControlplaneRel { t.Errorf("expected TemplateFiles=[templates/controlplane.yaml], got %v", opts.TemplateFiles) } - if opts.CommandName != "talm apply" { - t.Errorf("expected CommandName=%q, got %q (engine.Render uses this for FailIfMultiNodes error wording)", "talm apply", opts.CommandName) + if opts.CommandName != testTalmApply { + t.Errorf("expected CommandName=%q, got %q (engine.Render uses this for FailIfMultiNodes error wording)", testTalmApply, opts.CommandName) } } func TestResolveAuthTemplateNodes_CLINodesWin(t *testing.T) { - in := []string{"10.0.0.1", "10.0.0.2"} + in := []string{testNodeAddrA, testNodeAddrB} got := resolveAuthTemplateNodes(in, nil) if !slices.Equal(got, in) { t.Errorf("got %v, want %v (CLI nodes must take precedence over talosconfig context)", got, in) @@ -89,7 +93,7 @@ func TestBuildApplyPatchOptions(t *testing.T) { applyCmdFlags.debug = origDebug }() - applyCmdFlags.talosVersion = "v1.12" + applyCmdFlags.talosVersion = testTalosVersion applyCmdFlags.kubernetesVersion = "1.31.0" applyCmdFlags.debug = false @@ -123,10 +127,10 @@ func TestResolveTemplatePaths(t *testing.T) { } // Build a platform-portable absolute path outside tmpRoot. - // filepath.VolumeName is "" on POSIX (yielding e.g. "/other/...") and - // "C:" on Windows (yielding "C:\other\..."). Both are absolute and + // filepath.VolumeName is "" on POSIX (yielding e.g. "/elsewhere/...") and + // "C:" on Windows (yielding "C:\elsewhere\..."). Both are absolute and // definitely outside tmpRoot (which lives under the user temp dir). - absOutside := filepath.Join(filepath.VolumeName(tmpRoot), string(filepath.Separator), "other", "project", "templates", "controlplane.yaml") + absOutside := filepath.Join(filepath.VolumeName(tmpRoot), string(filepath.Separator), "elsewhere", "project", "templates", "controlplane.yaml") tests := []struct { name string @@ -136,27 +140,27 @@ func TestResolveTemplatePaths(t *testing.T) { }{ { name: "relative path with empty rootDir", - templates: []string{"templates/controlplane.yaml"}, + templates: []string{testTemplateControlplaneRel}, rootDir: "", - want: []string{"templates/controlplane.yaml"}, + want: []string{testTemplateControlplaneRel}, }, { name: "relative path resolved against rootDir", - templates: []string{"templates/controlplane.yaml"}, + templates: []string{testTemplateControlplaneRel}, rootDir: tmpRoot, - want: []string{"templates/controlplane.yaml"}, + want: []string{testTemplateControlplaneRel}, }, { name: "multiple paths with rootDir", - templates: []string{"templates/controlplane.yaml", "templates/worker.yaml"}, + templates: []string{testTemplateControlplaneRel, testTemplateWorker}, rootDir: tmpRoot, - want: []string{"templates/controlplane.yaml", "templates/worker.yaml"}, + want: []string{testTemplateControlplaneRel, testTemplateWorker}, }, { name: "absolute path inside rootDir", templates: []string{filepath.Join(tmpRoot, "templates", "controlplane.yaml")}, rootDir: tmpRoot, - want: []string{"templates/controlplane.yaml"}, + want: []string{testTemplateControlplaneRel}, }, { // Constructed to be absolute on both POSIX and Windows so the @@ -173,9 +177,9 @@ func TestResolveTemplatePaths(t *testing.T) { // outside-root check used HasPrefix("..") it would wrongly // drop this path back to the original input. name: "sibling dir whose name starts with .. is inside rootDir", - templates: []string{"..templates/controlplane.yaml"}, + templates: []string{testTemplateControlplane}, rootDir: tmpRoot, - want: []string{"..templates/controlplane.yaml"}, + want: []string{testTemplateControlplane}, }, } @@ -198,11 +202,11 @@ func TestWrapWithNodeContext_SetsNodesInContext(t *testing.T) { origNodes := GlobalArgs.Nodes defer func() { GlobalArgs.Nodes = origNodes }() - GlobalArgs.Nodes = []string{"10.0.0.1", "10.0.0.2"} + GlobalArgs.Nodes = []string{testNodeAddrA, testNodeAddrB} - var capturedCtx context.Context - inner := func(ctx context.Context, c *client.Client) error { - capturedCtx = ctx + capturedCtxCh := make(chan context.Context, 1) + inner := func(innerCtx context.Context, _ *client.Client) error { + capturedCtxCh <- innerCtx return nil } @@ -212,6 +216,7 @@ func TestWrapWithNodeContext_SetsNodesInContext(t *testing.T) { t.Fatalf("unexpected error: %v", err) } + capturedCtx := <-capturedCtxCh if capturedCtx == nil { t.Fatal("inner function was not called") } @@ -222,7 +227,7 @@ func TestWrapWithNodeContext_SetsNodesInContext(t *testing.T) { t.Fatal("expected outgoing gRPC metadata in context, got none") } gotNodes := md.Get("nodes") - wantNodes := []string{"10.0.0.1", "10.0.0.2"} + wantNodes := []string{testNodeAddrA, testNodeAddrB} if !slices.Equal(gotNodes, wantNodes) { t.Errorf("nodes in context metadata = %v, want %v", gotNodes, wantNodes) } @@ -235,10 +240,10 @@ func TestWrapWithNodeContext_DoesNotMutateGlobalArgs(t *testing.T) { // Use a slice with extra capacity so append inside the closure could // theoretically leak back to GlobalArgs if the copy is shallow nodes := make([]string, 1, 10) - nodes[0] = "10.0.0.1" + nodes[0] = testNodeAddrA GlobalArgs.Nodes = nodes - inner := func(ctx context.Context, c *client.Client) error { + inner := func(_ context.Context, _ *client.Client) error { return nil } @@ -248,17 +253,17 @@ func TestWrapWithNodeContext_DoesNotMutateGlobalArgs(t *testing.T) { } // Verify GlobalArgs.Nodes is unchanged after wrapWithNodeContext call - if !slices.Equal(GlobalArgs.Nodes, []string{"10.0.0.1"}) { + if !slices.Equal(GlobalArgs.Nodes, []string{testNodeAddrA}) { t.Errorf("GlobalArgs.Nodes was mutated to %v, expected [10.0.0.1]", GlobalArgs.Nodes) } // Verify that the defensive copy is independent: mutating GlobalArgs // after wrapper creation doesn't affect a subsequent call - GlobalArgs.Nodes = []string{"10.0.0.2"} + GlobalArgs.Nodes = []string{testNodeAddrB} - var capturedCtx context.Context - inner2 := func(ctx context.Context, c *client.Client) error { - capturedCtx = ctx + capturedCtxCh := make(chan context.Context, 1) + inner2 := func(innerCtx context.Context, _ *client.Client) error { + capturedCtxCh <- innerCtx return nil } wrapped2 := wrapWithNodeContext(inner2) @@ -266,12 +271,13 @@ func TestWrapWithNodeContext_DoesNotMutateGlobalArgs(t *testing.T) { t.Fatalf("unexpected error: %v", err) } + capturedCtx := <-capturedCtxCh md, ok := metadata.FromOutgoingContext(capturedCtx) if !ok { t.Fatal("expected outgoing gRPC metadata in context") } gotNodes := md.Get("nodes") - if !slices.Equal(gotNodes, []string{"10.0.0.2"}) { + if !slices.Equal(gotNodes, []string{testNodeAddrB}) { t.Errorf("nodes in context = %v, want [10.0.0.2]", gotNodes) } } @@ -282,7 +288,7 @@ func TestWrapWithNodeContext_NoNodesNoClient(t *testing.T) { GlobalArgs.Nodes = []string{} - inner := func(ctx context.Context, c *client.Client) error { + inner := func(_ context.Context, _ *client.Client) error { return nil } @@ -309,7 +315,7 @@ func TestWrapWithNodeContext_NoNodesNoClient(t *testing.T) { // callers that use client.WithNode directly. Tests that pin the metadata // contract assert against md.Get directly rather than going through this // helper. -func nodesFromOutgoingCtx(t *testing.T, ctx context.Context) []string { +func nodesFromOutgoingCtx(ctx context.Context, t *testing.T) []string { t.Helper() md, ok := metadata.FromOutgoingContext(ctx) if !ok { @@ -354,11 +360,11 @@ func TestApplyTemplatesPerNode_LoopsOncePerNodeWithSingleNodeContext(t *testing. t.Fatalf("write configFile: %v", err) } - want := []string{"10.0.0.1", "10.0.0.2", "10.0.0.3"} + want := []string{testNodeAddrA, testNodeAddrB, testNodeAddrC} var renderCalls, applyCalls []string render := func(ctx context.Context, _ *client.Client, _ engine.Options) ([]byte, error) { - got := nodesFromOutgoingCtx(t, ctx) + got := nodesFromOutgoingCtx(ctx, t) if len(got) != 1 { t.Errorf("render: expected single-node ctx, got %v", got) } @@ -366,7 +372,7 @@ func TestApplyTemplatesPerNode_LoopsOncePerNodeWithSingleNodeContext(t *testing. return []byte("version: v1alpha1\nmachine:\n type: worker\n"), nil } apply := func(ctx context.Context, _ *client.Client, _ []byte) error { - got := nodesFromOutgoingCtx(t, ctx) + got := nodesFromOutgoingCtx(ctx, t) if len(got) != 1 { t.Errorf("apply: expected single-node ctx, got %v", got) } @@ -398,12 +404,12 @@ func TestApplyTemplatesPerNode_NeverBatchesNodes(t *testing.T) { t.Fatalf("write configFile: %v", err) } - want := []string{"10.0.0.1", "10.0.0.2", "10.0.0.3"} + want := []string{testNodeAddrA, testNodeAddrB, testNodeAddrC} renderCount := 0 applyCount := 0 render := func(ctx context.Context, _ *client.Client, _ engine.Options) ([]byte, error) { - got := nodesFromOutgoingCtx(t, ctx) + got := nodesFromOutgoingCtx(ctx, t) if len(got) > 1 { t.Fatalf("render must NEVER see a multi-node ctx; got %v", got) } @@ -411,7 +417,7 @@ func TestApplyTemplatesPerNode_NeverBatchesNodes(t *testing.T) { return []byte("version: v1alpha1\nmachine:\n type: worker\n"), nil } apply := func(ctx context.Context, _ *client.Client, _ []byte) error { - got := nodesFromOutgoingCtx(t, ctx) + got := nodesFromOutgoingCtx(ctx, t) if len(got) > 1 { t.Fatalf("apply must NEVER see a multi-node ctx; got %v", got) } @@ -457,7 +463,7 @@ machine: } err := applyTemplatesPerNode(engine.Options{}, configFile, - []string{"10.0.0.1", "10.0.0.2"}, + []string{testNodeAddrA, testNodeAddrB}, fakeAuthOpenClient(context.Background()), render, apply) if err == nil { t.Fatal("expected an error for multi-node + non-empty body, got nil") @@ -493,7 +499,7 @@ func TestApplyTemplatesPerNode_MultiNodeEmptyBodyIsAllowed(t *testing.T) { } if err := applyTemplatesPerNode(engine.Options{}, configFile, - []string{"10.0.0.1", "10.0.0.2"}, + []string{testNodeAddrA, testNodeAddrB}, fakeAuthOpenClient(context.Background()), render, apply); err != nil { t.Fatalf("applyTemplatesPerNode: %v", err) } @@ -539,7 +545,7 @@ func TestApplyTemplatesPerNode_NoNodesIsAnError(t *testing.T) { t.Fatalf("expected at least one hint guiding the operator to set nodes, got %v", err) } combined := strings.Join(hints, "\n") - for _, want := range []string{"--nodes", "modeline", "talosconfig"} { + for _, want := range []string{"--nodes", "modeline", testTalosconfigName} { if !strings.Contains(combined, want) { t.Errorf("hint chain %q does not mention %q (operator-actionable resolution path)", combined, want) } @@ -564,7 +570,7 @@ func TestApplyTemplatesPerNode_MaintenanceModeOpensFreshClientPerNode(t *testing t.Fatalf("write configFile: %v", err) } - want := []string{"10.0.0.1", "10.0.0.2"} + want := []string{testNodeAddrA, testNodeAddrB} var clientOpenedFor []string openClient := func(node string, action func(ctx context.Context, c *client.Client) error) error { @@ -604,7 +610,7 @@ func TestOpenClientPerNodeMaintenance_NarrowsAndRestoresGlobalNodes(t *testing.T saved := append([]string(nil), GlobalArgs.Nodes...) defer func() { GlobalArgs.Nodes = saved }() - GlobalArgs.Nodes = []string{"original-A", "original-B"} + GlobalArgs.Nodes = []string{testNodeFixtureA, testNodeFixtureB} type call struct { fingerprints []string @@ -622,25 +628,25 @@ func TestOpenClientPerNodeMaintenance_NarrowsAndRestoresGlobalNodes(t *testing.T return action(context.Background(), nil) } - openClient := openClientPerNodeMaintenance([]string{"fp-1"}, fakeMaintenance) + openClient := openClientPerNodeMaintenance([]string{testNodeFixtureFingerprint}, fakeMaintenance) - for _, node := range []string{"10.0.0.1", "10.0.0.2"} { + for _, node := range []string{testNodeAddrA, testNodeAddrB} { if err := openClient(node, func(_ context.Context, _ *client.Client) error { return nil }); err != nil { t.Fatalf("openClient(%q): %v", node, err) } } - if !slices.Equal(GlobalArgs.Nodes, []string{"original-A", "original-B"}) { + if !slices.Equal(GlobalArgs.Nodes, []string{testNodeFixtureA, testNodeFixtureB}) { t.Errorf("GlobalArgs.Nodes not restored after maintenance loop: got %v", GlobalArgs.Nodes) } if len(calls) != 2 { t.Fatalf("maintenance fake should have been called twice, got %d times", len(calls)) } - for i, want := range []string{"10.0.0.1", "10.0.0.2"} { + for i, want := range []string{testNodeAddrA, testNodeAddrB} { if !slices.Equal(calls[i].nodesAtCall, []string{want}) { t.Errorf("call %d: GlobalArgs.Nodes at WithClientMaintenance time = %v, want [%q]", i, calls[i].nodesAtCall, want) } - if !slices.Equal(calls[i].fingerprints, []string{"fp-1"}) { + if !slices.Equal(calls[i].fingerprints, []string{testNodeFixtureFingerprint}) { t.Errorf("call %d: fingerprints passed through = %v, want [\"fp-1\"]", i, calls[i].fingerprints) } } @@ -658,20 +664,20 @@ func TestOpenClientPerNodeMaintenance_RestoresGlobalNodesOnError(t *testing.T) { saved := append([]string(nil), GlobalArgs.Nodes...) defer func() { GlobalArgs.Nodes = saved }() - GlobalArgs.Nodes = []string{"original-A", "original-B"} + GlobalArgs.Nodes = []string{testNodeFixtureA, testNodeFixtureB} failingMaintenance := func(_ []string, action func(ctx context.Context, c *client.Client) error) error { return action(context.Background(), nil) } openClient := openClientPerNodeMaintenance(nil, failingMaintenance) - err := openClient("10.0.0.1", func(_ context.Context, _ *client.Client) error { - return fmt.Errorf("simulated apply failure") + err := openClient(testNodeAddrA, func(_ context.Context, _ *client.Client) error { + return errSimulatedApplyFailure }) if err == nil { t.Fatal("expected error from failing action, got nil") } - if !slices.Equal(GlobalArgs.Nodes, []string{"original-A", "original-B"}) { + if !slices.Equal(GlobalArgs.Nodes, []string{testNodeFixtureA, testNodeFixtureB}) { t.Errorf("GlobalArgs.Nodes not restored after error: got %v, want [original-A original-B]", GlobalArgs.Nodes) } } @@ -694,7 +700,7 @@ func TestApplyTemplatesPerNode_AuthModeUsesPluralNodesMetadataKey(t *testing.T) t.Fatalf("write configFile: %v", err) } - const node = "10.0.0.1" + const node = testNodeAddrA render := func(ctx context.Context, _ *client.Client, _ engine.Options) ([]byte, error) { md, ok := metadata.FromOutgoingContext(ctx) if !ok { @@ -728,7 +734,7 @@ func TestApplyTemplatesPerNode_AuthModeUsesPluralNodesMetadataKey(t *testing.T) // swallows errors and returns ok=false on rejection, so the user // never sees the mismatch warning the preflight exists to surface. func TestCosiPreflightContext_StripsPluralAndAttachesSingular(t *testing.T) { - const node = "10.0.0.1" + const node = testNodeAddrA in := client.WithNodes(context.Background(), node) out, err := cosiPreflightContext(in) @@ -837,6 +843,88 @@ machine: // regression that would wire MergeFileAsPatch into generateOutput. } +// TestResetGlobalArgsBetweenFiles_ClearsModelineSuppliedEndpoints pins +// the post-iteration reset semantics for the multi-file apply/template +// loop. After a file's modeline supplies endpoints, the next iteration +// must start with an EMPTY GlobalArgs.Endpoints slice — not a re-seeded +// `[defaultLocalEndpoint]` placeholder. The empty slice triggers the +// talos client's intended fallback to talosconfig context endpoints +// (cmd/talosctl/pkg/talos/global/client.go: client.WithConfig is +// registered first, then client.WithEndpoints only when len > 0). +// Re-seeding loopback would silently override the talosconfig context +// for any subsequent file whose modeline omits endpoints. The +// matching nodes branch zeroes out Nodes for the same reason. +func TestResetGlobalArgsBetweenFiles_ClearsModelineSuppliedEndpoints(t *testing.T) { + savedNodes := append([]string(nil), GlobalArgs.Nodes...) + savedEndpoints := append([]string(nil), GlobalArgs.Endpoints...) + defer func() { + GlobalArgs.Nodes = savedNodes + GlobalArgs.Endpoints = savedEndpoints + }() + + GlobalArgs.Nodes = []string{testNodeAddrA, testNodeAddrB} + GlobalArgs.Endpoints = []string{testNodeAddrA} + + resetGlobalArgsBetweenFiles(false, false) + + if len(GlobalArgs.Nodes) != 0 { + t.Errorf("GlobalArgs.Nodes = %v, want empty (modeline-supplied nodes must not leak across files)", GlobalArgs.Nodes) + } + if len(GlobalArgs.Endpoints) != 0 { + t.Errorf("GlobalArgs.Endpoints = %v, want empty (re-seeding defaultLocalEndpoint here would override talosconfig context fallback for the next file)", GlobalArgs.Endpoints) + } +} + +// TestResetGlobalArgsBetweenFiles_PreservesCLIFlagState pins the +// other half of the contract: when the operator supplied --nodes or +// --endpoints on the CLI (nodesFromArgs / endpointsFromArgs == true), +// the reset must NOT clobber those values. Otherwise the second and +// later files would silently lose the operator's explicit targeting. +func TestResetGlobalArgsBetweenFiles_PreservesCLIFlagState(t *testing.T) { + savedNodes := append([]string(nil), GlobalArgs.Nodes...) + savedEndpoints := append([]string(nil), GlobalArgs.Endpoints...) + defer func() { + GlobalArgs.Nodes = savedNodes + GlobalArgs.Endpoints = savedEndpoints + }() + + GlobalArgs.Nodes = []string{testNodeAddrA, testNodeAddrB} + GlobalArgs.Endpoints = []string{testNodeAddrC} + + resetGlobalArgsBetweenFiles(true, true) + + if !slices.Equal(GlobalArgs.Nodes, []string{testNodeAddrA, testNodeAddrB}) { + t.Errorf("GlobalArgs.Nodes = %v, want [a,b] (CLI --nodes must not be reset between files)", GlobalArgs.Nodes) + } + if !slices.Equal(GlobalArgs.Endpoints, []string{testNodeAddrC}) { + t.Errorf("GlobalArgs.Endpoints = %v, want [c] (CLI --endpoints must not be reset between files)", GlobalArgs.Endpoints) + } +} + +// TestResetGlobalArgsBetweenFiles_DoesNotReseedLoopback pins the +// regression that prompted the contract above. A previous iteration +// of the strict-lint PR re-seeded `[defaultLocalEndpoint]` in this +// reset, hoping to "preserve the PreRunE fallback" across files. That +// broke the talosconfig fallback path: `client.WithEndpoints` is +// applied unconditionally when `len(c.Endpoints) > 0`, so the +// re-seeded loopback overrode the talosconfig context's real +// endpoints for every file past the first. Pin the empty-slice reset +// explicitly so a future "fix" doesn't reintroduce the override. +func TestResetGlobalArgsBetweenFiles_DoesNotReseedLoopback(t *testing.T) { + savedEndpoints := append([]string(nil), GlobalArgs.Endpoints...) + defer func() { GlobalArgs.Endpoints = savedEndpoints }() + + GlobalArgs.Endpoints = []string{"10.0.0.1"} + + resetGlobalArgsBetweenFiles(false, false) + + for _, got := range GlobalArgs.Endpoints { + if got == defaultLocalEndpoint { + t.Errorf("GlobalArgs.Endpoints contains defaultLocalEndpoint (%q) after reset; this would override talosconfig context fallback on the next iteration", got) + } + } +} + // TestIsOutsideRoot pins the contract that distinguishes a path that // truly escapes the project root (".." or a first element of "..") // from a path whose first element merely *starts* with ".." but is @@ -850,14 +938,14 @@ func TestIsOutsideRoot(t *testing.T) { want bool }{ {"..", true}, - {".." + string(filepath.Separator) + "foo", true}, - {".." + string(filepath.Separator) + "foo" + string(filepath.Separator) + "bar", true}, + {".." + string(filepath.Separator) + testFooLiteral, true}, + {".." + string(filepath.Separator) + testFooLiteral + string(filepath.Separator) + "bar", true}, {"..foo", false}, {"..foo" + string(filepath.Separator) + "bar", false}, {"..templates" + string(filepath.Separator) + "controlplane.yaml", false}, {"..mykube", false}, - {"foo", false}, - {"foo" + string(filepath.Separator) + "..bar", false}, + {testFooLiteral, false}, + {testFooLiteral + string(filepath.Separator) + "..bar", false}, {".", false}, } for _, c := range cases { diff --git a/pkg/commands/consts.go b/pkg/commands/consts.go new file mode 100644 index 00000000..7dcfef02 --- /dev/null +++ b/pkg/commands/consts.go @@ -0,0 +1,39 @@ +// Copyright Cozystack Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package commands + +// Shared string constants used across the commands package. Centralized +// here so the goconst linter has a single canonical reference and so a +// rename touches one location. +const ( + // defaultKubeconfigName is the default basename used when + // Config.GlobalOptions.Kubeconfig is unset; it doubles as the + // .gitignore entry and the value compared against + // filepath.Base(kubeconfigPath) to gate kubeconfig-specific + // post-processing. + defaultKubeconfigName = "kubeconfig" + + // chartYamlName is the on-disk name of the Helm chart manifest at + // the project root. + chartYamlName = "Chart.yaml" + + // defaultLocalEndpoint is the loopback endpoint baked into freshly + // generated talosconfig contexts when no real endpoint is known yet. + defaultLocalEndpoint = "127.0.0.1" + + // initSubcommand is the canonical name of the init subcommand, + // used when the dispatcher needs to special-case it. + initSubcommand = "init" +) diff --git a/pkg/commands/consts_test.go b/pkg/commands/consts_test.go new file mode 100644 index 00000000..ca52516b --- /dev/null +++ b/pkg/commands/consts_test.go @@ -0,0 +1,113 @@ +// Copyright Cozystack Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package commands + +// Shared string constants used across test files. Hoisted out of the +// individual _test.go files so goconst is satisfied with one source of +// truth — and so a future preset rename or override-image bump is one +// edit instead of a sweep across every contract test. +const ( + // Preset names from the embedded chart bundle. Tests use them as + // flag values and as expected results from preset-detection + // helpers. Test-only fixtures (this file is _test.go, so they + // are not visible to non-test code). + presetCozystack = "cozystack" + presetGeneric = "generic" + + // myClusterName is the canonical "valid DNS-1123 subdomain" + // fixture; every contract test that needs a non-trivial cluster + // name uses it so a future rename is one edit. + myClusterName = "my-cluster" + + // encryptFlag / decryptFlag are the literal sub-test names AND + // the flag identifiers passed to the validator under test; + // keeping them constants lets a single rename ripple through the + // table-driven cases without drift. + encryptFlag = "encrypt" + decryptFlag = "decrypt" + + // alphanumericLabel is a substring of the upstream + // k8s.io/apimachinery validator's error message. Every + // table-driven case that asserts the validator surfaced its + // message uses this string so a kubelet-bump that touches the + // upstream wording fails in one place. + alphanumericLabel = "alphanumeric" + + // testNodeAddrA / testNodeAddrB / testNodeAddrC are the three + // canonical reserved-range IPs the apply / template suite uses + // to address fake Talos nodes. Documentation-range RFC 5737 + // 192.0.2.0/24 would be even more strictly correct, but these + // values are how every existing test fixture spells "node". + testNodeAddrA = "10.0.0.1" + testNodeAddrC = "10.0.0.3" + + // testTalosVersion / testKubernetesVersion are the version + // pair the apply-options builders are tested against; pinning + // them here keeps fixture and assertion in sync after a future + // version bump. + testTalosVersion = "v1.12" + testKubernetesVersion = "1.31.0" + + // testProjectRoot is the synthetic absolute Config.RootDir the + // apply-options builder tests use to verify path resolution + // without touching the real filesystem layout. + testProjectRoot = "/project" + + // testTalmApply / testTalosconfigName are user-facing literals + // the apply-options assertions reference verbatim — the engine + // reads CommandName for FailIfMultiNodes wording, and + // "talosconfig" is the talosconfig basename pinned by every + // node-resolution test. + testTalmApply = "talm apply" + testTalosconfigName = "talosconfig" + + // testTemplateControlplane / testTemplateOutsideRoot are the + // canonical template paths exercised by both + // resolveTemplatePaths and resolveEngineTemplatePaths suites. + // The "..templates/" prefix is the historical regression seed + // that taught isOutsideRoot the difference between ".." + // (parent) and "..templates" (sibling). + testTemplateControlplane = "..templates/controlplane.yaml" + + // testNodeFixtureA / testNodeFixtureFingerprint are + // fixture-only strings that appear in restore-on-error / fake + // maintenance call paths. + testNodeFixtureA = "original-A" + testNodeFixtureFingerprint = "fp-1" + + // testFooLiteral is the placeholder relPath token used by + // isOutsideRoot's table-driven cases; six occurrences exceed + // goconst's threshold so a single hoisted const documents the + // intent. + testFooLiteral = "foo" + + // testNodeAddrB is the second canonical reserved-range IP used + // across multi-node fixtures; pairs with testNodeAddrA / testNodeAddrC. + testNodeAddrB = "10.0.0.2" + + // testNodeFixtureB is the second restore-on-error fixture node; + // pairs with testNodeFixtureA. + testNodeFixtureB = "original-B" + + // testTemplateControlplaneRel / testTemplateWorker / + // testTemplateMissing / testTemplateConfig are the canonical + // inside-root template paths used by the apply / template / + // contract suites. Hoisted to a single const so goconst sees one + // reference per literal. + testTemplateControlplaneRel = "templates/controlplane.yaml" + testTemplateWorker = "templates/worker.yaml" + testTemplateMissing = "templates/missing.yaml" + testTemplateConfig = "templates/config.yaml" +) diff --git a/pkg/commands/contract_chart_gitignore_test.go b/pkg/commands/contract_chart_gitignore_test.go index 54ed8e96..901cb19e 100644 --- a/pkg/commands/contract_chart_gitignore_test.go +++ b/pkg/commands/contract_chart_gitignore_test.go @@ -23,6 +23,8 @@ package commands import ( + "bytes" + "io" "os" "path/filepath" "strings" @@ -64,7 +66,7 @@ dependencies: if err != nil { t.Fatalf("unexpected error: %v", err) } - if got != "cozystack" { + if got != presetCozystack { t.Errorf("expected preset 'cozystack', got %q", got) } } @@ -306,3 +308,167 @@ kubeconfig t.Errorf("annotated 'talosconfig' duplicated:\n%s", data) } } + +// Contract: writeGitignoreFile prints "Created " the first +// time it actually writes the file and "Updated " on every +// later write. Pinning this guards against a regression where the +// existence-check happened AFTER WriteFile (when the file always +// exists), so a fresh init reported "Updated" for a file it just +// created. The second pass forces a write by changing the required +// kubeconfig basename — writeGitignoreFile early-returns when no +// new entries are needed. +// captureStderr is a self-contained test helper: redirect os.Stderr +// to a pipe for the duration of fn, then restore os.Stderr and +// return whatever fn wrote. The "self-contained" part is +// load-bearing — restoration happens via a per-call defer rather +// than t.Cleanup, so back-to-back invocations from the same test do +// not leak a closed writer into the next call's origStderr capture. +// See TestCaptureStderr_RestoresOsStderrPerCall for the regression +// guard. +func captureStderr(t *testing.T, fn func()) string { + t.Helper() + + origStderr := os.Stderr + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stderr = w + + // Close both pipe ends in the defer so a panic or t.Fatal + // inside fn() does not leak the writer fd. Closing w twice is + // safe — os.File.Close on an already-closed file returns an + // error which we discard explicitly. + defer func() { + os.Stderr = origStderr + _ = w.Close() + _ = r.Close() + }() + + fn() + _ = w.Close() + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + + return buf.String() +} + +func TestContract_WriteGitignoreFile_CreatedVsUpdatedReporting(t *testing.T) { + dir := t.TempDir() + setRoot(t, dir) + originalKube := Config.GlobalOptions.Kubeconfig + t.Cleanup(func() { Config.GlobalOptions.Kubeconfig = originalKube }) + + // First call: fresh tempdir, no .gitignore exists. + Config.GlobalOptions.Kubeconfig = "" + first := captureStderr(t, func() { + if err := writeGitignoreFile(); err != nil { + t.Fatalf("first writeGitignoreFile: %v", err) + } + }) + if !strings.Contains(first, "Created ") { + t.Errorf("first invocation must print 'Created ...', got:\n%s", first) + } + if strings.Contains(first, "Updated ") { + t.Errorf("first invocation must NOT print 'Updated ...', got:\n%s", first) + } + + // Second call: change kubeconfig basename so a NEW required entry + // is added (otherwise writeGitignoreFile returns early without + // printing anything). + Config.GlobalOptions.Kubeconfig = "/etc/kubernetes/admin.kubeconfig" + second := captureStderr(t, func() { + if err := writeGitignoreFile(); err != nil { + t.Fatalf("second writeGitignoreFile: %v", err) + } + }) + if !strings.Contains(second, "Updated ") { + t.Errorf("second invocation must print 'Updated ...', got:\n%s", second) + } + if strings.Contains(second, "Created ") { + t.Errorf("second invocation must NOT print 'Created ...', got:\n%s", second) + } +} + +// TestGitignoreReportVerb_BranchesOnIsNotExist pins the verb-pick +// contract: only ENOENT (the file truly did not exist before +// WriteFile) yields "Created"; any other stat error — EACCES on a +// parent directory, ENOTDIR mid-path, EIO on a flaky disk, or even +// a successful stat (nil error) — yields "Updated". The "Updated" +// wording in the ambiguous-error branch is deliberate: it does not +// falsely promise the absence we never confirmed. +// +// The previous bare `statErrBefore == nil` test would have wrongly +// reported "Created" for any non-IsNotExist stat error — wrong if +// the file already existed but a permission glitch hid it from us. +func TestGitignoreReportVerb_BranchesOnIsNotExist(t *testing.T) { + cases := []struct { + name string + in error + want string + }{ + {"file_existed_before_write", nil, "Updated"}, + {"file_did_not_exist_before_write", os.ErrNotExist, "Created"}, + {"permission_denied_on_parent", os.ErrPermission, "Updated"}, + {"unwrapped_pathError_notexist", &os.PathError{Op: "stat", Path: "x", Err: os.ErrNotExist}, "Created"}, + {"unwrapped_pathError_permission", &os.PathError{Op: "stat", Path: "x", Err: os.ErrPermission}, "Updated"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := gitignoreReportVerb(tc.in) + if got != tc.want { + t.Errorf("gitignoreReportVerb(%v) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +// TestCaptureStderr_RestoresOsStderrPerCall pins the helper's +// per-call restore semantics. The previous t.Cleanup-based version +// passed test assertions only because os.Pipe's buffer absorbed the +// writes — but between sequential captureStderr calls os.Stderr +// pointed at the first call's already-closed writer, so the second +// call's origStderr capture preserved a closed fd, and the surrounding +// test's t.Cleanup eventually restored that closed fd as the +// "original" os.Stderr. A test that wrote to os.Stderr after +// captureStderr returned would have crashed if the buffer had +// overflowed. +// +// The fix replaced t.Cleanup with a per-call defer. This test pins +// that contract directly: after each captureStderr call, os.Stderr +// must point at exactly the file it pointed to before the call, +// not at any pipe writer. +func TestCaptureStderr_RestoresOsStderrPerCall(t *testing.T) { + original := os.Stderr + + first := captureStderr(t, func() { + _, _ = os.Stderr.WriteString("first\n") + }) + + if os.Stderr != original { + t.Errorf("after first captureStderr call, os.Stderr is %p, want original %p", os.Stderr, original) + } + if !strings.Contains(first, "first") { + t.Errorf("first capture missing payload; got %q", first) + } + + // The critical case: a second call. The buggy t.Cleanup-based + // version captured a closed pipe writer here as origStderr. + second := captureStderr(t, func() { + _, _ = os.Stderr.WriteString("second\n") + }) + + if os.Stderr != original { + t.Errorf("after second captureStderr call, os.Stderr is %p, want original %p", os.Stderr, original) + } + if !strings.Contains(second, "second") { + t.Errorf("second capture missing payload; got %q", second) + } + + // Last guard: writing to os.Stderr after the helper returns + // must not blow up — proves the restore landed on the real + // stderr, not a closed pipe writer. + if _, err := os.Stderr.WriteString(""); err != nil { + t.Errorf("os.Stderr is no longer usable after captureStderr returned: %v", err) + } +} diff --git a/pkg/commands/contract_helpers_test.go b/pkg/commands/contract_helpers_test.go index ff811da0..98676040 100644 --- a/pkg/commands/contract_helpers_test.go +++ b/pkg/commands/contract_helpers_test.go @@ -30,6 +30,18 @@ import ( clientcmdapi "k8s.io/client-go/tools/clientcmd/api" ) +// File-local fixtures for the helpers contract tests. The literals +// these constants stand in for show up across many table-driven and +// scenario tests; centralising them lets goconst stop firing. +const ( + fixtureEndpointIPv4Canonical = "https://1.2.3.4:6443" + fixtureEndpointHostnameCanonical = "https://node.example.com:6443" + fixtureEndpointIPv6Canonical = "https://[2001:db8::1]:6443" + fixtureEndpointRotatedCanonical = "https://10.0.0.1:6443" + fixtureClusterNameMy = "my-cluster" + fixtureClusterNameFallback = "chart-fallback" +) + // === normalizeEndpoint === // Contract: every endpoint variant collapses to a canonical @@ -41,21 +53,21 @@ func TestContract_NormalizeEndpoint(t *testing.T) { cases := []struct { name, in, want string }{ - {"ipv4_no_port", "1.2.3.4", "https://1.2.3.4:6443"}, - {"ipv4_with_port", "1.2.3.4:50000", "https://1.2.3.4:6443"}, - {"ipv4_https_with_port", "https://1.2.3.4:50000", "https://1.2.3.4:6443"}, - {"ipv4_http", "http://1.2.3.4", "https://1.2.3.4:6443"}, - {"hostname_https_canonical", "https://node.example.com:6443", "https://node.example.com:6443"}, - {"hostname_no_port", "node.example.com", "https://node.example.com:6443"}, + {"ipv4_no_port", "1.2.3.4", fixtureEndpointIPv4Canonical}, + {"ipv4_with_port", "1.2.3.4:50000", fixtureEndpointIPv4Canonical}, + {"ipv4_https_with_port", "https://1.2.3.4:50000", fixtureEndpointIPv4Canonical}, + {"ipv4_http", "http://1.2.3.4", fixtureEndpointIPv4Canonical}, + {"hostname_https_canonical", fixtureEndpointHostnameCanonical, fixtureEndpointHostnameCanonical}, + {"hostname_no_port", "node.example.com", fixtureEndpointHostnameCanonical}, // IPv6 with brackets — net.JoinHostPort re-adds them for any // host containing a colon, so the canonical output is // "https://[2001:db8::1]:6443". URI-bracketed IPv6 literals // per RFC 3986 §3.2.2. - {"ipv6_bracketed_with_port", "[2001:db8::1]:6443", "https://[2001:db8::1]:6443"}, + {"ipv6_bracketed_with_port", "[2001:db8::1]:6443", fixtureEndpointIPv6Canonical}, // IPv6 without explicit port — bare bracketed literal. The // no-port branch strips the outer brackets so JoinHostPort // can add exactly one pair back. - {"ipv6_bracketed_no_port", "[2001:db8::1]", "https://[2001:db8::1]:6443"}, + {"ipv6_bracketed_no_port", "[2001:db8::1]", fixtureEndpointIPv6Canonical}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -80,7 +92,7 @@ func TestContract_UpdateKubeconfigServer_RewritesAllClusters(t *testing.T) { kcPath := filepath.Join(dir, "kubeconfig") cfg := clientcmdapi.NewConfig() - cfg.Clusters["one"] = &clientcmdapi.Cluster{Server: "https://1.2.3.4:6443"} + cfg.Clusters["one"] = &clientcmdapi.Cluster{Server: fixtureEndpointIPv4Canonical} cfg.Clusters["two"] = &clientcmdapi.Cluster{Server: "https://5.6.7.8:6443"} if err := clientcmd.WriteToFile(*cfg, kcPath); err != nil { t.Fatal(err) @@ -95,8 +107,8 @@ func TestContract_UpdateKubeconfigServer_RewritesAllClusters(t *testing.T) { t.Fatal(err) } for name, c := range got.Clusters { - if c.Server != "https://10.0.0.1:6443" { - t.Errorf("cluster %q server = %q, want https://10.0.0.1:6443", name, c.Server) + if c.Server != fixtureEndpointRotatedCanonical { + t.Errorf("cluster %q server = %q, want %s", name, c.Server, fixtureEndpointRotatedCanonical) } } } @@ -110,7 +122,7 @@ func TestContract_UpdateKubeconfigServer_NoChangeWhenAlreadyNormalised(t *testin kcPath := filepath.Join(dir, "kubeconfig") cfg := clientcmdapi.NewConfig() - cfg.Clusters["one"] = &clientcmdapi.Cluster{Server: "https://10.0.0.1:6443"} + cfg.Clusters["one"] = &clientcmdapi.Cluster{Server: fixtureEndpointRotatedCanonical} if err := clientcmd.WriteToFile(*cfg, kcPath); err != nil { t.Fatal(err) } @@ -173,14 +185,14 @@ func TestContract_AddToGitignore_PreservesExisting(t *testing.T) { setRoot(t, dir) gitignore := filepath.Join(dir, ".gitignore") - if err := os.WriteFile(gitignore, []byte("# Sensitive\nsecrets.yaml\n"), 0o644); err != nil { + if err := os.WriteFile(gitignore, []byte("# Sensitive\n"+localSecretsYamlName+"\n"), 0o644); err != nil { t.Fatal(err) } if err := addToGitignore("artifacts/"); err != nil { t.Fatal(err) } got, _ := os.ReadFile(gitignore) - for _, want := range []string{"# Sensitive", "secrets.yaml", "artifacts/"} { + for _, want := range []string{"# Sensitive", localSecretsYamlName, "artifacts/"} { if !strings.Contains(string(got), want) { t.Errorf("expected %q in:\n%s", want, got) } @@ -229,13 +241,13 @@ func TestContract_AddToGitignore_PathPrefixMatch(t *testing.T) { func TestContract_GetClusterNameFromChart_ReadsTopLevelName(t *testing.T) { dir := t.TempDir() setRoot(t, dir) - yaml := "apiVersion: v2\nname: my-cluster\nversion: 0.1.0\n" + yaml := "apiVersion: v2\nname: " + fixtureClusterNameMy + "\nversion: 0.1.0\n" if err := os.WriteFile(filepath.Join(dir, "Chart.yaml"), []byte(yaml), 0o644); err != nil { t.Fatal(err) } got := getClusterNameFromChart() - if got != "my-cluster" { - t.Errorf("expected 'my-cluster', got %q", got) + if got != fixtureClusterNameMy { + t.Errorf("expected %q, got %q", fixtureClusterNameMy, got) } } @@ -266,14 +278,14 @@ func TestContract_GetClusterNameFromChart_ValuesYamlOverridesChartYaml(t *testin func TestContract_GetClusterNameFromChart_EmptyValuesClusterNameFallsBack(t *testing.T) { dir := t.TempDir() setRoot(t, dir) - if err := os.WriteFile(filepath.Join(dir, "Chart.yaml"), []byte("apiVersion: v2\nname: chart-fallback\nversion: 0.1.0\n"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(dir, "Chart.yaml"), []byte("apiVersion: v2\nname: "+fixtureClusterNameFallback+"\nversion: 0.1.0\n"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "values.yaml"), []byte("clusterName: \"\"\nendpoint: \"\"\n"), 0o644); err != nil { t.Fatal(err) } got := getClusterNameFromChart() - if got != "chart-fallback" { + if got != fixtureClusterNameFallback { t.Errorf("expected fallback to Chart.yaml name, got %q", got) } } @@ -286,14 +298,14 @@ func TestContract_GetClusterNameFromChart_EmptyValuesClusterNameFallsBack(t *tes func TestContract_GetClusterNameFromChart_AbsentValuesKeyFallsBack(t *testing.T) { dir := t.TempDir() setRoot(t, dir) - if err := os.WriteFile(filepath.Join(dir, "Chart.yaml"), []byte("apiVersion: v2\nname: chart-fallback\nversion: 0.1.0\n"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(dir, "Chart.yaml"), []byte("apiVersion: v2\nname: "+fixtureClusterNameFallback+"\nversion: 0.1.0\n"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "values.yaml"), []byte("endpoint: \"https://example.com:6443\"\n"), 0o644); err != nil { t.Fatal(err) } got := getClusterNameFromChart() - if got != "chart-fallback" { + if got != fixtureClusterNameFallback { t.Errorf("expected fallback to Chart.yaml name, got %q", got) } } @@ -306,14 +318,14 @@ func TestContract_GetClusterNameFromChart_AbsentValuesKeyFallsBack(t *testing.T) func TestContract_GetClusterNameFromChart_MalformedValuesFallsBack(t *testing.T) { dir := t.TempDir() setRoot(t, dir) - if err := os.WriteFile(filepath.Join(dir, "Chart.yaml"), []byte("apiVersion: v2\nname: chart-fallback\nversion: 0.1.0\n"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(dir, "Chart.yaml"), []byte("apiVersion: v2\nname: "+fixtureClusterNameFallback+"\nversion: 0.1.0\n"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "values.yaml"), []byte(":bad: yaml :"), 0o644); err != nil { t.Fatal(err) } got := getClusterNameFromChart() - if got != "chart-fallback" { + if got != fixtureClusterNameFallback { t.Errorf("expected fallback on malformed values.yaml, got %q", got) } } @@ -385,3 +397,57 @@ func TestContract_UpdateFileWithConfirmation_SameContentSkips(t *testing.T) { t.Errorf("identical content should not touch mtime") } } + +// === updateKubeconfigEndpoint === + +// Contract: updateKubeconfigEndpoint normalises the supplied endpoint +// through the same canonical form as normalizeEndpoint. The IPv6 +// `[host]` no-port input must round-trip to `https://[host]:6443` — +// not `https://[[host]]:6443` (the historic bug from re-implementing +// the trim-and-rejoin logic without the bracket-stripping branch). +// Multiple clusters in the kubeconfig all receive the same rewrite. +func TestContract_UpdateKubeconfigEndpoint_NormalisesAllClusters(t *testing.T) { + cases := []struct { + name, in, want string + }{ + {"ipv4_no_port", "1.2.3.4", fixtureEndpointIPv4Canonical}, + {"ipv4_with_port", "1.2.3.4:50000", fixtureEndpointIPv4Canonical}, + {"hostname_no_port", "node.example.com", fixtureEndpointHostnameCanonical}, + // The regression case: bracketed IPv6 literal with no port. + // A naive trim+rejoin (re-implemented without the bracket- + // stripping branch from normalizeEndpoint) would emit + // "https://[[2001:db8::1]]:6443" because net.SplitHostPort + // fails on the bare bracketed form and net.JoinHostPort then + // re-brackets the already-bracketed string. Pinning the + // canonical IPv6 output guards against any future inline + // re-implementation. + {"ipv6_bracketed_no_port", "[2001:db8::1]", fixtureEndpointIPv6Canonical}, + {"ipv6_bracketed_with_port", "[2001:db8::1]:6443", fixtureEndpointIPv6Canonical}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := clientcmdapi.NewConfig() + cfg.Clusters["one"] = &clientcmdapi.Cluster{Server: fixtureEndpointIPv4Canonical} + cfg.Clusters["two"] = &clientcmdapi.Cluster{Server: "https://5.6.7.8:6443"} + data, err := clientcmd.Write(*cfg) + if err != nil { + t.Fatalf("clientcmd.Write: %v", err) + } + + got, err := updateKubeconfigEndpoint(data, tc.in) + if err != nil { + t.Fatalf("updateKubeconfigEndpoint(%q): %v", tc.in, err) + } + + parsed, err := clientcmd.Load(got) + if err != nil { + t.Fatalf("clientcmd.Load round-trip: %v", err) + } + for name, c := range parsed.Clusters { + if c.Server != tc.want { + t.Errorf("cluster %q server = %q, want %q (input %q)", name, c.Server, tc.want, tc.in) + } + } + }) + } +} diff --git a/pkg/commands/contract_init_ux_test.go b/pkg/commands/contract_init_ux_test.go index af4ed3eb..2e60cb30 100644 --- a/pkg/commands/contract_init_ux_test.go +++ b/pkg/commands/contract_init_ux_test.go @@ -58,7 +58,7 @@ func TestContract_InitPreRun_RefusesWhenInsideAncestorProject(t *testing.T) { Config.RootDir = rootAbs Config.RootDirExplicit = false - initCmdFlags.preset = "cozystack" + initCmdFlags.preset = presetCozystack initCmdFlags.name = "test-cluster" initCmdFlags.encrypt = false initCmdFlags.decrypt = false @@ -106,7 +106,7 @@ func TestContract_InitPreRun_RootExplicitSkipsAncestorCheck(t *testing.T) { Config.RootDir = subdir Config.RootDirExplicit = true - initCmdFlags.preset = "cozystack" + initCmdFlags.preset = presetCozystack initCmdFlags.name = "test-cluster" initCmdFlags.encrypt = false initCmdFlags.decrypt = false @@ -132,7 +132,7 @@ func TestContract_InitPreRun_AcceptsWhenCWDIsRoot(t *testing.T) { Config.RootDir = dirAbs Config.RootDirExplicit = false - initCmdFlags.preset = "cozystack" + initCmdFlags.preset = presetCozystack initCmdFlags.name = "test-cluster" initCmdFlags.encrypt = false initCmdFlags.decrypt = false @@ -173,7 +173,7 @@ func TestContract_InitRun_PreCheckRejectsBeforeAnyWrite(t *testing.T) { t.Fatal(err) } - initCmdFlags.preset = "cozystack" + initCmdFlags.preset = presetCozystack initCmdFlags.name = "test-cluster" initCmdFlags.force = false initCmdFlags.encrypt = false @@ -238,7 +238,7 @@ func TestContract_InitRun_PreCheckListsAllConflicts(t *testing.T) { } } - initCmdFlags.preset = "cozystack" + initCmdFlags.preset = presetCozystack initCmdFlags.name = "test-cluster" initCmdFlags.force = false initCmdFlags.encrypt = false @@ -319,7 +319,7 @@ func TestContract_InitPreRun_FailsClosedOnGetwd(t *testing.T) { t.Fatal(err) } - initCmdFlags.preset = "cozystack" + initCmdFlags.preset = presetCozystack initCmdFlags.name = "test-cluster" err := initCmd.PreRunE(initCmd, nil) @@ -369,7 +369,7 @@ func TestContract_InitPreRun_FailsClosedOnAbsRootDir(t *testing.T) { Config.RootDir = "." Config.RootDirExplicit = false - initCmdFlags.preset = "cozystack" + initCmdFlags.preset = presetCozystack initCmdFlags.name = "test-cluster" err := initCmd.PreRunE(initCmd, nil) @@ -498,7 +498,7 @@ func TestContract_InitRun_ForceBypassesPreCheck(t *testing.T) { t.Fatal(err) } - initCmdFlags.preset = "cozystack" + initCmdFlags.preset = presetCozystack initCmdFlags.name = "test-cluster" initCmdFlags.force = true // <- bypass initCmdFlags.encrypt = false diff --git a/pkg/commands/contract_init_validation_test.go b/pkg/commands/contract_init_validation_test.go index af92d68e..8add1cfa 100644 --- a/pkg/commands/contract_init_validation_test.go +++ b/pkg/commands/contract_init_validation_test.go @@ -44,10 +44,10 @@ func TestContract_InitPreRun_AcceptsValidDNS1123Subdomain(t *testing.T) { withInitFlagsSnapshot(t) cases := []string{ - "cozystack", - "generic", + presetCozystack, + presetGeneric, "talm", - "my-cluster", + myClusterName, "my.cluster.example", "1leading-digit", "a", // single character @@ -55,7 +55,7 @@ func TestContract_InitPreRun_AcceptsValidDNS1123Subdomain(t *testing.T) { } for _, name := range cases { t.Run(name, func(t *testing.T) { - initCmdFlags.preset = "cozystack" + initCmdFlags.preset = presetCozystack initCmdFlags.name = name initCmdFlags.encrypt = false initCmdFlags.decrypt = false @@ -83,16 +83,16 @@ func TestContract_InitPreRun_RejectsInvalidDNS1123Subdomain(t *testing.T) { expectInMsg string // substring of the k8s validator message }{ {"uppercase", "MyCluster", "lower case"}, - {"underscore", "my_cluster", "alphanumeric"}, - {"leading dash", "-bad", "alphanumeric"}, - {"trailing dash", "bad-", "alphanumeric"}, - {"space", "my cluster", "alphanumeric"}, - {"empty label between dots", "foo..bar", "alphanumeric"}, + {"underscore", "my_cluster", alphanumericLabel}, + {"leading dash", "-bad", alphanumericLabel}, + {"trailing dash", "bad-", alphanumericLabel}, + {"space", "my cluster", alphanumericLabel}, + {"empty label between dots", "foo..bar", alphanumericLabel}, {"subdomain too long", strings.Repeat("a", 254), "253"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - initCmdFlags.preset = "cozystack" + initCmdFlags.preset = presetCozystack initCmdFlags.name = tc.clusterName initCmdFlags.encrypt = false initCmdFlags.decrypt = false @@ -134,8 +134,8 @@ func TestContract_InitPreRun_SkipsValidationOnExclusiveModes(t *testing.T) { name string set func() }{ - {"encrypt", func() { initCmdFlags.encrypt = true }}, - {"decrypt", func() { initCmdFlags.decrypt = true }}, + {encryptFlag, func() { initCmdFlags.encrypt = true }}, + {decryptFlag, func() { initCmdFlags.decrypt = true }}, {"update", func() { initCmdFlags.update = true }}, } for _, tc := range cases { diff --git a/pkg/commands/contract_root_dispatch_test.go b/pkg/commands/contract_root_dispatch_test.go index 60ec5d2c..8815e218 100644 --- a/pkg/commands/contract_root_dispatch_test.go +++ b/pkg/commands/contract_root_dispatch_test.go @@ -31,6 +31,22 @@ import ( "github.com/spf13/cobra" ) +// File-local fixtures for the dispatch contract tests. The literals +// these constants stand in for show up across many table-driven and +// scenario tests; centralising them lets goconst stop firing without +// turning the tests into a tangle of inline string concatenation. +const ( + fixtureBinaryName = "talm" + fixtureFileAYaml = "a.yaml" + fixtureFileBYaml = "b.yaml" + fixtureSomeAbsPath = "/some/path" + fixtureExplicitAbsPath = "/explicit/path" + // fixtureTestCmdUse is the "Use:" name baked into every cobra + // command spun up by the dispatch tests. The same literal pops up + // across many scenario tests; centralising it satisfies goconst. + fixtureTestCmdUse = "test" +) + // crossPlatformAbs builds an absolute path that satisfies // filepath.IsAbs on both POSIX and Windows. On POSIX the joined // path is already absolute (leading `/`). On Windows the drive @@ -82,7 +98,7 @@ func withOSArgs(t *testing.T, args []string) { // makeCmdWithStringSliceFlag creates a cobra.Command with a // `StringSliceP` flag named `flagName`. Used to exercise getFlagValues. func makeCmdWithStringSliceFlag(flagName, shortFlag string, defaults []string, persistent bool) *cobra.Command { - cmd := &cobra.Command{Use: "test"} + cmd := &cobra.Command{Use: fixtureTestCmdUse} if persistent { cmd.PersistentFlags().StringSliceP(flagName, shortFlag, defaults, "") } else { @@ -98,11 +114,11 @@ func makeCmdWithStringSliceFlag(flagName, shortFlag string, defaults []string, p // returns those values. func TestContract_GetFlagValues_NonPersistent(t *testing.T) { cmd := makeCmdWithStringSliceFlag("file", "f", nil, false) - if err := cmd.Flags().Set("file", "a.yaml,b.yaml"); err != nil { + if err := cmd.Flags().Set("file", fixtureFileAYaml+","+fixtureFileBYaml); err != nil { t.Fatal(err) } got := getFlagValues(cmd, "file") - if len(got) != 2 || got[0] != "a.yaml" || got[1] != "b.yaml" { + if len(got) != 2 || got[0] != fixtureFileAYaml || got[1] != fixtureFileBYaml { t.Errorf("expected [a.yaml b.yaml], got %v", got) } } @@ -124,7 +140,7 @@ func TestContract_GetFlagValues_Persistent(t *testing.T) { // returns an empty (non-nil) slice — never nil — so callers can // `range` it without a guard. Pin the non-nil property explicitly. func TestContract_GetFlagValues_AbsentFlagReturnsEmpty(t *testing.T) { - cmd := &cobra.Command{Use: "test"} + cmd := &cobra.Command{Use: fixtureTestCmdUse} got := getFlagValues(cmd, "nope") if got == nil { t.Error("expected non-nil empty slice") @@ -240,7 +256,7 @@ func TestContract_DetectRootFromCWD_WalksUp(t *testing.T) { // errors on mismatch surfaces here. func TestContract_CheckRootConflict_NotExplicit(t *testing.T) { withConfigSnapshot(t) - Config.RootDir = "/some/path" + Config.RootDir = fixtureSomeAbsPath if err := checkRootConflict("/different/path", false); err != nil { t.Errorf("expected nil error when --root not explicit, got %v", err) } @@ -250,8 +266,8 @@ func TestContract_CheckRootConflict_NotExplicit(t *testing.T) { // no error. func TestContract_CheckRootConflict_ExplicitMatching(t *testing.T) { withConfigSnapshot(t) - Config.RootDir = "/some/path" - if err := checkRootConflict("/some/path", true); err != nil { + Config.RootDir = fixtureSomeAbsPath + if err := checkRootConflict(fixtureSomeAbsPath, true); err != nil { t.Errorf("matching paths should not error, got %v", err) } } @@ -304,15 +320,15 @@ func TestContract_DetectAndSetRoot_RootInheritedPersistentFlag(t *testing.T) { // the parent command via StringVar(&Config.RootDir, ...), the // subcommand inherits it through cmd.Flags() but NOT // cmd.PersistentFlags(). - parent := &cobra.Command{Use: "talm"} + parent := &cobra.Command{Use: fixtureBinaryName} parent.PersistentFlags().StringVar(&Config.RootDir, "root", ".", "") - child := &cobra.Command{Use: "init"} + child := &cobra.Command{Use: initSubcommand} parent.AddCommand(child) if err := parent.PersistentFlags().Set("root", subdir); err != nil { t.Fatal(err) } - withOSArgs(t, []string{"talm", "init"}) + withOSArgs(t, []string{fixtureBinaryName, initSubcommand}) if err := DetectAndSetRoot(child, nil); err != nil { t.Fatalf("DetectAndSetRoot: %v", err) @@ -340,14 +356,14 @@ func TestContract_DetectAndSetRoot_FromFileFlag(t *testing.T) { t.Fatal(err) } - cmd := &cobra.Command{Use: "test"} + cmd := &cobra.Command{Use: fixtureTestCmdUse} cmd.PersistentFlags().String("root", "", "") cmd.Flags().StringSliceP("file", "f", nil, "") cmd.Flags().StringSliceP("template", "t", nil, "") if err := cmd.Flags().Set("file", nodeFile); err != nil { t.Fatal(err) } - withOSArgs(t, []string{"talm"}) + withOSArgs(t, []string{fixtureBinaryName}) if err := DetectAndSetRoot(cmd, nil); err != nil { t.Fatalf("DetectAndSetRoot: %v", err) @@ -367,15 +383,15 @@ func TestContract_DetectAndSetRoot_FilesInDifferentRootsError(t *testing.T) { rootB := t.TempDir() makeProjectRoot(t, rootA) makeProjectRoot(t, rootB) - fileA := filepath.Join(rootA, "a.yaml") - fileB := filepath.Join(rootB, "b.yaml") + fileA := filepath.Join(rootA, fixtureFileAYaml) + fileB := filepath.Join(rootB, fixtureFileBYaml) for _, f := range []string{fileA, fileB} { if err := os.WriteFile(f, nil, 0o600); err != nil { t.Fatal(err) } } - cmd := &cobra.Command{Use: "test"} + cmd := &cobra.Command{Use: fixtureTestCmdUse} cmd.PersistentFlags().String("root", "", "") cmd.Flags().StringSliceP("file", "f", nil, "") cmd.Flags().StringSliceP("template", "t", nil, "") @@ -385,7 +401,7 @@ func TestContract_DetectAndSetRoot_FilesInDifferentRootsError(t *testing.T) { if err := cmd.Flags().Set("file", fileB); err != nil { t.Fatal(err) } - withOSArgs(t, []string{"talm"}) + withOSArgs(t, []string{fixtureBinaryName}) err := DetectAndSetRoot(cmd, nil) if err == nil { @@ -403,11 +419,11 @@ func TestContract_DetectAndSetRoot_NoFlagsNoMarkersIsTolerated(t *testing.T) { emptyDir := t.TempDir() t.Chdir(emptyDir) - cmd := &cobra.Command{Use: "test"} + cmd := &cobra.Command{Use: fixtureTestCmdUse} cmd.PersistentFlags().String("root", "", "") cmd.Flags().StringSliceP("file", "f", nil, "") cmd.Flags().StringSliceP("template", "t", nil, "") - withOSArgs(t, []string{"talm"}) + withOSArgs(t, []string{fixtureBinaryName}) if err := DetectAndSetRoot(cmd, nil); err != nil { t.Errorf("expected nil error, got %v", err) @@ -494,15 +510,15 @@ func TestContract_DetectAndSetRootFromFiles_ExplicitConflict(t *testing.T) { func TestContract_EnsureTalosconfigPath_NoOpWhenChanged(t *testing.T) { withConfigSnapshot(t) - cmd := &cobra.Command{Use: "test"} + cmd := &cobra.Command{Use: fixtureTestCmdUse} cmd.PersistentFlags().String("talosconfig", "", "") - if err := cmd.PersistentFlags().Set("talosconfig", "/explicit/path"); err != nil { + if err := cmd.PersistentFlags().Set("talosconfig", fixtureExplicitAbsPath); err != nil { t.Fatal(err) } - GlobalArgs.Talosconfig = "/explicit/path" + GlobalArgs.Talosconfig = fixtureExplicitAbsPath EnsureTalosconfigPath(cmd) - if GlobalArgs.Talosconfig != "/explicit/path" { + if GlobalArgs.Talosconfig != fixtureExplicitAbsPath { t.Errorf("expected unchanged, got %q", GlobalArgs.Talosconfig) } } @@ -514,7 +530,7 @@ func TestContract_EnsureTalosconfigPath_NoOpWhenChanged(t *testing.T) { func TestContract_EnsureTalosconfigPath_DefaultsToRoot(t *testing.T) { withConfigSnapshot(t) - cmd := &cobra.Command{Use: "test"} + cmd := &cobra.Command{Use: fixtureTestCmdUse} cmd.PersistentFlags().String("talosconfig", "", "") root := crossPlatformAbs("some", "project") Config.RootDir = root @@ -535,7 +551,7 @@ func TestContract_EnsureTalosconfigPath_DefaultsToRoot(t *testing.T) { func TestContract_EnsureTalosconfigPath_RelativeAnchoredToRoot(t *testing.T) { withConfigSnapshot(t) - cmd := &cobra.Command{Use: "test"} + cmd := &cobra.Command{Use: fixtureTestCmdUse} cmd.PersistentFlags().String("talosconfig", "", "") root := crossPlatformAbs("some", "project") Config.RootDir = root @@ -553,7 +569,7 @@ func TestContract_EnsureTalosconfigPath_RelativeAnchoredToRoot(t *testing.T) { func TestContract_EnsureTalosconfigPath_AbsolutePathPreserved(t *testing.T) { withConfigSnapshot(t) - cmd := &cobra.Command{Use: "test"} + cmd := &cobra.Command{Use: fixtureTestCmdUse} cmd.PersistentFlags().String("talosconfig", "", "") Config.RootDir = filepath.Join(string(filepath.Separator), "some", "project") abs := crossPlatformAbs("etc", "talos", "config") diff --git a/pkg/commands/contract_template_test.go b/pkg/commands/contract_template_test.go index e4a9606a..f3b9ec0d 100644 --- a/pkg/commands/contract_template_test.go +++ b/pkg/commands/contract_template_test.go @@ -73,7 +73,7 @@ func TestContract_ResolveEngineTemplatePaths_AbsoluteInsideRoot(t *testing.T) { t.Fatal(err) } got := resolveEngineTemplatePaths([]string{tmpl}, root) - if len(got) != 1 || got[0] != "templates/controlplane.yaml" { + if len(got) != 1 || got[0] != testTemplateControlplaneRel { t.Errorf("got %v, want [templates/controlplane.yaml]", got) } } @@ -93,8 +93,8 @@ func TestContract_ResolveEngineTemplatePaths_RelativeFromCWD(t *testing.T) { t.Fatal(err) } t.Chdir(root) - got := resolveEngineTemplatePaths([]string{"templates/worker.yaml"}, root) - if len(got) != 1 || got[0] != "templates/worker.yaml" { + got := resolveEngineTemplatePaths([]string{testTemplateWorker}, root) + if len(got) != 1 || got[0] != testTemplateWorker { t.Errorf("got %v, want [templates/worker.yaml]", got) } } @@ -125,7 +125,7 @@ func TestContract_ResolveEngineTemplatePaths_OutsideRootFallbackToTemplatesBasen t.Chdir(sibling) got := resolveEngineTemplatePaths([]string{"../some/other/controlplane.yaml"}, root) - if len(got) != 1 || got[0] != "templates/controlplane.yaml" { + if len(got) != 1 || got[0] != testTemplateControlplaneRel { t.Errorf("got %v, want [templates/controlplane.yaml] (fallback)", got) } } @@ -146,7 +146,7 @@ func TestContract_ResolveEngineTemplatePaths_OutsideRootNoFallback(t *testing.T) } // Must not have been silently rewritten to `templates/missing.yaml` // (because that file does not exist). - if got[0] == "templates/missing.yaml" { + if got[0] == testTemplateMissing { t.Errorf("did not expect templates/ fallback, got %q", got[0]) } } @@ -198,10 +198,10 @@ func TestContract_GenerateOutput_ComposesModelineWarningAndRender(t *testing.T) templatesFromArgs bool }{ offline: true, - templateFiles: []string{"templates/config.yaml"}, + templateFiles: []string{testTemplateConfig}, } - GlobalArgs.Nodes = []string{"10.0.0.1"} - GlobalArgs.Endpoints = []string{"10.0.0.1"} + GlobalArgs.Nodes = []string{testNodeAddrA} + GlobalArgs.Endpoints = []string{testNodeAddrA} got, err := generateOutput(context.Background(), nil, nil) if err != nil { @@ -218,7 +218,7 @@ func TestContract_GenerateOutput_ComposesModelineWarningAndRender(t *testing.T) if !strings.Contains(lines[0], `"10.0.0.1"`) { t.Errorf("modeline missing nodes: %q", lines[0]) } - if !strings.Contains(lines[0], "templates/config.yaml") { + if !strings.Contains(lines[0], testTemplateConfig) { t.Errorf("modeline missing template path: %q", lines[0]) } // Line 2: warning banner. @@ -242,7 +242,7 @@ func TestContract_GenerateOutput_MissingTemplateError(t *testing.T) { Config.RootDir = chartRoot templateCmdFlags.offline = true templateCmdFlags.templateFiles = []string{"templates/does-not-exist.yaml"} - GlobalArgs.Nodes = []string{"10.0.0.1"} + GlobalArgs.Nodes = []string{testNodeAddrA} _, err := generateOutput(context.Background(), nil, nil) if err == nil { @@ -262,7 +262,7 @@ func TestContract_GenerateOutput_NoTemplatesError(t *testing.T) { Config.RootDir = chartRoot templateCmdFlags.offline = true templateCmdFlags.templateFiles = nil - GlobalArgs.Nodes = []string{"10.0.0.1"} + GlobalArgs.Nodes = []string{testNodeAddrA} _, err := generateOutput(context.Background(), nil, nil) if err == nil { @@ -281,9 +281,9 @@ func TestContract_Template_PrintsToStdout(t *testing.T) { chartRoot := makeMinimalChart(t) Config.RootDir = chartRoot templateCmdFlags.offline = true - templateCmdFlags.templateFiles = []string{"templates/config.yaml"} - GlobalArgs.Nodes = []string{"10.0.0.1"} - GlobalArgs.Endpoints = []string{"10.0.0.1"} + templateCmdFlags.templateFiles = []string{testTemplateConfig} + GlobalArgs.Nodes = []string{testNodeAddrA} + GlobalArgs.Endpoints = []string{testNodeAddrA} out := captureStdout(t, func() { if err := template(nil)(context.Background(), nil); err != nil { @@ -309,8 +309,8 @@ func TestContract_Template_PropagatesError(t *testing.T) { chartRoot := makeMinimalChart(t) Config.RootDir = chartRoot templateCmdFlags.offline = true - templateCmdFlags.templateFiles = []string{"templates/missing.yaml"} - GlobalArgs.Nodes = []string{"10.0.0.1"} + templateCmdFlags.templateFiles = []string{testTemplateMissing} + GlobalArgs.Nodes = []string{testNodeAddrA} err := template(nil)(context.Background(), nil) if err == nil { @@ -328,10 +328,12 @@ func TestContract_Template_PropagatesError(t *testing.T) { // laptop), so generating it inside every test would dominate the // suite runtime. The serialized bytes are reused as the // secrets.yaml fixture for every minimal chart. +// +//nolint:gochecknoglobals // sync.Once + cached PKI bundle, scoped to the test process; instantiating per-test would dominate runtime var ( - sharedSecretsOnce sync.Once - sharedSecretsYAML []byte - sharedSecretsError error + sharedSecretsOnce sync.Once + sharedSecretsYAML []byte + errSharedSecrets error ) func loadSharedSecretsYAML(t *testing.T) []byte { @@ -339,13 +341,13 @@ func loadSharedSecretsYAML(t *testing.T) []byte { sharedSecretsOnce.Do(func() { bundle, err := secrets.NewBundle(secrets.NewClock(), nil) if err != nil { - sharedSecretsError = err + errSharedSecrets = err return } - sharedSecretsYAML, sharedSecretsError = yaml.Marshal(bundle) + sharedSecretsYAML, errSharedSecrets = yaml.Marshal(bundle) }) - if sharedSecretsError != nil { - t.Fatalf("generate shared secrets bundle: %v", sharedSecretsError) + if errSharedSecrets != nil { + t.Fatalf("generate shared secrets bundle: %v", errSharedSecrets) } return sharedSecretsYAML } diff --git a/pkg/commands/init.go b/pkg/commands/init.go index 1d776641..c463d5b9 100644 --- a/pkg/commands/init.go +++ b/pkg/commands/init.go @@ -16,6 +16,7 @@ package commands import ( "bufio" + "bytes" "fmt" "io" "os" @@ -25,6 +26,7 @@ import ( "strings" "time" + "github.com/cockroachdb/errors" "github.com/cozystack/talm/pkg/age" "github.com/cozystack/talm/pkg/generated" "github.com/cozystack/talm/pkg/secureperm" @@ -38,6 +40,54 @@ import ( "k8s.io/apimachinery/pkg/util/validation" ) +// Init-flow file and chart name constants. Cross-subcommand strings +// (initSubcommand, chartYamlName, defaultKubeconfigName, +// defaultLocalEndpoint) live in consts.go. +const ( + // secretsYamlName is the unencrypted secrets manifest written + // during init and consumed by chart rendering. + secretsYamlName = "secrets.yaml" + // secretsEncryptedYamlName is the age-encrypted secrets manifest + // committed to git in encrypted form. + secretsEncryptedYamlName = "secrets.encrypted.yaml" + // talmKeyName is the age private-key file the init flow generates + // alongside the encrypted bundle; it is what `.gitignore` excludes + // and what tests pin as a sensitive artefact name. + talmKeyName = "talm.key" + // talosconfigName is the talosctl client-config filename written + // during init and rotated by --encrypt/--decrypt. + talosconfigName = "talosconfig" + // valuesYamlName is the chart's values.yaml manifest filename; + // the init flow rewrites it when --image is provided and reads + // it from updateTalmLibraryChart. + valuesYamlName = "values.yaml" + // presetTalmLibrary is the name of the bundled library chart; + // it ships with every preset and is excluded from preset-name + // detection. + presetTalmLibrary = "talm" + // presetFileMode is the permission applied to chart artefacts + // (Chart.yaml, values.yaml, helpers, templates) that are not + // secret-bearing. + presetFileMode os.FileMode = 0o644 + // secureDirMode is the permission used for parent directories of + // secret-bearing files so an over-permissive umask cannot widen + // access. + secureDirMode os.FileMode = 0o700 + // reportVerbCreated and reportVerbUpdated are the operator-facing + // verbs the init flow prints when materialising or rewriting a + // project artefact. Hoisted so goconst sees a single canonical + // reference for each. + reportVerbCreated = "Created" + reportVerbUpdated = "Updated" +) + +// initCmdFlags is the package-level flag struct backing the init +// subcommand; cobra binds Flags() entries directly to these fields, +// which forces a global. The global also exposes the flag values to +// helpers (validateImageOverride, updateTalmLibraryChart) that share +// the same configuration without threading it through every signature. +// +//nolint:gochecknoglobals // cobra flag binding requires a stable address var initCmdFlags struct { force bool preset string @@ -50,12 +100,14 @@ var initCmdFlags struct { } // initCmd represents the `init` command. +// +//nolint:gochecknoglobals // cobra command registration requires a package-level value var initCmd = &cobra.Command{ - Use: "init", + Use: initSubcommand, Short: "Initialize a new project and generate default values", Long: ``, Args: cobra.NoArgs, - PreRunE: func(cmd *cobra.Command, args []string) error { + PreRunE: func(cmd *cobra.Command, _ []string) error { if !cmd.Flags().Changed("talos-version") { initCmdFlags.talosVersion = Config.TemplateOptions.TalosVersion } @@ -65,7 +117,10 @@ var initCmd = &cobra.Command{ // --decrypt / --update would let the flag silently disappear — // surface the mismatch up front instead. if initCmdFlags.image != "" && (initCmdFlags.encrypt || initCmdFlags.decrypt || initCmdFlags.update) { - return fmt.Errorf("--image is honored on initial init only; not valid with --encrypt, --decrypt, or --update") + return errors.WithHint( + errors.New("--image is honored on initial init only; not valid with --encrypt, --decrypt, or --update"), + "drop --image, or run init without --encrypt/--decrypt/--update", + ) } // For -e, -d, and -u flags, always check that we're in a project root @@ -73,14 +128,19 @@ var initCmd = &cobra.Command{ // Verify that Config.RootDir is actually a project root detectedRoot, err := DetectProjectRoot(Config.RootDir) if err != nil { - return fmt.Errorf("failed to verify project root: %w", err) + return errors.Wrap(err, "failed to verify project root") } + if detectedRoot == "" { - return fmt.Errorf("not in a project root: Chart.yaml and secrets.yaml (or secrets.encrypted.yaml) must exist in %s or parent directories", Config.RootDir) + return errors.WithHintf( + errors.Newf("not in a project root: Chart.yaml and secrets.yaml (or secrets.encrypted.yaml) must exist in %s or parent directories", Config.RootDir), + "run from a project directory or pass --root explicitly", + ) } // Ensure Config.RootDir is set to the detected root absDetectedRoot, _ := filepath.Abs(detectedRoot) absConfigRoot, _ := filepath.Abs(Config.RootDir) + if absDetectedRoot != absConfigRoot { Config.RootDir = detectedRoot } @@ -96,11 +156,19 @@ var initCmd = &cobra.Command{ // where it can come from -p flag or Chart.yaml return nil } + if initCmdFlags.preset == "" { - return fmt.Errorf("preset is required (use --preset or -p flag)") + return errors.WithHint( + errors.New("preset is required (use --preset or -p flag)"), + "pass --preset cozystack (or another available preset) to choose a chart layout", + ) } + if initCmdFlags.name == "" { - return fmt.Errorf("cluster name is required (use --name or -N flag)") + return errors.WithHint( + errors.New("cluster name is required (use --name or -N flag)"), + "pass --name to set the new project's cluster identifier", + ) } // Validate the operator-supplied cluster name against the same // DNS-1123 subdomain rule the chart helpers enforce at render @@ -109,7 +177,10 @@ var initCmd = &cobra.Command{ // it here means the operator sees the precise upstream message // (length, character class, etc.) before any file is written. if errs := validation.IsDNS1123Subdomain(initCmdFlags.name); len(errs) > 0 { - return fmt.Errorf("--name %q is not a valid DNS-1123 subdomain: %s", initCmdFlags.name, strings.Join(errs, "; ")) + return errors.WithHintf( + errors.Newf("--name %q is not a valid DNS-1123 subdomain: %s", initCmdFlags.name, strings.Join(errs, "; ")), + "cluster names must be lowercase, alphanumeric or '-'/'.', and start/end with an alphanumeric character", + ) } // Refuse to init when CWD is inside an existing talm project but @@ -128,11 +199,12 @@ var initCmd = &cobra.Command{ // prevent. cwd, err := os.Getwd() if err != nil { - return fmt.Errorf("failed to determine current working directory: %w", err) + return errors.Wrap(err, "failed to determine current working directory") } + absCwd, err := filepath.Abs(cwd) if err != nil { - return fmt.Errorf("failed to resolve absolute path of current working directory: %w", err) + return errors.Wrap(err, "failed to resolve absolute path of current working directory") } // Config.RootDir may be a relative path (defaults to "."); // filepath.Abs calls os.Getwd internally for relative inputs, @@ -142,18 +214,23 @@ var initCmd = &cobra.Command{ // and let the comparison go the wrong way. absRootDir, err := filepath.Abs(Config.RootDir) if err != nil { - return fmt.Errorf("failed to resolve absolute path of project root %q: %w", Config.RootDir, err) + return errors.Wrapf(err, "failed to resolve absolute path of project root %q", Config.RootDir) } + if !Config.RootDirExplicit && absRootDir != absCwd { // %s, not %q: %q calls strconv.Quote which escapes // backslashes in Windows paths (C:\Users\... renders as // "C:\\Users\\..."), making the message harder to read // for the operator and breaking substring tests. - return fmt.Errorf("refusing to init: %s is inside an existing talm project at %s. To create a new project under the current directory, pass --root . explicitly. To re-initialise the parent, run from the parent directory", absCwd, absRootDir) + return errors.WithHint( + errors.Newf("refusing to init: %s is inside an existing talm project at %s. To create a new project under the current directory, pass --root . explicitly. To re-initialise the parent, run from the parent directory", absCwd, absRootDir), + "pass --root . to create a new project here, or run from the parent directory to re-initialise it", + ) } + return nil }, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, _ []string) error { var ( secretsBundle *secrets.Bundle versionContract *config.VersionContract @@ -163,10 +240,11 @@ var initCmd = &cobra.Command{ if initCmdFlags.update { return updateTalmLibraryChart() } + if initCmdFlags.talosVersion != "" { versionContract, err = config.ParseContractFromVersion(initCmdFlags.talosVersion) if err != nil { - return fmt.Errorf("invalid talos-version: %w", err) + return errors.Wrap(err, "invalid talos-version") } } @@ -174,29 +252,37 @@ var initCmd = &cobra.Command{ versionContract, ) if err != nil { - return fmt.Errorf("failed to create secrets bundle: %w", err) + return errors.Wrap(err, "failed to create secrets bundle") } - var genOptions []generate.Option //nolint:prealloc + + var genOptions []generate.Option + // Validate preset only if not using --encrypt or --decrypt if !initCmdFlags.encrypt && !initCmdFlags.decrypt { availablePresets, err := generated.AvailablePresets() if err != nil { - return fmt.Errorf("failed to get available presets: %w", err) + return errors.Wrap(err, "failed to get available presets") } + if !isValidPreset(initCmdFlags.preset, availablePresets) { - return fmt.Errorf("invalid preset: %s. Valid presets are: %v", initCmdFlags.preset, availablePresets) + return errors.WithHintf( + errors.Newf("invalid preset: %s. Valid presets are: %v", initCmdFlags.preset, availablePresets), + "pick one of the listed presets and pass it via --preset", + ) } } + if initCmdFlags.talosVersion != "" { var versionContract *config.VersionContract versionContract, err = config.ParseContractFromVersion(initCmdFlags.talosVersion) if err != nil { - return fmt.Errorf("invalid talos-version: %w", err) + return errors.Wrap(err, "invalid talos-version") } genOptions = append(genOptions, generate.WithVersionContract(versionContract)) } + genOptions = append(genOptions, generate.WithSecretsBundle(secretsBundle)) // Pre-check every preset-loop destination so a fresh init is @@ -223,10 +309,12 @@ var initCmd = &cobra.Command{ if !initCmdFlags.encrypt && !initCmdFlags.decrypt { presetFiles, err = generated.PresetFiles() if err != nil { - return fmt.Errorf("failed to get preset files: %w", err) + return errors.Wrap(err, "failed to get preset files") } + if !initCmdFlags.force { var conflicts []string + for path := range presetFiles { parts := strings.SplitN(path, "/", 2) // PresetFiles walks an embed.FS so it only ever @@ -240,7 +328,9 @@ var initCmd = &cobra.Command{ if len(parts) < 2 { continue } + chartName := parts[0] + var dest string // Library chart files always land under charts/talm/. // Checked first so a hypothetical preset literally @@ -249,29 +339,35 @@ var initCmd = &cobra.Command{ // "talm" today, but the dispatch should not depend // on that invariant). switch chartName { - case "talm": + case presetTalmLibrary: dest = filepath.Join(Config.RootDir, "charts", path) case initCmdFlags.preset: dest = filepath.Join(Config.RootDir, filepath.Join(parts[1:]...)) default: continue } + if _, statErr := os.Stat(dest); statErr == nil { conflicts = append(conflicts, dest) } } + if len(conflicts) > 0 { slices.Sort(conflicts) - return fmt.Errorf("refusing to init: %d file(s) already exist in target directory; pass --force to overwrite, or --update to refresh only the talm library chart:\n - %s", - len(conflicts), strings.Join(conflicts, "\n - ")) + + return errors.WithHint( + errors.Newf("refusing to init: %d file(s) already exist in target directory; pass --force to overwrite, or --update to refresh only the talm library chart:\n - %s", + len(conflicts), strings.Join(conflicts, "\n - ")), + "rerun with --force to overwrite, --update to refresh only the talm library chart, or remove the listed files manually", + ) } } } // Handle age encryption logic - secretsFile := filepath.Join(Config.RootDir, "secrets.yaml") - encryptedSecretsFile := filepath.Join(Config.RootDir, "secrets.encrypted.yaml") - keyFile := filepath.Join(Config.RootDir, "talm.key") + secretsFile := filepath.Join(Config.RootDir, secretsYamlName) + encryptedSecretsFile := filepath.Join(Config.RootDir, secretsEncryptedYamlName) + keyFile := filepath.Join(Config.RootDir, talmKeyName) secretsFileExists := fileExists(secretsFile) encryptedSecretsFileExists := fileExists(encryptedSecretsFile) @@ -280,19 +376,24 @@ var initCmd = &cobra.Command{ // Check for invalid state: encrypted file exists but secrets.yaml and key don't if encryptedSecretsFileExists && !secretsFileExists && !keyFileExists { - return fmt.Errorf("secrets.encrypted.yaml exists but secrets.yaml and talm.key are missing. Cannot decrypt without key") + return errors.WithHint( + errors.New("secrets.encrypted.yaml exists but secrets.yaml and talm.key are missing. Cannot decrypt without key"), + "restore talm.key from your backup, or recreate the project from scratch if the key is unrecoverable", + ) } // Handle --encrypt flag (early return, doesn't need preset) if initCmdFlags.encrypt { // Ensure key exists before encryption - keyFile := filepath.Join(Config.RootDir, "talm.key") + keyFile := filepath.Join(Config.RootDir, talmKeyName) keyFileExists := fileExists(keyFile) + if !keyFileExists { _, keyCreated, err := age.GenerateKey(Config.RootDir) if err != nil { - return fmt.Errorf("failed to generate key: %w", err) + return errors.Wrap(err, "failed to generate key") } + if keyCreated { fmt.Fprintf(os.Stderr, "Generated new encryption key: talm.key\n") printSecretsWarning() @@ -300,12 +401,14 @@ var initCmd = &cobra.Command{ } // Encrypt all sensitive files - secretsFile := filepath.Join(Config.RootDir, "secrets.yaml") + secretsFile := filepath.Join(Config.RootDir, secretsYamlName) talosconfigFile := filepath.Join(Config.RootDir, "talosconfig") + kubeconfigPath := Config.GlobalOptions.Kubeconfig if kubeconfigPath == "" { - kubeconfigPath = "kubeconfig" + kubeconfigPath = defaultKubeconfigName } + kubeconfigFile := filepath.Join(Config.RootDir, kubeconfigPath) encryptedCount := 0 @@ -313,18 +416,24 @@ var initCmd = &cobra.Command{ // Encrypt secrets.yaml if fileExists(secretsFile) { fmt.Fprintf(os.Stderr, "Encrypting secrets.yaml -> secrets.encrypted.yaml\n") - if err := age.EncryptSecretsFile(Config.RootDir); err != nil { - return fmt.Errorf("failed to encrypt secrets: %w", err) + + err := age.EncryptSecretsFile(Config.RootDir) + if err != nil { + return errors.Wrap(err, "failed to encrypt secrets") } + encryptedCount++ } // Encrypt talosconfig if fileExists(talosconfigFile) { fmt.Fprintf(os.Stderr, "Encrypting talosconfig -> talosconfig.encrypted\n") - if err := age.EncryptYAMLFile(Config.RootDir, "talosconfig", "talosconfig.encrypted"); err != nil { - return fmt.Errorf("failed to encrypt talosconfig: %w", err) + + err := age.EncryptYAMLFile(Config.RootDir, "talosconfig", "talosconfig.encrypted") + if err != nil { + return errors.Wrap(err, "failed to encrypt talosconfig") } + encryptedCount++ } else { fmt.Fprintf(os.Stderr, "Skipping talosconfig (file not found)\n") @@ -333,17 +442,21 @@ var initCmd = &cobra.Command{ // Encrypt kubeconfig if fileExists(kubeconfigFile) { fmt.Fprintf(os.Stderr, "Encrypting %s -> %s.encrypted\n", kubeconfigPath, kubeconfigPath) - if err := age.EncryptYAMLFile(Config.RootDir, kubeconfigPath, kubeconfigPath+".encrypted"); err != nil { - return fmt.Errorf("failed to encrypt kubeconfig: %w", err) + + err = age.EncryptYAMLFile(Config.RootDir, kubeconfigPath, kubeconfigPath+".encrypted") + if err != nil { + return errors.Wrap(err, "failed to encrypt kubeconfig") } + encryptedCount++ } else { fmt.Fprintf(os.Stderr, "Skipping %s (file not found)\n", kubeconfigPath) } // Update .gitignore file - if err := writeGitignoreFile(); err != nil { - return fmt.Errorf("failed to update .gitignore: %w", err) + err = writeGitignoreFile() + if err != nil { + return errors.Wrap(err, "failed to update .gitignore") } if encryptedCount > 0 { @@ -351,18 +464,21 @@ var initCmd = &cobra.Command{ } else { fmt.Fprintf(os.Stderr, "No files to encrypt.\n") } + return nil } // Handle --decrypt flag (early return, doesn't need preset) if initCmdFlags.decrypt { // Decrypt all encrypted files - encryptedSecretsFile := filepath.Join(Config.RootDir, "secrets.encrypted.yaml") + encryptedSecretsFile := filepath.Join(Config.RootDir, secretsEncryptedYamlName) encryptedTalosconfigFile := filepath.Join(Config.RootDir, "talosconfig.encrypted") + kubeconfigPath := Config.GlobalOptions.Kubeconfig if kubeconfigPath == "" { - kubeconfigPath = "kubeconfig" + kubeconfigPath = defaultKubeconfigName } + encryptedKubeconfigFile := filepath.Join(Config.RootDir, kubeconfigPath+".encrypted") decryptedCount := 0 @@ -370,9 +486,11 @@ var initCmd = &cobra.Command{ // Decrypt secrets.encrypted.yaml if fileExists(encryptedSecretsFile) { fmt.Fprintf(os.Stderr, "Decrypting secrets.encrypted.yaml -> secrets.yaml\n") + if err := age.DecryptSecretsFile(Config.RootDir); err != nil { - return fmt.Errorf("failed to decrypt secrets: %w", err) + return errors.Wrap(err, "failed to decrypt secrets") } + decryptedCount++ } else { fmt.Fprintf(os.Stderr, "Skipping secrets.encrypted.yaml (file not found)\n") @@ -381,9 +499,11 @@ var initCmd = &cobra.Command{ // Decrypt talosconfig.encrypted if fileExists(encryptedTalosconfigFile) { fmt.Fprintf(os.Stderr, "Decrypting talosconfig.encrypted -> talosconfig\n") + if err := age.DecryptYAMLFile(Config.RootDir, "talosconfig.encrypted", "talosconfig"); err != nil { - return fmt.Errorf("failed to decrypt talosconfig: %w", err) + return errors.Wrap(err, "failed to decrypt talosconfig") } + decryptedCount++ } else { fmt.Fprintf(os.Stderr, "Skipping talosconfig.encrypted (file not found)\n") @@ -392,9 +512,11 @@ var initCmd = &cobra.Command{ // Decrypt kubeconfig.encrypted if fileExists(encryptedKubeconfigFile) { fmt.Fprintf(os.Stderr, "Decrypting %s.encrypted -> %s\n", kubeconfigPath, kubeconfigPath) + if err := age.DecryptYAMLFile(Config.RootDir, kubeconfigPath+".encrypted", kubeconfigPath); err != nil { - return fmt.Errorf("failed to decrypt kubeconfig: %w", err) + return errors.Wrap(err, "failed to decrypt kubeconfig") } + decryptedCount++ } else { fmt.Fprintf(os.Stderr, "Skipping %s.encrypted (file not found)\n", kubeconfigPath) @@ -402,7 +524,7 @@ var initCmd = &cobra.Command{ // Update .gitignore file if err := writeGitignoreFile(); err != nil { - return fmt.Errorf("failed to update .gitignore: %w", err) + return errors.Wrap(err, "failed to update .gitignore") } if decryptedCount > 0 { @@ -410,13 +532,14 @@ var initCmd = &cobra.Command{ } else { fmt.Fprintf(os.Stderr, "No files to decrypt.\n") } + return nil } // If encrypted file exists, decrypt it if encryptedSecretsFileExists && !secretsFileExists { if err := age.DecryptSecretsFile(Config.RootDir); err != nil { - return fmt.Errorf("failed to decrypt secrets: %w", err) + return errors.Wrap(err, "failed to decrypt secrets") } } @@ -425,6 +548,7 @@ var initCmd = &cobra.Command{ if err = writeSecretsBundleToFile(secretsBundle); err != nil { return err } + secretsFileExists = true // Update flag after creation } @@ -434,15 +558,16 @@ var initCmd = &cobra.Command{ if !keyFileExists { _, keyCreated, err := age.GenerateKey(Config.RootDir) if err != nil { - return fmt.Errorf("failed to generate key: %w", err) + return errors.Wrap(err, "failed to generate key") } + keyFileExists = true // Update flag after creation keyWasCreated = keyCreated } // Encrypt secrets if err := age.EncryptSecretsFile(Config.RootDir); err != nil { - return fmt.Errorf("failed to encrypt secrets: %w", err) + return errors.Wrap(err, "failed to encrypt secrets") } } @@ -459,6 +584,7 @@ var initCmd = &cobra.Command{ if _, err := handleTalosconfigEncryption(false); err != nil { return err } + talosconfigFileExists = fileExists(talosconfigFile) } @@ -466,13 +592,14 @@ var initCmd = &cobra.Command{ if !talosconfigFileExists { configBundle, err := gen.GenerateConfigBundle(genOptions, clusterName, "https://192.168.0.1:6443", "", []string{}, []string{}, []string{}) if err != nil { - return err + return errors.Wrap(err, "generating talos config bundle") } - configBundle.TalosConfig().Contexts[clusterName].Endpoints = []string{"127.0.0.1"} + + configBundle.TalosConfig().Contexts[clusterName].Endpoints = []string{defaultLocalEndpoint} data, err := yaml.Marshal(configBundle.TalosConfig()) if err != nil { - return fmt.Errorf("failed to marshal config: %+v", err) + return errors.Wrap(err, "failed to marshal config") } if err = writeSecureToDestination(data, talosconfigFile); err != nil { @@ -485,6 +612,7 @@ var initCmd = &cobra.Command{ if err != nil { return err } + if talosKeyCreated { keyWasCreated = true } @@ -492,8 +620,9 @@ var initCmd = &cobra.Command{ // Handle kubeconfig encryption logic (check if kubeconfig exists from Chart.yaml) kubeconfigPath := Config.GlobalOptions.Kubeconfig if kubeconfigPath == "" { - kubeconfigPath = "kubeconfig" + kubeconfigPath = defaultKubeconfigName } + kubeconfigFile := filepath.Join(Config.RootDir, kubeconfigPath) encryptedKubeconfigFile := filepath.Join(Config.RootDir, kubeconfigPath+".encrypted") kubeconfigFileExists := fileExists(kubeconfigFile) @@ -502,8 +631,9 @@ var initCmd = &cobra.Command{ // If encrypted file exists, decrypt it if encryptedKubeconfigFileExists && !kubeconfigFileExists { if err := age.DecryptYAMLFile(Config.RootDir, kubeconfigPath+".encrypted", kubeconfigPath); err != nil { - return fmt.Errorf("failed to decrypt kubeconfig: %w", err) + return errors.Wrap(err, "failed to decrypt kubeconfig") } + kubeconfigFileExists = true } @@ -513,14 +643,15 @@ var initCmd = &cobra.Command{ if !keyFileExists { _, keyCreated, err := age.GenerateKey(Config.RootDir) if err != nil { - return fmt.Errorf("failed to generate key: %w", err) + return errors.Wrap(err, "failed to generate key") } + keyWasCreated = keyCreated } // Encrypt kubeconfig if err := age.EncryptYAMLFile(Config.RootDir, kubeconfigPath, kubeconfigPath+".encrypted"); err != nil { - return fmt.Errorf("failed to encrypt kubeconfig: %w", err) + return errors.Wrap(err, "failed to encrypt kubeconfig") } } @@ -531,7 +662,7 @@ var initCmd = &cobra.Command{ nodesDir := filepath.Join(Config.RootDir, "nodes") if err := os.MkdirAll(nodesDir, os.ModePerm); err != nil { - return fmt.Errorf("failed to create nodes directory: %w", err) + return errors.Wrap(err, "failed to create nodes directory") } // presetFiles was loaded up-front for the pre-check above and @@ -550,30 +681,34 @@ var initCmd = &cobra.Command{ if chartName == initCmdFlags.preset { file := filepath.Join(Config.RootDir, filepath.Join(parts[1:]...)) switch parts[len(parts)-1] { - case "Chart.yaml": - err = writeToDestination(fmt.Appendf(nil, content, clusterName, Config.InitOptions.Version), file, 0o644) - case "values.yaml": + case chartYamlName: + err = writeToDestination(fmt.Appendf(nil, content, clusterName, Config.InitOptions.Version), file, presetFileMode) + case valuesYamlName: var rendered []byte + rendered, err = applyImageOverride([]byte(content), initCmdFlags.image) if err != nil { return err } - err = writeToDestination(rendered, file, 0o644) + + err = writeToDestination(rendered, file, presetFileMode) default: - err = writeToDestination([]byte(content), file, 0o644) + err = writeToDestination([]byte(content), file, presetFileMode) } + if err != nil { return err } } // Write library chart - if chartName == "talm" { + if chartName == presetTalmLibrary { file := filepath.Join(Config.RootDir, filepath.Join("charts", path)) - if parts[len(parts)-1] == "Chart.yaml" { - err = writeToDestination(fmt.Appendf(nil, content, "talm", Config.InitOptions.Version), file, 0o644) + if parts[len(parts)-1] == chartYamlName { + err = writeToDestination(fmt.Appendf(nil, content, presetTalmLibrary, Config.InitOptions.Version), file, presetFileMode) } else { - err = writeToDestination([]byte(content), file, 0o644) + err = writeToDestination([]byte(content), file, presetFileMode) } + if err != nil { return err } @@ -586,28 +721,28 @@ var initCmd = &cobra.Command{ } return nil - }, } func writeSecretsBundleToFile(bundle *secrets.Bundle) error { bundleBytes, err := yaml.Marshal(bundle) if err != nil { - return err + return errors.Wrap(err, "marshalling secrets bundle") } - secretsFile := filepath.Join(Config.RootDir, "secrets.yaml") + secretsFile := filepath.Join(Config.RootDir, secretsYamlName) // validateFileExists is invoked inside writeSecureToDestination; // no need to duplicate the --force / existing-file gate here. return writeSecureToDestination(bundleBytes, secretsFile) } -// readChartYamlPreset reads Chart.yaml and determines the preset name from dependencies +// readChartYamlPreset reads Chart.yaml and determines the preset name from dependencies. func readChartYamlPreset() (string, error) { - chartYamlPath := filepath.Join(Config.RootDir, "Chart.yaml") + chartYamlPath := filepath.Join(Config.RootDir, chartYamlName) + data, err := os.ReadFile(chartYamlPath) if err != nil { - return "", fmt.Errorf("failed to read Chart.yaml: %w", err) + return "", errors.Wrap(err, "failed to read Chart.yaml") } var chartData struct { @@ -617,17 +752,21 @@ func readChartYamlPreset() (string, error) { } if err := yaml.Unmarshal(data, &chartData); err != nil { - return "", fmt.Errorf("failed to parse Chart.yaml: %w", err) + return "", errors.Wrap(err, "failed to parse Chart.yaml") } // Find preset in dependencies (exclude "talm" which is the library chart) for _, dep := range chartData.Dependencies { - if dep.Name != "talm" { + if dep.Name != presetTalmLibrary { return dep.Name, nil } } - return "", fmt.Errorf("preset not found in Chart.yaml dependencies") + //nolint:wrapcheck // cockroachdb/errors.WithHint at boundary. + return "", errors.WithHint( + errors.New("preset not found in Chart.yaml dependencies"), + "add a preset chart (e.g. cozystack) to Chart.yaml's dependencies, or pass --preset on the command line", + ) } // imageLineRe matches the top-level `image:` line in a preset @@ -669,9 +808,15 @@ func applyImageOverride(values []byte, override string) ([]byte, error) { // depth for direct callers (unit tests, future code paths that // might skip the validator) so the helper is safe in isolation. if !imageLineRe.Match(values) { - return nil, fmt.Errorf("--image was set but the preset values.yaml does not declare a top-level image: field; remove --image, choose a different preset, or add the image field manually") + //nolint:wrapcheck // cockroachdb/errors.WithHint at boundary. + return nil, errors.WithHint( + errors.New("--image was set but the preset values.yaml does not declare a top-level image: field; remove --image, choose a different preset, or add the image field manually"), + "choose a preset that exposes a top-level image: field (e.g. cozystack), or omit --image", + ) } + replacement := fmt.Appendf(nil, "image: %q", override) + return imageLineRe.ReplaceAllFunc(values, func([]byte) []byte { return replacement }), nil @@ -686,23 +831,36 @@ func validateImageOverride(presetFiles map[string]string, presetName, override s if override == "" { return nil } + for path, content := range presetFiles { parts := strings.SplitN(path, "/", 2) if len(parts) != 2 || parts[0] != presetName { continue } - if parts[1] != "values.yaml" { + + if parts[1] != valuesYamlName { continue } + if !imageLineRe.MatchString(content) { - return fmt.Errorf("--image was set but preset %q does not declare a top-level image: field in values.yaml; remove --image or choose a preset that exposes it (e.g. cozystack)", presetName) + //nolint:wrapcheck // cockroachdb/errors.WithHint at boundary. + return errors.WithHint( + errors.Newf("--image was set but preset %q does not declare a top-level image: field in values.yaml; remove --image or choose a preset that exposes it (e.g. cozystack)", presetName), + "choose a preset that exposes a top-level image: field, or omit --image", + ) } + return nil } - return fmt.Errorf("--image was set but preset %q has no values.yaml in the embedded chart files", presetName) + + //nolint:wrapcheck // cockroachdb/errors.WithHint at boundary. + return errors.WithHint( + errors.Newf("--image was set but preset %q has no values.yaml in the embedded chart files", presetName), + "this looks like a build-time issue with the embedded chart files; rebuild talm or pick a different preset", + ) } -// askUserOverwrite asks user if they want to overwrite a file +// askUserOverwrite asks user if they want to overwrite a file. func askUserOverwrite(filePath string) (bool, error) { // Show relative path from project root relPath, err := filepath.Rel(Config.RootDir, filePath) @@ -712,16 +870,20 @@ func askUserOverwrite(filePath string) (bool, error) { } reader := bufio.NewReader(os.Stdin) + fmt.Fprintf(os.Stderr, "File %s differs from template. Overwrite? [y/N]: ", relPath) + response, err := reader.ReadString('\n') if err != nil { - return false, err + return false, errors.Wrap(err, "reading interactive overwrite confirmation") } + response = strings.TrimSpace(strings.ToLower(response)) + return response == "y" || response == "yes", nil } -// filesDiffer checks if two files have different content +// filesDiffer checks if two files have different content. func filesDiffer(filePath string, newContent []byte) (bool, error) { existingContent, err := os.ReadFile(filePath) if err != nil { @@ -729,12 +891,14 @@ func filesDiffer(filePath string, newContent []byte) (bool, error) { if os.IsNotExist(err) { return true, nil } - return false, err + + return false, errors.Wrapf(err, "reading %s for diff", filePath) } - return string(existingContent) != string(newContent), nil + + return !bytes.Equal(existingContent, newContent), nil } -// updateFileWithConfirmation updates a file if it differs, asking user for confirmation +// updateFileWithConfirmation updates a file if it differs, asking user for confirmation. func updateFileWithConfirmation(filePath string, newContent []byte, permissions os.FileMode) error { // Check if file exists exists := fileExists(filePath) @@ -743,17 +907,21 @@ func updateFileWithConfirmation(filePath string, newContent []byte, permissions // File doesn't exist, create it without asking parentDir := filepath.Dir(filePath) if err := os.MkdirAll(parentDir, os.ModePerm); err != nil { - return fmt.Errorf("failed to create output dir: %w", err) + return errors.Wrap(err, "failed to create output dir") } + if err := os.WriteFile(filePath, newContent, permissions); err != nil { - return fmt.Errorf("failed to write file: %w", err) + return errors.Wrap(err, "failed to write file") } + // Show relative path from project root relPath, err := filepath.Rel(Config.RootDir, filePath) if err != nil { relPath = filePath } - fmt.Fprintf(os.Stderr, "Created %s\n", relPath) + + fmt.Fprintf(os.Stderr, "%s %s\n", reportVerbCreated, relPath) + return nil } @@ -771,22 +939,23 @@ func updateFileWithConfirmation(filePath string, newContent []byte, permissions // File differs, ask user overwrite, err := askUserOverwrite(filePath) if err != nil { - return fmt.Errorf("failed to read user input: %w", err) + return errors.Wrap(err, "failed to read user input") } if !overwrite { fmt.Fprintf(os.Stderr, "Skipping %s\n", filePath) + return nil } // Write file parentDir := filepath.Dir(filePath) if err := os.MkdirAll(parentDir, os.ModePerm); err != nil { - return fmt.Errorf("failed to create output dir: %w", err) + return errors.Wrap(err, "failed to create output dir") } if err := os.WriteFile(filePath, newContent, permissions); err != nil { - return fmt.Errorf("failed to write file: %w", err) + return errors.Wrap(err, "failed to write file") } // Show relative path from project root @@ -794,17 +963,32 @@ func updateFileWithConfirmation(filePath string, newContent []byte, permissions if err != nil { relPath = filePath } - fmt.Fprintf(os.Stderr, "Updated %s\n", relPath) + + fmt.Fprintf(os.Stderr, "%s %s\n", reportVerbUpdated, relPath) + return nil } +// updateTalmLibraryChart implements `talm init --update`: it refreshes +// the bundled talm library chart and (optionally) the preset template +// files in an existing project, leaving secrets and operator-edited +// values intact. The function is a flat dispatcher over presetFiles +// entries; splitting it apart at every nestif layer would scatter the +// per-file dispatch across helpers without making any single branch +// easier to follow. +// +//nolint:gocognit,gocyclo,cyclop,nestif,funlen // see doc above func updateTalmLibraryChart() error { // --image is only honored on initial init (it customizes the // preset's values.yaml at write time). Refusing it on --update // surfaces the no-op trap explicitly instead of letting the // user's flag silently disappear. if initCmdFlags.image != "" { - return fmt.Errorf("--image is honored on initial init only; for an existing project, edit the image field in values.yaml directly") + //nolint:wrapcheck // cockroachdb/errors.WithHint at boundary. + return errors.WithHint( + errors.New("--image is honored on initial init only; for an existing project, edit the image field in values.yaml directly"), + "drop --image and edit values.yaml manually if you need to change the installer image on an existing project", + ) } // Determine preset: use -p flag if provided, otherwise try to read from Chart.yaml @@ -816,78 +1000,101 @@ func updateTalmLibraryChart() error { // Validate preset availablePresets, err := generated.AvailablePresets() if err != nil { - return fmt.Errorf("failed to get available presets: %w", err) + return errors.Wrap(err, "failed to get available presets") } + if !isValidPreset(presetName, availablePresets) { - return fmt.Errorf("invalid preset: %s. Valid presets are: %v", presetName, availablePresets) + //nolint:wrapcheck // cockroachdb/errors.WithHint at boundary. + return errors.WithHint( + errors.Newf("invalid preset: %s. Valid presets are: %v", presetName, availablePresets), + "pick one of the listed presets and pass it via --preset", + ) } } else { // Try to read from Chart.yaml var err error + presetName, err = readChartYamlPreset() if err != nil { - return fmt.Errorf("preset is required: use --preset flag or ensure Chart.yaml has a preset dependency: %w", err) + return errors.Wrap(err, "preset is required: use --preset flag or ensure Chart.yaml has a preset dependency") } } presetFiles, err := generated.PresetFiles() if err != nil { - return fmt.Errorf("failed to get preset files: %w", err) + return errors.Wrap(err, "failed to get preset files") } // Step 1: Update talm library chart files (without interactive confirmation) fmt.Fprintf(os.Stderr, "Updating talm library chart...\n") + for path, content := range presetFiles { parts := strings.SplitN(path, "/", 2) + chartName := parts[0] - if chartName == "talm" { + if chartName == presetTalmLibrary { file := filepath.Join(Config.RootDir, filepath.Join("charts", path)) + var fileContent []byte - if parts[len(parts)-1] == "Chart.yaml" { - fileContent = fmt.Appendf(nil, content, "talm", Config.InitOptions.Version) + if parts[len(parts)-1] == chartYamlName { + fileContent = fmt.Appendf(nil, content, presetTalmLibrary, Config.InitOptions.Version) } else { fileContent = []byte(content) } + // For talm library, always update without asking parentDir := filepath.Dir(file) if err := os.MkdirAll(parentDir, os.ModePerm); err != nil { - return fmt.Errorf("failed to create output dir: %w", err) + return errors.Wrap(err, "failed to create output dir") } - if err := os.WriteFile(file, fileContent, 0o644); err != nil { - return fmt.Errorf("failed to write file: %w", err) + + // Library chart files are public (Chart.yaml, helpers, + // templates) — 0o644 is the documented Helm convention, + // not a secret leak. + if err := os.WriteFile(file, fileContent, presetFileMode); err != nil { + return errors.Wrap(err, "failed to write file") } + relPath, _ := filepath.Rel(Config.RootDir, file) - fmt.Fprintf(os.Stderr, "Updated %s\n", relPath) + fmt.Fprintf(os.Stderr, "%s %s\n", reportVerbUpdated, relPath) } } // Step 2: Update preset template files (with interactive confirmation) if presetName != "" { fmt.Fprintf(os.Stderr, "Updating preset templates...\n") + for path, content := range presetFiles { parts := strings.SplitN(path, "/", 2) + chartName := parts[0] if chartName == presetName { file := filepath.Join(Config.RootDir, filepath.Join(parts[1:]...)) + var fileContent []byte - if parts[len(parts)-1] == "Chart.yaml" { + + if parts[len(parts)-1] == chartYamlName { // Read cluster name from existing Chart.yaml - existingChartPath := filepath.Join(Config.RootDir, "Chart.yaml") + existingChartPath := filepath.Join(Config.RootDir, chartYamlName) + existingData, err := os.ReadFile(existingChartPath) if err != nil { - return fmt.Errorf("failed to read existing Chart.yaml: %w", err) + return errors.Wrap(err, "failed to read existing Chart.yaml") } + var existingChart struct { Name string `yaml:"name"` } if err := yaml.Unmarshal(existingData, &existingChart); err != nil { - return fmt.Errorf("failed to parse existing Chart.yaml: %w", err) + return errors.Wrap(err, "failed to parse existing Chart.yaml") } + fileContent = fmt.Appendf(nil, content, existingChart.Name, Config.InitOptions.Version) } else { fileContent = []byte(content) } - if err := updateFileWithConfirmation(file, fileContent, 0o644); err != nil { + + if err := updateFileWithConfirmation(file, fileContent, presetFileMode); err != nil { return err } } @@ -921,20 +1128,36 @@ func isValidPreset(preset string, availablePresets []string) bool { func validateFileExists(file string) error { if !initCmdFlags.force { if _, err := os.Stat(file); err == nil { - return fmt.Errorf("file %q already exists, use --force to overwrite, and --update to update Talm library chart only", file) + //nolint:wrapcheck // cockroachdb/errors.WithHint at boundary. + return errors.WithHint( + errors.Newf("file %q already exists, use --force to overwrite, and --update to update Talm library chart only", file), + "rerun with --force to overwrite, --update to refresh only the talm library chart, or remove the file manually", + ) } } return nil } +// gitignoreEntryCount is the size of the requiredEntries slice in +// writeGitignoreFile: three secret-bearing artefacts plus the +// kubeconfig base name. Hoisting it into a const sidesteps mnd's +// magic-number lint without inlining the comment at every call site. +const gitignoreEntryCount = 4 + +//nolint:funlen // wrapping the secrets-list assembly in helpers buys nothing in clarity func writeGitignoreFile() error { - requiredEntries := []string{"secrets.yaml", "talosconfig", "talm.key"} + // Capacity gitignoreEntryCount: three secret-bearing artefacts + // (secrets.yaml, talosconfig, talm.key) plus the kubeconfig base + // name appended just below. Preallocating avoids the slice growth + // prealloc flags. + requiredEntries := make([]string, 0, gitignoreEntryCount) + requiredEntries = append(requiredEntries, secretsYamlName, talosconfigName, talmKeyName) // Add kubeconfig to required entries (use path from config or default) kubeconfigPath := Config.GlobalOptions.Kubeconfig if kubeconfigPath == "" { - kubeconfigPath = "kubeconfig" + kubeconfigPath = defaultKubeconfigName } // Only add base name (not full path) to gitignore kubeconfigBase := filepath.Base(kubeconfigPath) @@ -947,8 +1170,9 @@ func writeGitignoreFile() error { if _, err := os.Stat(gitignoreFile); err == nil { existingContent, err := os.ReadFile(gitignoreFile) if err != nil { - return fmt.Errorf("failed to read existing .gitignore: %w", err) + return errors.Wrap(err, "failed to read existing .gitignore") } + existingStr = string(existingContent) } else { existingStr = "# Sensitive files\n" @@ -956,21 +1180,27 @@ func writeGitignoreFile() error { // Check which entries are missing needsUpdate := false + for _, entry := range requiredEntries { // Check if entry exists (as whole line or with comment) lines := strings.Split(existingStr, "\n") + found := false + for _, line := range lines { line = strings.TrimSpace(line) if line == entry || strings.HasPrefix(line, entry+" ") || strings.HasPrefix(line, entry+"#") { found = true + break } } + if !found { if !strings.HasSuffix(existingStr, "\n") { existingStr += "\n" } + existingStr += entry + "\n" needsUpdate = true } @@ -984,24 +1214,56 @@ func writeGitignoreFile() error { // Write without validation (allow overwrite for .gitignore) parentDir := filepath.Dir(gitignoreFile) if err := os.MkdirAll(parentDir, os.ModePerm); err != nil { - return fmt.Errorf("failed to create output dir: %w", err) + return errors.Wrap(err, "failed to create output dir") } - err := os.WriteFile(gitignoreFile, []byte(existingStr), 0o644) - if _, statErr := os.Stat(gitignoreFile); statErr == nil { - fmt.Fprintf(os.Stderr, "Updated %s\n", gitignoreFile) - } else { - fmt.Fprintf(os.Stderr, "Created %s\n", gitignoreFile) + // Capture existence BEFORE the write so we can distinguish "created" + // from "updated" in the operator-facing log line. Doing the stat + // after WriteFile would always succeed (the file exists post-write) + // and the message would be wrong on a fresh init — we'd report + // "Updated" for a file we just created. + // + // Branch on os.IsNotExist explicitly so that ambiguous stat + // failures (e.g. EACCES on the parent directory, ENOTDIR mid-path) + // fall into the same bucket as "exists" — the file may already be + // there, we just can't see it. Reporting "Created" for an + // inscrutable stat error would be a lie when the WriteFile + // succeeded only because the operator separately fixed the + // permission. The "Updated" wording is correct in the ambiguous + // case because it does not falsely promise the absence we never + // confirmed. + _, statErrBefore := os.Stat(gitignoreFile) + + // .gitignore is checked into the repo and read by every developer + // who clones the project — 0o644 is the standard, not a leak. + err := os.WriteFile(gitignoreFile, []byte(existingStr), presetFileMode) //nolint:gosec // .gitignore is world-readable by design + if err == nil { + fmt.Fprintf(os.Stderr, "%s %s\n", gitignoreReportVerb(statErrBefore), gitignoreFile) + } + + return errors.Wrap(err, "writing .gitignore") +} + +// gitignoreReportVerb returns the operator-facing verb for the +// .gitignore write report. The branch is hoisted out of +// writeGitignoreFile so the IsNotExist contract is unit-testable +// without an os.Stat fault injection — see +// TestGitignoreReportVerb_*. +func gitignoreReportVerb(statErrBefore error) string { + if os.IsNotExist(statErrBefore) { + return reportVerbCreated } - return err + + return reportVerbUpdated } func fileExists(file string) bool { _, err := os.Stat(file) + return err == nil } func printSecretsWarning() { - keyFile := filepath.Join(Config.RootDir, "talm.key") + keyFile := filepath.Join(Config.RootDir, talmKeyName) keyFileExists := fileExists(keyFile) if !keyFileExists { @@ -1030,16 +1292,25 @@ func printSecretsWarning() { fmt.Fprintf(os.Stderr, "\n") } -// handleTalosconfigEncryption handles encryption/decryption logic for talosconfig file. -// It decrypts if encrypted file exists, encrypts if plain file exists. -// requireKeyForDecrypt: if true, returns error if key is missing when trying to decrypt. -// Returns true if key was created during this call, false otherwise. +// handleTalosconfigEncryption handles encryption/decryption logic for +// talosconfig file. It decrypts if encrypted file exists, encrypts if +// plain file exists. requireKeyForDecrypt: if true, returns error if +// key is missing when trying to decrypt. Returns true if key was +// created during this call, false otherwise. +// +// requireKeyForDecrypt always receives false today, but the parameter +// pins the intended contract for the planned init-time branch that +// requires the key for an existing encrypted talosconfig. nestif fires +// on the well-isolated decrypt/encrypt pair; splitting it into helpers +// wouldn't make either branch easier to follow. +// +//nolint:unparam,nestif // see doc above func handleTalosconfigEncryption(requireKeyForDecrypt bool) (bool, error) { talosconfigFile := filepath.Join(Config.RootDir, "talosconfig") encryptedTalosconfigFile := filepath.Join(Config.RootDir, "talosconfig.encrypted") talosconfigFileExists := fileExists(talosconfigFile) encryptedTalosconfigFileExists := fileExists(encryptedTalosconfigFile) - keyFile := filepath.Join(Config.RootDir, "talm.key") + keyFile := filepath.Join(Config.RootDir, talmKeyName) keyFileExists := fileExists(keyFile) keyWasCreated := false @@ -1047,15 +1318,22 @@ func handleTalosconfigEncryption(requireKeyForDecrypt bool) (bool, error) { if encryptedTalosconfigFileExists && !talosconfigFileExists { if !keyFileExists { if requireKeyForDecrypt { - return false, fmt.Errorf("talosconfig.encrypted exists but talm.key is missing. Cannot decrypt without key") + //nolint:wrapcheck // cockroachdb/errors.WithHint at boundary. + return false, errors.WithHint( + errors.New("talosconfig.encrypted exists but talm.key is missing. Cannot decrypt without key"), + "restore talm.key from your backup before re-running this command", + ) } // If key is not required, just return (don't decrypt) return false, nil } + fmt.Fprintf(os.Stderr, "Decrypting talosconfig.encrypted -> talosconfig\n") + if err := age.DecryptYAMLFile(Config.RootDir, "talosconfig.encrypted", "talosconfig"); err != nil { - return false, fmt.Errorf("failed to decrypt talosconfig: %w", err) + return false, errors.Wrap(err, "failed to decrypt talosconfig") } + talosconfigFileExists = true } @@ -1065,8 +1343,9 @@ func handleTalosconfigEncryption(requireKeyForDecrypt bool) (bool, error) { if !keyFileExists { _, keyCreated, err := age.GenerateKey(Config.RootDir) if err != nil { - return false, fmt.Errorf("failed to generate key: %w", err) + return false, errors.Wrap(err, "failed to generate key") } + keyWasCreated = keyCreated if keyCreated { fmt.Fprintf(os.Stderr, "Generated new encryption key: talm.key\n") @@ -1075,8 +1354,9 @@ func handleTalosconfigEncryption(requireKeyForDecrypt bool) (bool, error) { // Encrypt talosconfig fmt.Fprintf(os.Stderr, "Encrypting talosconfig -> talosconfig.encrypted\n") + if err := age.EncryptYAMLFile(Config.RootDir, "talosconfig", "talosconfig.encrypted"); err != nil { - return false, fmt.Errorf("failed to encrypt talosconfig: %w", err) + return false, errors.Wrap(err, "failed to encrypt talosconfig") } } @@ -1085,8 +1365,17 @@ func handleTalosconfigEncryption(requireKeyForDecrypt bool) (bool, error) { // createdSink is where "Created " messages go after a successful // write. Swappable in tests to assert no message is emitted on failure. +// +//nolint:gochecknoglobals // test seam: tests swap this to capture output without spinning up a fake fd var createdSink io.Writer = os.Stderr +// writeToDestination writes a chart artefact (Chart.yaml, values.yaml, +// helpers, templates) to destination with the supplied permissions. +// permissions is always presetFileMode today, but the parameter pins +// the signature for the planned per-file mode dispatch (e.g. +// owner-only modes for embedded scripts). +// +//nolint:unparam // see doc above func writeToDestination(data []byte, destination string, permissions os.FileMode) error { if err := validateFileExists(destination); err != nil { return err @@ -1096,14 +1385,17 @@ func writeToDestination(data []byte, destination string, permissions os.FileMode // Create dir path, ignoring "already exists" messages if err := os.MkdirAll(parentDir, os.ModePerm); err != nil { - return fmt.Errorf("failed to create output dir: %w", err) + return errors.Wrap(err, "failed to create output dir") } + // Permissions are caller-supplied; chart artefacts use + // presetFileMode (0o644) by design — they are world-readable. err := os.WriteFile(destination, data, permissions) if err == nil { - _, _ = fmt.Fprintf(createdSink, "Created %s\n", destination) + _, _ = fmt.Fprintf(createdSink, "%s %s\n", reportVerbCreated, destination) } - return err + + return errors.Wrapf(err, "writing %s", destination) } // writeSecureToDestination writes a secret (talosconfig, secrets.yaml, @@ -1120,13 +1412,14 @@ func writeSecureToDestination(data []byte, destination string) error { // Use 0o700 so any newly-created parent dir for secrets is owner-only // even under a permissive umask. MkdirAll is a no-op when the dir // already exists, so this does not override pre-existing dir perms. - if err := os.MkdirAll(parentDir, 0o700); err != nil { - return fmt.Errorf("failed to create output dir: %w", err) + if err := os.MkdirAll(parentDir, secureDirMode); err != nil { + return errors.Wrap(err, "failed to create output dir") } err := secureperm.WriteFile(destination, data) if err == nil { - _, _ = fmt.Fprintf(createdSink, "Created %s\n", destination) + _, _ = fmt.Fprintf(createdSink, "%s %s\n", reportVerbCreated, destination) } - return err + + return errors.Wrapf(err, "writing secret %s", destination) } diff --git a/pkg/commands/kubeconfig_handler.go b/pkg/commands/kubeconfig_handler.go index 297b0679..e3c730f0 100644 --- a/pkg/commands/kubeconfig_handler.go +++ b/pkg/commands/kubeconfig_handler.go @@ -19,18 +19,21 @@ import ( "os" "path/filepath" + "github.com/cockroachdb/errors" "github.com/cozystack/talm/pkg/age" "github.com/cozystack/talm/pkg/secureperm" "github.com/spf13/cobra" ) -// wrapKubeconfigCommand adds special handling for kubeconfig command +// wrapKubeconfigCommand adds special handling for kubeconfig command. +// +//nolint:gocognit,gocyclo,cyclop,funlen,nestif // single linear cobra-command wrapper that branches over (--login present? args expected? path inside project? encrypted variant exists?); each branch is ~5 lines, splitting them would scatter the dispatch contract documented in CLAUDE.md. func wrapKubeconfigCommand(wrappedCmd *cobra.Command, originalRunE func(*cobra.Command, []string) error) { // Add --login flag to update system kubeconfig file instead of local one wrappedCmd.Flags().BoolP("login", "l", false, "update system kubeconfig file, not local one") // Fix help text for unused arg [local-path] from talosctl kubeconfig command - wrappedCmd.Use = "kubeconfig" + wrappedCmd.Use = defaultKubeconfigName wrappedCmd.Long = `Download the admin kubeconfig from the node. If merge flag is true, config will be merged with ~/.kube/config. Otherwise, kubeconfig will be written to PWD.` @@ -46,9 +49,11 @@ Otherwise, kubeconfig will be written to PWD.` // Check if --login flag is set loginFlagValue, _ := cmd.Flags().GetBool("login") - - var newArgs []string - var kubeconfigPath string + + var ( + newArgs []string + kubeconfigPath string + ) // If --login flag is set, use original args without auto-substitution if loginFlagValue { @@ -60,15 +65,15 @@ Otherwise, kubeconfig will be written to PWD.` } else { kubeconfigPath = Config.GlobalOptions.Kubeconfig if kubeconfigPath == "" { - kubeconfigPath = "kubeconfig" + kubeconfigPath = defaultKubeconfigName } } } else { // Always use kubeconfig path from Chart.yaml globalOptions kubeconfigPath = Config.GlobalOptions.Kubeconfig if kubeconfigPath == "" { - // Default to "kubeconfig" if not specified in Chart.yaml - kubeconfigPath = "kubeconfig" + // Default to defaultKubeconfigName if not specified in Chart.yaml + kubeconfigPath = defaultKubeconfigName } // Resolve to absolute path relative to project root if !filepath.IsAbs(kubeconfigPath) { @@ -89,15 +94,18 @@ Otherwise, kubeconfig will be written to PWD.` // After command execution, set secure permissions and check if kubeconfig path is in project root // Resolve to absolute path - var absPath string - var err error + var ( + absPath string + err error + ) if !filepath.IsAbs(kubeconfigPath) { absPath, err = filepath.Abs(filepath.Join(Config.RootDir, kubeconfigPath)) } else { absPath, err = filepath.Abs(kubeconfigPath) } + if err != nil { - return fmt.Errorf("failed to resolve kubeconfig path: %w", err) + return errors.Wrap(err, "failed to resolve kubeconfig path") } // Set secure permissions (600) on kubeconfig file. On Windows @@ -113,8 +121,8 @@ Otherwise, kubeconfig will be written to PWD.` if err == nil && !isOutsideRoot(relPath) { // Path is within project root, add to .gitignore fileName := filepath.Base(kubeconfigPath) - if fileName == "kubeconfig" { - if err := addToGitignore("kubeconfig"); err != nil { + if fileName == defaultKubeconfigName { + if err := addToGitignore(defaultKubeconfigName); err != nil { // Don't fail the command if gitignore update fails fmt.Fprintf(os.Stderr, "Warning: failed to update .gitignore: %v\n", err) } @@ -166,9 +174,9 @@ Otherwise, kubeconfig will be written to PWD.` wrappedCmd.Args = func(cmd *cobra.Command, args []string) error { loginFlagValue, _ := cmd.Flags().GetBool("login") if !loginFlagValue && len(args) > 0 { - return fmt.Errorf("kubeconfig command does not accept arguments (use --login flag to pass arguments)") + return errors.New("kubeconfig command does not accept arguments (use --login flag to pass arguments)") } + return nil } } - diff --git a/pkg/commands/preflight.go b/pkg/commands/preflight.go index da871563..33585f3e 100644 --- a/pkg/commands/preflight.go +++ b/pkg/commands/preflight.go @@ -24,7 +24,6 @@ import ( "github.com/cockroachdb/errors" "github.com/cosi-project/runtime/pkg/resource" "github.com/cosi-project/runtime/pkg/safe" - "github.com/siderolabs/talos/pkg/machinery/client" machineryconfig "github.com/siderolabs/talos/pkg/machinery/config" "github.com/siderolabs/talos/pkg/machinery/resources/runtime" @@ -65,6 +64,7 @@ func annotateApplyConfigError(err error) error { return err } + //nolint:wrapcheck // cockroachdb/errors.WithHint at boundary. return errors.WithHint(err, applyConfigDecodeHint) } @@ -93,6 +93,7 @@ func cosiVersionReader(c *client.Client) versionReader { if err != nil { return "", false } + return res.TypedSpec().Version, true } } @@ -135,27 +136,31 @@ func preflightCheckTalosVersion(ctx context.Context, read versionReader, configu // WithVersionContract option is supplied. func evaluateVersionMismatch(configuredVersion, runningVersion string) error { var configuredContract *machineryconfig.VersionContract + if configuredVersion != "" { var err error + configuredContract, err = machineryconfig.ParseContractFromVersion(configuredVersion) if err != nil { - return nil + return nil //nolint:nilerr // best-effort: never block apply on parse failure } } runningContract, err := machineryconfig.ParseContractFromVersion(runningVersion) if err != nil { - return nil + return nil //nolint:nilerr // best-effort: never block apply on parse failure } if !configuredContract.Greater(runningContract) { return nil } - warning := fmt.Errorf( + warning := errors.Newf( "pre-flight: configured talosVersion=%s is newer than the node's running Talos %s", configuredContract, runningVersion, ) + + //nolint:wrapcheck // cockroachdb/errors.WithHint at boundary. return errors.WithHint(warning, preflightVersionMismatchHint) } diff --git a/pkg/commands/preflight_test.go b/pkg/commands/preflight_test.go index a42f7529..0ce9b2a2 100644 --- a/pkg/commands/preflight_test.go +++ b/pkg/commands/preflight_test.go @@ -24,6 +24,16 @@ import ( ) func TestEvaluateVersionMismatch(t *testing.T) { + const ( + // versionCurrentSubstring is the rendering of machinery's + // TalosVersionCurrent (nil contract); the warning embeds it + // when no configured version is supplied. + versionCurrentSubstring = "current" + // unparseableVersion is a sentinel that + // machineryconfig.ParseContractFromVersion rejects. + unparseableVersion = "garbage" + ) + tests := []struct { name string configured string @@ -36,11 +46,12 @@ func TestEvaluateVersionMismatch(t *testing.T) { {"configured older than running", "v1.10", "v1.12.6", false, ""}, {"configured equal exact", "v1.11", "v1.11.0", false, ""}, // Empty configured = TalosVersionCurrent (nil contract = newest); the warning - // must fire because that's the documented reproduction case in cozystack/talm#132. - {"configured empty means current and warns", "", "v1.11.6", true, "current"}, - {"configured empty equal-modern still warns", "", "v1.12.6", true, "current"}, - {"running unparseable", "v1.12", "garbage", false, ""}, - {"configured unparseable", "garbage", "v1.12.6", false, ""}, + // must fire because that's the documented reproduction case for an unset + // talosVersion against an older node. + {"configured empty means current and warns", "", "v1.11.6", true, versionCurrentSubstring}, + {"configured empty equal-modern still warns", "", "v1.12.6", true, versionCurrentSubstring}, + {"running unparseable", "v1.12", unparseableVersion, false, ""}, + {"configured unparseable", unparseableVersion, "v1.12.6", false, ""}, {"both empty silent", "", "", false, ""}, } @@ -78,7 +89,7 @@ func TestAnnotateApplyConfigError(t *testing.T) { }, { "strict decoder error", - errors.New("rpc error: code = Unknown desc = failed to parse config: unknown keys found during decoding:\nmachine:\n install:\n grubUseUKICmdline: true\n"), + errors.New("rpc error: code = Unknown desc = failed to parse config: unknown keys found during decoding:\nmachine:\n install:\n grubUseUKICmdline: true"), true, }, } diff --git a/pkg/commands/root.go b/pkg/commands/root.go index c5c68716..49305cb3 100644 --- a/pkg/commands/root.go +++ b/pkg/commands/root.go @@ -16,12 +16,11 @@ package commands import ( "context" - "errors" - "fmt" "os" "path/filepath" "time" + "github.com/cockroachdb/errors" "github.com/cozystack/talm/pkg/modeline" "github.com/spf13/cobra" "google.golang.org/grpc" @@ -31,12 +30,19 @@ import ( ) // GlobalArgs is the common arguments for the root command. +// +//nolint:gochecknoglobals // cobra CLI architecture: persistent flags bind to package-level state shared across all subcommands; refactoring out the global would require threading state through every command's RunE. var GlobalArgs global.Args +// Config is the package-level configuration populated from Chart.yaml and +// CLI persistent flags. Mirrors GlobalArgs for project-root-relative path +// resolution across every subcommand. +// +//nolint:gochecknoglobals // cobra CLI architecture: persistent flags bind to package-level config; mirrors GlobalArgs and is read by every subcommand for project-root-relative path resolution. var Config struct { - RootDir string + RootDir string RootDirExplicit bool // true if --root was explicitly set - GlobalOptions struct { + GlobalOptions struct { Talosconfig string `yaml:"talosconfig"` Kubeconfig string `yaml:"kubeconfig"` } `yaml:"globalOptions"` @@ -46,7 +52,7 @@ var Config struct { Values []string `yaml:"values"` StringValues []string `yaml:"stringValues"` FileValues []string `yaml:"fileValues"` - JsonValues []string `yaml:"jsonValues"` + JsonValues []string `yaml:"jsonValues"` //nolint:revive // public field name kept for backwards compatibility with existing consumers in template.go and pkg/engine LiteralValues []string `yaml:"literalValues"` TalosVersion string `yaml:"talosVersion"` WithSecrets string `yaml:"withSecrets"` @@ -74,6 +80,7 @@ var Config struct { // // WithClientNoNodes doesn't set any node information on the request context. func WithClientNoNodes(action func(context.Context, *client.Client) error, dialOptions ...grpc.DialOption) error { + //nolint:wrapcheck // thin pass-through to talos global.Args; error already carries Talos context return GlobalArgs.WithClientNoNodes(action, dialOptions...) } @@ -84,7 +91,10 @@ func WithClient(action func(context.Context, *client.Client) error, dialOptions if len(GlobalArgs.Nodes) < 1 { configContext := cli.GetConfigContext() if configContext == nil { - return errors.New("failed to resolve config context") + return errors.WithHint( + errors.New("failed to resolve config context"), + "verify ~/.talos/config or pass --talosconfig pointing at a valid file", + ) } GlobalArgs.Nodes = configContext.Nodes @@ -96,11 +106,11 @@ func WithClient(action func(context.Context, *client.Client) error, dialOptions }, dialOptions..., ) - } // WithClientMaintenance wraps common code to initialize Talos client in maintenance (insecure mode). func WithClientMaintenance(enforceFingerprints []string, action func(context.Context, *client.Client) error) error { + //nolint:wrapcheck // thin pass-through to talos global.Args; error already carries Talos context return GlobalArgs.WithClientMaintenance(enforceFingerprints, action) } @@ -108,6 +118,7 @@ func WithClientMaintenance(enforceFingerprints []string, action func(context.Con // but with client certificate authentication preserved. // This is useful when connecting to nodes via IP addresses not listed in the server certificate's SANs. func WithClientSkipVerify(action func(context.Context, *client.Client) error) error { + //nolint:wrapcheck // thin pass-through to talos global.Args; error already carries Talos context return GlobalArgs.WithClientSkipVerify(action) } @@ -117,10 +128,13 @@ func WithClientAuto(action func(context.Context, *client.Client) error) error { if GlobalArgs.SkipVerify { return WithClientSkipVerify(action) } + return WithClientNoNodes(action) } // Commands is a list of commands published by the package. +// +//nolint:gochecknoglobals // command registry: each subcommand's init() registers itself via addCommand(); main.go iterates the slice to attach all commands to the root cobra command. var Commands []*cobra.Command func addCommand(cmd *cobra.Command) { @@ -133,12 +147,12 @@ func addCommand(cmd *cobra.Command) { func DetectProjectRoot(startDir string) (string, error) { absStartDir, err := filepath.Abs(startDir) if err != nil { - return "", fmt.Errorf("failed to get absolute path: %w", err) + return "", errors.Wrap(err, "failed to get absolute path") } currentDir := absStartDir for { - chartYaml := filepath.Join(currentDir, "Chart.yaml") + chartYaml := filepath.Join(currentDir, chartYamlName) secretsYaml := filepath.Join(currentDir, "secrets.yaml") secretsEncryptedYaml := filepath.Join(currentDir, "secrets.encrypted.yaml") @@ -148,9 +162,11 @@ func DetectProjectRoot(startDir string) (string, error) { if _, err := os.Stat(chartYaml); err == nil { chartExists = true } + if _, err := os.Stat(secretsYaml); err == nil { secretsExists = true } + if _, err := os.Stat(secretsEncryptedYaml); err == nil { secretsExists = true } @@ -164,6 +180,7 @@ func DetectProjectRoot(startDir string) (string, error) { // Reached filesystem root break } + currentDir = parentDir } @@ -175,11 +192,12 @@ func DetectProjectRoot(startDir string) (string, error) { func DetectProjectRootForFile(filePath string) (string, error) { absFilePath, err := filepath.Abs(filePath) if err != nil { - return "", fmt.Errorf("failed to get absolute path: %w", err) + return "", errors.Wrap(err, "failed to get absolute path") } // Get directory containing the file fileDir := filepath.Dir(absFilePath) + return DetectProjectRoot(fileDir) } @@ -191,22 +209,32 @@ func ValidateAndDetectRootsForFiles(filePaths []string) (string, error) { } var commonRoot string + roots := make(map[string]bool) for _, filePath := range filePaths { fileRoot, err := DetectProjectRootForFile(filePath) if err != nil { - return "", fmt.Errorf("failed to detect root for file %s: %w", filePath, err) + return "", errors.Wrapf(err, "failed to detect root for file %s", filePath) } + if fileRoot == "" { - return "", fmt.Errorf("failed to detect project root for file %s (Chart.yaml and secrets.yaml not found)", filePath) + //nolint:wrapcheck // cockroachdb/errors.WithHint at boundary. + return "", errors.WithHint( + errors.Newf("failed to detect project root for file %s (Chart.yaml and secrets.yaml not found)", filePath), + "run `talm init` at the project root, or move the file under an existing talm project", + ) } roots[fileRoot] = true if commonRoot == "" { commonRoot = fileRoot } else if commonRoot != fileRoot { - return "", fmt.Errorf("files belong to different project roots: %s and %s", commonRoot, fileRoot) + //nolint:wrapcheck // cockroachdb/errors.WithHint at boundary. + return "", errors.WithHint( + errors.Newf("files belong to different project roots: %s and %s", commonRoot, fileRoot), + "run the command separately for each project, or pass files from a single project root", + ) } } @@ -219,37 +247,53 @@ func DetectRootForTemplate(templatePath string) (string, error) { return DetectProjectRootForFile(templatePath) } -func processModelineAndUpdateGlobals(configFile string, nodesFromArgs bool, endpointsFromArgs bool, overwrite bool) ([]string, error) { +func processModelineAndUpdateGlobals(configFile string, nodesFromArgs, endpointsFromArgs, overwrite bool) ([]string, error) { modelineConfig, err := modeline.ReadAndParseModeline(configFile) if err != nil { - fmt.Printf("Warning: modeline parsing failed: %v\n", err) - return nil, err + // Don't print the error here — cobra surfaces the wrapped + // return through stderr at the command level. Printing here + // AND returning the wrap caused the same message to appear + // twice with a misleading "Warning:" prefix on the first copy. + return nil, errors.Wrapf(err, "parsing modeline in %s", configFile) } - var templates []string + templates := updateGlobalsFromModeline(modelineConfig, nodesFromArgs, endpointsFromArgs, overwrite) - // Update global settings if modeline was successfully parsed - if modelineConfig != nil { - if !nodesFromArgs && len(modelineConfig.Nodes) > 0 { - if overwrite { - GlobalArgs.Nodes = modelineConfig.Nodes - } else { - GlobalArgs.Nodes = append(GlobalArgs.Nodes, modelineConfig.Nodes...) - } - } - if !endpointsFromArgs && len(modelineConfig.Endpoints) > 0 { - if overwrite { - GlobalArgs.Endpoints = modelineConfig.Endpoints - } else { - GlobalArgs.Endpoints = append(GlobalArgs.Endpoints, modelineConfig.Endpoints...) - } + if len(GlobalArgs.Nodes) < 1 { + //nolint:wrapcheck // cockroachdb/errors.WithHint at boundary. + return nil, errors.WithHint( + errors.New("nodes are not set for the command"), + "use --nodes flag or configuration file to set the nodes to run the command against", + ) + } + + return templates, nil +} + +// updateGlobalsFromModeline merges modeline-supplied nodes/endpoints into +// GlobalArgs and returns the templates list. Split out of +// processModelineAndUpdateGlobals to flatten the surrounding nestif and +// keep modeline-merge logic isolated from validation. +func updateGlobalsFromModeline(modelineConfig *modeline.Config, nodesFromArgs, endpointsFromArgs, overwrite bool) []string { + if modelineConfig == nil { + return nil + } + + if !nodesFromArgs && len(modelineConfig.Nodes) > 0 { + if overwrite { + GlobalArgs.Nodes = modelineConfig.Nodes + } else { + GlobalArgs.Nodes = append(GlobalArgs.Nodes, modelineConfig.Nodes...) } - templates = modelineConfig.Templates } - if len(GlobalArgs.Nodes) < 1 { - return nil, errors.New("nodes are not set for the command: please use `--nodes` flag or configuration file to set the nodes to run the command against") + if !endpointsFromArgs && len(modelineConfig.Endpoints) > 0 { + if overwrite { + GlobalArgs.Endpoints = modelineConfig.Endpoints + } else { + GlobalArgs.Endpoints = append(GlobalArgs.Endpoints, modelineConfig.Endpoints...) + } } - return templates, nil + return modelineConfig.Templates } diff --git a/pkg/commands/root_detection.go b/pkg/commands/root_detection.go index b6ed6a49..76a02e35 100644 --- a/pkg/commands/root_detection.go +++ b/pkg/commands/root_detection.go @@ -15,43 +15,60 @@ package commands import ( - "fmt" "os" "path/filepath" "strings" + "github.com/cockroachdb/errors" "github.com/spf13/cobra" ) +// localSecretsYamlName is the on-disk name of the plaintext secrets file +// used by ResolveSecretsPath when the operator did not pass --with-secrets. +// Defined as a file-local const so the goconst linter has a single +// canonical reference for the seven occurrences in this file alone. +const localSecretsYamlName = "secrets.yaml" + +// talosconfigFlagName is the persistent --talosconfig flag name and the +// default basename used by EnsureTalosconfigPath when neither the flag +// nor Chart.yaml's globalOptions.talosconfig declares a value. +const talosconfigFlagName = "talosconfig" + // parseFlagFromArgs parses a flag value from command line arguments. // Supports both -flag value and -flag=value formats, as well as comma-separated values. func parseFlagFromArgs(args []string, shortFlag, longFlag string) []string { var values []string + for i, arg := range args { - if arg == shortFlag || arg == longFlag { - // Get the next argument(s) as value(s) + switch { + case arg == shortFlag || arg == longFlag: + // Get the next argument as a value if it isn't another flag. if i+1 < len(args) { nextArg := args[i+1] if !strings.HasPrefix(nextArg, "-") { values = parseCommaSeparatedValues(nextArg) } } - break - } else if strings.HasPrefix(arg, shortFlag+"=") || strings.HasPrefix(arg, longFlag+"=") { - // Handle -flag=value or --flag=value format + + return values + case strings.HasPrefix(arg, shortFlag+"=") || strings.HasPrefix(arg, longFlag+"="): + // Handle -flag=value or --flag=value form. parts := strings.SplitN(arg, "=", 2) if len(parts) == 2 { values = parseCommaSeparatedValues(parts[1]) } - break + + return values } } + return values } // parseCommaSeparatedValues parses comma-separated values and returns a slice of trimmed values. func parseCommaSeparatedValues(value string) []string { var values []string + if strings.Contains(value, ",") { parts := strings.SplitSeq(value, ",") for part := range parts { @@ -59,11 +76,10 @@ func parseCommaSeparatedValues(value string) []string { values = append(values, trimmed) } } - } else { - if trimmed := strings.TrimSpace(value); trimmed != "" { - values = append(values, trimmed) - } + } else if trimmed := strings.TrimSpace(value); trimmed != "" { + values = append(values, trimmed) } + return values } @@ -82,6 +98,7 @@ func getFlagValues(cmd *cobra.Command, flagName string) []string { return values } } + return []string{} } @@ -90,6 +107,7 @@ func detectRootFromFiles(filePaths []string) (string, error) { if len(filePaths) == 0 { return "", nil } + return ValidateAndDetectRootsForFiles(filePaths) } @@ -106,8 +124,9 @@ func detectRootFromTemplates(templatePaths []string) (string, error) { func detectRootFromCWD() (string, error) { currentDir, err := os.Getwd() if err != nil { - return "", fmt.Errorf("failed to get current working directory: %w", err) + return "", errors.Wrap(err, "failed to get current working directory") } + return DetectProjectRoot(currentDir) } @@ -116,11 +135,18 @@ func checkRootConflict(detectedRoot string, rootDirExplicit bool) error { if !rootDirExplicit { return nil } + absConfigRoot, _ := filepath.Abs(Config.RootDir) + absDetectedRoot, _ := filepath.Abs(detectedRoot) if absConfigRoot != absDetectedRoot { - return fmt.Errorf("conflicting project roots: global --root=%s, but detected root=%s", absConfigRoot, absDetectedRoot) + //nolint:wrapcheck // cockroachdb/errors.WithHint multi-return; ignore-sigs cover single-return only. + return errors.WithHint( + errors.Newf("conflicting project roots: global --root=%s, but detected root=%s", absConfigRoot, absDetectedRoot), + "drop --root or move the files so they live under the explicit root", + ) } + return nil } @@ -128,7 +154,11 @@ func checkRootConflict(detectedRoot string, rootDirExplicit bool) error { // 1. From -f/--file flag (if files specified) // 2. From -t/--template flag (if templates specified) // 3. From current working directory -func DetectAndSetRoot(cmd *cobra.Command, args []string) error { +// +// args is part of the cobra.PositionalArgs / PreRunE signature; the +// function does not consult positional arguments — root selection is +// driven entirely by --root, --file, --template, and the CWD walk-up. +func DetectAndSetRoot(cmd *cobra.Command, _ []string) error { // Check if --root was explicitly set. Use cmd.Flag(name).Changed // rather than cmd.PersistentFlags().Changed("root"): // PersistentFlags lists ONLY flags declared persistent on cmd @@ -145,35 +175,12 @@ func DetectAndSetRoot(cmd *cobra.Command, args []string) error { Config.RootDirExplicit = flag.Changed } - // Get file paths from -f/--file flag - configFiles := getFlagValues(cmd, "file") - if len(configFiles) == 0 { - // Parse from args if not found in flags - allArgs := os.Args[1:] - configFiles = parseFlagFromArgs(allArgs, "-f", "--file") - } - - // Get template paths from -t/--template flag - templateFiles := getFlagValues(cmd, "template") - if len(templateFiles) == 0 { - // Parse from args if not found in flags - allArgs := os.Args[1:] - templateFiles = parseFlagFromArgs(allArgs, "-t", "--template") - } + configFiles := lookupFileArg(cmd, "file", "-f", "--file") + templateFiles := lookupFileArg(cmd, "template", "-t", "--template") // Strategy 1: Detect root from files - if len(configFiles) > 0 { - detectedRoot, err := detectRootFromFiles(configFiles) - if err != nil { - return err - } - if detectedRoot != "" { - if err := checkRootConflict(detectedRoot, Config.RootDirExplicit); err != nil { - return err - } - Config.RootDir = detectedRoot - return nil - } + if applied, err := applyFileBasedRoot(configFiles); err != nil || applied { + return err } // Strategy 2: Detect root from templates (only if root not explicitly set) @@ -181,6 +188,7 @@ func DetectAndSetRoot(cmd *cobra.Command, args []string) error { detectedRoot, err := detectRootFromTemplates(templateFiles) if err == nil && detectedRoot != "" { Config.RootDir = detectedRoot + return nil } } @@ -196,29 +204,53 @@ func DetectAndSetRoot(cmd *cobra.Command, args []string) error { return nil } +// lookupFileArg fetches the named flag value from cobra and falls back +// to scanning os.Args[1:] for short/long forms if cobra returns nothing. +// Split out so DetectAndSetRoot stays linear instead of repeating the +// same fetch-then-fallback pattern for -f and -t. +func lookupFileArg(cmd *cobra.Command, flagName, shortFlag, longFlag string) []string { + values := getFlagValues(cmd, flagName) + if len(values) == 0 { + values = parseFlagFromArgs(os.Args[1:], shortFlag, longFlag) + } + + return values +} + +// applyFileBasedRoot runs strategy 1 of DetectAndSetRoot: if files are +// passed, derive the root from them and pin it after a conflict check. +// Returns (true, nil) when the root was applied (caller should stop), +// (false, nil) when files yielded no root and the caller should fall +// through to the next strategy, and (false, err) on a hard failure. +func applyFileBasedRoot(configFiles []string) (bool, error) { + if len(configFiles) == 0 { + return false, nil + } + + detectedRoot, err := detectRootFromFiles(configFiles) + if err != nil { + return false, err + } + + if detectedRoot == "" { + return false, nil + } + + if err := checkRootConflict(detectedRoot, Config.RootDirExplicit); err != nil { + return false, err + } + + Config.RootDir = detectedRoot + + return true, nil +} + // DetectAndSetRootFromFiles detects and sets project root from file paths. // This is a common pattern used in commands like apply, upgrade, and talosctl wrapper. // It detects root from files if provided, otherwise falls back to current working directory. func DetectAndSetRootFromFiles(filePaths []string) error { - if len(filePaths) > 0 { - detectedRoot, err := ValidateAndDetectRootsForFiles(filePaths) - if err != nil { - return err - } - if detectedRoot != "" { - absConfigRoot, _ := filepath.Abs(Config.RootDir) - absDetectedRoot, _ := filepath.Abs(detectedRoot) - // Root from files has priority - if absConfigRoot != absDetectedRoot { - // If --root was explicitly set and differs from files root, error - if Config.RootDirExplicit { - return fmt.Errorf("conflicting project roots: global --root=%s, but files belong to root=%s", absConfigRoot, absDetectedRoot) - } - } - // Use root from files (has priority) - Config.RootDir = detectedRoot - return nil - } + if applied, err := applyExplicitFilesRoot(filePaths); err != nil || applied { + return err } // Fallback: detect root from current working directory if not explicitly set @@ -235,20 +267,59 @@ func DetectAndSetRootFromFiles(filePaths []string) error { return nil } +// applyExplicitFilesRoot derives Config.RootDir from explicit file +// paths. Returns (true, nil) on success, (false, nil) when filePaths is +// empty or yields no root, and (false, err) on conflict / detection +// failure. Split out of DetectAndSetRootFromFiles to flatten the +// surrounding nestif. +func applyExplicitFilesRoot(filePaths []string) (bool, error) { + if len(filePaths) == 0 { + return false, nil + } + + detectedRoot, err := ValidateAndDetectRootsForFiles(filePaths) + if err != nil { + return false, err + } + + if detectedRoot == "" { + return false, nil + } + + absConfigRoot, _ := filepath.Abs(Config.RootDir) + absDetectedRoot, _ := filepath.Abs(detectedRoot) + // Root from files has priority unless --root was set explicitly + // to a different location, in which case the operator's intent + // must win over the file-derived guess. + if absConfigRoot != absDetectedRoot && Config.RootDirExplicit { + //nolint:wrapcheck // cockroachdb/errors.WithHint multi-return; ignore-sigs cover single-return only. + return false, errors.WithHint( + errors.Newf("conflicting project roots: global --root=%s, but files belong to root=%s", absConfigRoot, absDetectedRoot), + "drop --root or pass files that live under the explicit root", + ) + } + + Config.RootDir = detectedRoot + + return true, nil +} + // ResolveSecretsPath resolves secrets.yaml path relative to project root if not absolute. func ResolveSecretsPath(withSecrets string) string { if withSecrets == "" { - withSecrets = "secrets.yaml" + withSecrets = localSecretsYamlName } + if !filepath.IsAbs(withSecrets) { withSecrets = filepath.Join(Config.RootDir, withSecrets) } + return withSecrets } // EnsureTalosconfigPath ensures talosconfig path is set to project root if not explicitly set via flag. func EnsureTalosconfigPath(cmd *cobra.Command) { - if cmd.PersistentFlags().Changed("talosconfig") { + if cmd.PersistentFlags().Changed(talosconfigFlagName) { return } @@ -260,7 +331,7 @@ func EnsureTalosconfigPath(cmd *cobra.Command) { // Use talosconfig from project root talosconfigPath = Config.GlobalOptions.Talosconfig if talosconfigPath == "" { - talosconfigPath = "talosconfig" + talosconfigPath = talosconfigFlagName } } // Make it absolute path relative to project root if it's relative @@ -275,16 +346,18 @@ func EnsureTalosconfigPath(cmd *cobra.Command) { // Returns a list of file paths, with directories expanded to their YAML files. func ExpandFilePaths(paths []string) ([]string, error) { var expanded []string + for _, path := range paths { absPath, err := filepath.Abs(path) if err != nil { - return nil, fmt.Errorf("failed to get absolute path for %s: %w", path, err) + return nil, errors.Wrapf(err, "failed to get absolute path for %s", path) } info, err := os.Stat(absPath) if err != nil { // If path doesn't exist, treat it as a file (let the caller handle the error) expanded = append(expanded, absPath) + continue } @@ -292,38 +365,53 @@ func ExpandFilePaths(paths []string) ([]string, error) { // Find all YAML files in the directory yamlFiles, err := findYAMLFiles(absPath) if err != nil { - return nil, fmt.Errorf("failed to find YAML files in %s: %w", path, err) + return nil, errors.Wrapf(err, "failed to find YAML files in %s", path) } + if len(yamlFiles) == 0 { - return nil, fmt.Errorf("no YAML files found in directory %s", path) + //nolint:wrapcheck // cockroachdb/errors.WithHint multi-return; ignore-sigs cover single-return only. + return nil, errors.WithHint( + errors.Newf("no YAML files found in directory %s", path), + "point at a directory that contains .yaml or .yml files, or pass individual files", + ) } + expanded = append(expanded, yamlFiles...) } else { // It's a file, add it as is expanded = append(expanded, absPath) } } + return expanded, nil } // findYAMLFiles recursively finds all YAML files in a directory. func findYAMLFiles(dir string) ([]string, error) { var yamlFiles []string + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err != nil { - return err + return errors.Wrapf(err, "walking %s", path) } + if !info.IsDir() { ext := filepath.Ext(path) if ext == ".yaml" || ext == ".yml" { absPath, err := filepath.Abs(path) if err != nil { - return err + return errors.Wrapf(err, "failed to get absolute path for %s", path) } + yamlFiles = append(yamlFiles, absPath) } } + return nil }) - return yamlFiles, err + if err != nil { + return nil, errors.Wrapf(err, "failed to walk directory %s", dir) + } + + return yamlFiles, nil } diff --git a/pkg/commands/root_detection_test.go b/pkg/commands/root_detection_test.go index 01acc225..06aba3bf 100644 --- a/pkg/commands/root_detection_test.go +++ b/pkg/commands/root_detection_test.go @@ -29,6 +29,24 @@ import ( "testing" ) +// File-local fixtures for root-detection contract tests. Centralised so +// goconst stops flagging the high-occurrence literals (the test fixtures +// repeat by design — a flag, its value, and a node path each appear many +// times across table cases). +const ( + fixtureValueFoo = "foo" + fixtureValueBar = "bar" + fixtureNodePath = "nodes/cp1.yaml" + fixtureFlagFile = "--file" + fixtureShortFile = "-f" + fixtureFlagOther = "--other" + fixtureShortTemplate = "-t" + fixtureFileA = "a.yaml" + fixtureFileB = "b.yaml" + fixtureFirstYaml = "first.yaml" + fixtureTemplatePath = "templates/cluster.yaml" +) + // === parseCommaSeparatedValues === // Contract: comma-separated values are split, trimmed, and empty @@ -41,13 +59,13 @@ func TestContract_ParseCommaSeparatedValues(t *testing.T) { want []string }{ {"empty", "", nil}, - {"single", "foo", []string{"foo"}}, - {"two values", "foo,bar", []string{"foo", "bar"}}, - {"three with whitespace", " foo , bar , baz ", []string{"foo", "bar", "baz"}}, - {"empty entries dropped", "foo,,bar,", []string{"foo", "bar"}}, + {"single", fixtureValueFoo, []string{fixtureValueFoo}}, + {"two values", fixtureValueFoo + "," + fixtureValueBar, []string{fixtureValueFoo, fixtureValueBar}}, + {"three with whitespace", " " + fixtureValueFoo + " , " + fixtureValueBar + " , baz ", []string{fixtureValueFoo, fixtureValueBar, "baz"}}, + {"empty entries dropped", fixtureValueFoo + ",," + fixtureValueBar + ",", []string{fixtureValueFoo, fixtureValueBar}}, {"only commas", ",,,", nil}, {"only whitespace", " ", nil}, - {"path-like values", "nodes/cp1.yaml,nodes/cp2.yaml", []string{"nodes/cp1.yaml", "nodes/cp2.yaml"}}, + {"path-like values", fixtureNodePath + ",nodes/cp2.yaml", []string{fixtureNodePath, "nodes/cp2.yaml"}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -77,64 +95,64 @@ func TestContract_ParseFlagFromArgs(t *testing.T) { want []string }{ { - name: "short-form space-separated", - args: []string{"-f", "nodes/cp1.yaml", "--other"}, - shortFlag: "-f", longFlag: "--file", - want: []string{"nodes/cp1.yaml"}, + name: "short-form space-separated", + args: []string{fixtureShortFile, fixtureNodePath, fixtureFlagOther}, + shortFlag: fixtureShortFile, longFlag: fixtureFlagFile, + want: []string{fixtureNodePath}, }, { - name: "long-form space-separated", - args: []string{"--file", "nodes/cp1.yaml"}, - shortFlag: "-f", longFlag: "--file", - want: []string{"nodes/cp1.yaml"}, + name: "long-form space-separated", + args: []string{fixtureFlagFile, fixtureNodePath}, + shortFlag: fixtureShortFile, longFlag: fixtureFlagFile, + want: []string{fixtureNodePath}, }, { - name: "short-form equal-sign", - args: []string{"-f=nodes/cp1.yaml"}, - shortFlag: "-f", longFlag: "--file", - want: []string{"nodes/cp1.yaml"}, + name: "short-form equal-sign", + args: []string{fixtureShortFile + "=" + fixtureNodePath}, + shortFlag: fixtureShortFile, longFlag: fixtureFlagFile, + want: []string{fixtureNodePath}, }, { - name: "long-form equal-sign", - args: []string{"--file=nodes/cp1.yaml"}, - shortFlag: "-f", longFlag: "--file", - want: []string{"nodes/cp1.yaml"}, + name: "long-form equal-sign", + args: []string{fixtureFlagFile + "=" + fixtureNodePath}, + shortFlag: fixtureShortFile, longFlag: fixtureFlagFile, + want: []string{fixtureNodePath}, }, { - name: "comma-separated values via short form", - args: []string{"-f", "a.yaml,b.yaml,c.yaml"}, - shortFlag: "-f", longFlag: "--file", - want: []string{"a.yaml", "b.yaml", "c.yaml"}, + name: "comma-separated values via short form", + args: []string{fixtureShortFile, fixtureFileA + "," + fixtureFileB + ",c.yaml"}, + shortFlag: fixtureShortFile, longFlag: fixtureFlagFile, + want: []string{fixtureFileA, fixtureFileB, "c.yaml"}, }, { - name: "absent flag", - args: []string{"--other", "x"}, - shortFlag: "-f", longFlag: "--file", + name: "absent flag", + args: []string{fixtureFlagOther, "x"}, + shortFlag: fixtureShortFile, longFlag: fixtureFlagFile, want: nil, }, { - name: "flag with no following value", - args: []string{"-f"}, - shortFlag: "-f", longFlag: "--file", + name: "flag with no following value", + args: []string{fixtureShortFile}, + shortFlag: fixtureShortFile, longFlag: fixtureFlagFile, want: nil, }, { - name: "flag followed by another flag (no value)", - args: []string{"-f", "--other"}, - shortFlag: "-f", longFlag: "--file", + name: "flag followed by another flag (no value)", + args: []string{fixtureShortFile, fixtureFlagOther}, + shortFlag: fixtureShortFile, longFlag: fixtureFlagFile, want: nil, }, { - name: "first occurrence wins", - args: []string{"-f", "first.yaml", "-f", "second.yaml"}, - shortFlag: "-f", longFlag: "--file", - want: []string{"first.yaml"}, + name: "first occurrence wins", + args: []string{fixtureShortFile, fixtureFirstYaml, fixtureShortFile, "second.yaml"}, + shortFlag: fixtureShortFile, longFlag: fixtureFlagFile, + want: []string{fixtureFirstYaml}, }, { - name: "templates flag (-t / --template)", - args: []string{"-t", "templates/cluster.yaml"}, - shortFlag: "-t", longFlag: "--template", - want: []string{"templates/cluster.yaml"}, + name: "templates flag (-t / --template)", + args: []string{fixtureShortTemplate, fixtureTemplatePath}, + shortFlag: fixtureShortTemplate, longFlag: "--template", + want: []string{fixtureTemplatePath}, }, } for _, tc := range cases { @@ -161,14 +179,14 @@ func TestContract_ResolveSecretsPath(t *testing.T) { root := crossPlatformAbs("some", "project") Config.RootDir = root - absSecrets := crossPlatformAbs("etc", "secrets.yaml") + absSecrets := crossPlatformAbs("etc", localSecretsYamlName) cases := []struct { name string input string want string }{ - {"empty defaults to secrets.yaml under root", "", filepath.Join(root, "secrets.yaml")}, - {"relative anchored to root", filepath.Join("vault", "secrets.yaml"), filepath.Join(root, "vault", "secrets.yaml")}, + {"empty defaults to secrets.yaml under root", "", filepath.Join(root, localSecretsYamlName)}, + {"relative anchored to root", filepath.Join("vault", localSecretsYamlName), filepath.Join(root, "vault", localSecretsYamlName)}, {"absolute returned verbatim", absSecrets, absSecrets}, } for _, tc := range cases { @@ -269,7 +287,7 @@ func TestContract_ExpandFilePaths_NonExistentPathPropagated(t *testing.T) { // Case-sensitive; no fuzzy matching. The list comes from // pkg/generated/presets.go (built at chart-bake time). func TestContract_IsValidPreset(t *testing.T) { - available := []string{"cozystack", "generic", "talos"} + available := []string{presetCozystack, presetGeneric, "talos"} for _, name := range available { if !isValidPreset(name, available) { diff --git a/pkg/commands/rotate_ca_handler.go b/pkg/commands/rotate_ca_handler.go index 19e0d2dd..f7f29c06 100644 --- a/pkg/commands/rotate_ca_handler.go +++ b/pkg/commands/rotate_ca_handler.go @@ -17,14 +17,14 @@ package commands import ( "context" "fmt" - "net" "os" "path/filepath" "strings" + "github.com/cockroachdb/errors" + "github.com/cosi-project/runtime/pkg/safe" "github.com/cozystack/talm/pkg/age" "github.com/cozystack/talm/pkg/secureperm" - "github.com/cosi-project/runtime/pkg/safe" "github.com/siderolabs/crypto/x509" "github.com/spf13/cobra" "gopkg.in/yaml.v3" @@ -38,7 +38,12 @@ import ( secretsres "github.com/siderolabs/talos/pkg/machinery/resources/secrets" ) +// cobraDefValueFalse is the cobra flag default-value rendering of false. +const cobraDefValueFalse = "false" + // wrapRotateCACommand adds talm-specific handling to the rotate-ca command. +// +//nolint:gocognit,gocyclo,cyclop,funlen,nestif // cobra command wrapper with linear PreRunE+RunE branching over (single-node validation, auto-discover nodes, set --output, set --k8s-endpoint, post-rotate updates); each branch is short, splitting would lose the documented dispatch order. func wrapRotateCACommand(wrappedCmd *cobra.Command, originalRunE func(*cobra.Command, []string) error) { // Update command description wrappedCmd.Long = `Rotates Talos and/or Kubernetes root Certificate Authorities. @@ -74,11 +79,12 @@ The command runs in dry-run mode by default. Use --dry-run=false to perform actu // Disable --with-docs and --with-examples by default if f := wrappedCmd.Flags().Lookup("with-docs"); f != nil { - f.DefValue = "false" + f.DefValue = cobraDefValueFalse _ = wrappedCmd.Flags().Set("with-docs", "false") } + if f := wrappedCmd.Flags().Lookup("with-examples"); f != nil { - f.DefValue = "false" + f.DefValue = cobraDefValueFalse _ = wrappedCmd.Flags().Set("with-examples", "false") } @@ -95,10 +101,17 @@ The command runs in dry-run mode by default. Use --dry-run=false to perform actu // Validate that only one endpoint/node is provided if len(GlobalArgs.Endpoints) > 1 { - return fmt.Errorf("rotate-ca requires exactly one control-plane node, but %d endpoints were provided\n\nThe rotate-ca command coordinates CA rotation across the entire cluster from a single\ncontrol-plane node. Please specify only one endpoint using -e flag or a single config file", len(GlobalArgs.Endpoints)) + return errors.WithHint( + errors.Newf("rotate-ca requires exactly one control-plane node, but %d endpoints were provided", len(GlobalArgs.Endpoints)), + "the rotate-ca command coordinates CA rotation across the entire cluster from a single control-plane node; specify only one endpoint via --endpoints or a single config file", + ) } + if len(GlobalArgs.Nodes) > 1 { - return fmt.Errorf("rotate-ca requires exactly one control-plane node, but %d nodes were provided\n\nThe rotate-ca command coordinates CA rotation across the entire cluster from a single\ncontrol-plane node. Please specify only one node using -n flag or a single config file", len(GlobalArgs.Nodes)) + return errors.WithHint( + errors.Newf("rotate-ca requires exactly one control-plane node, but %d nodes were provided", len(GlobalArgs.Nodes)), + "the rotate-ca command coordinates CA rotation across the entire cluster from a single control-plane node; specify only one node via --nodes or a single config file", + ) } return nil @@ -124,18 +137,22 @@ The command runs in dry-run mode by default. Use --dry-run=false to perform actu if len(controlPlaneNodes) == 0 && len(workerNodes) == 0 { fmt.Fprintf(os.Stderr, "> Auto-discovering cluster nodes...\n") + cpNodes, wNodes, err := discoverClusterNodes() if err != nil { - return fmt.Errorf("failed to auto-discover nodes: %w", err) + return errors.Wrap(err, "failed to auto-discover nodes") } + if err := cmd.Flags().Set("control-plane-nodes", strings.Join(cpNodes, ",")); err != nil { - return fmt.Errorf("failed to set control-plane-nodes: %w", err) + return errors.Wrap(err, "failed to set control-plane-nodes") } + if len(wNodes) > 0 { if err := cmd.Flags().Set("worker-nodes", strings.Join(wNodes, ",")); err != nil { - return fmt.Errorf("failed to set worker-nodes: %w", err) + return errors.Wrap(err, "failed to set worker-nodes") } } + fmt.Fprintf(os.Stderr, " Control plane: %v\n", cpNodes) fmt.Fprintf(os.Stderr, " Workers: %v\n", wNodes) } @@ -146,22 +163,21 @@ The command runs in dry-run mode by default. Use --dry-run=false to perform actu if talosconfigPath == "" { talosconfigPath = filepath.Join(Config.RootDir, "talosconfig") } + if err := cmd.Flags().Set("output", talosconfigPath); err != nil { - return fmt.Errorf("failed to set output: %w", err) + return errors.Wrap(err, "failed to set output") } } - // Set --k8s-endpoint from GlobalArgs.Endpoints + // Set --k8s-endpoint from GlobalArgs.Endpoints. Delegate to + // normalizeEndpoint so the canonical form (including the IPv6 + // `[host]` no-port branch) matches the rest of the package + // instead of re-implementing the trim-and-rejoin logic and + // silently dropping the bracket-stripping branch. if !cmd.Flags().Changed("k8s-endpoint") && len(GlobalArgs.Endpoints) > 0 { - host := GlobalArgs.Endpoints[0] - host = strings.TrimPrefix(host, "https://") - host = strings.TrimPrefix(host, "http://") - if h, _, err := net.SplitHostPort(host); err == nil { - host = h - } - k8sEndpoint := fmt.Sprintf("https://%s:6443", host) + k8sEndpoint := normalizeEndpoint(GlobalArgs.Endpoints[0]) if err := cmd.Flags().Set("k8s-endpoint", k8sEndpoint); err != nil { - return fmt.Errorf("failed to set k8s-endpoint: %w", err) + return errors.Wrap(err, "failed to set k8s-endpoint") } } @@ -179,6 +195,7 @@ The command runs in dry-run mode by default. Use --dry-run=false to perform actu // Use control plane node for COSI requests (not the external endpoint) cpNodes, _ := cmd.Flags().GetStringSlice("control-plane-nodes") + var targetNode string if len(cpNodes) > 0 { targetNode = cpNodes[0] @@ -186,71 +203,79 @@ The command runs in dry-run mode by default. Use --dry-run=false to perform actu // Update secrets.yaml with new CA from cluster if err := updateSecretsFromCluster(rotateTalos, rotateKubernetes, targetNode); err != nil { - return fmt.Errorf("failed to update secrets.yaml: %w", err) + return errors.Wrap(err, "failed to update secrets.yaml") } // Update talosconfig.encrypted if it exists (talosconfig already updated by upstream) if rotateTalos { if err := updateTalosconfigEncryption(); err != nil { - return fmt.Errorf("failed to update talosconfig.encrypted: %w", err) + return errors.Wrap(err, "failed to update talosconfig.encrypted") } } // Update kubeconfig using talm kubeconfig if rotateKubernetes { fmt.Fprintf(os.Stderr, "> Updating kubeconfig...\n") + if err := runKubeconfigCmd(); err != nil { - return fmt.Errorf("failed to update kubeconfig: %w", err) + return errors.Wrap(err, "failed to update kubeconfig") } } fmt.Fprintf(os.Stderr, "\n> CA rotation completed successfully!\n") + return nil } } // discoverClusterNodes discovers control plane and worker nodes from the Kubernetes API. -func discoverClusterNodes() (controlPlane []string, workers []string, err error) { +// +//nolint:nonamedreturns // named returns document semantics (control-plane vs workers); naked returns are not used so renaming would only lose the documentation. +func discoverClusterNodes() (controlPlane, workers []string, err error) { // Get kubeconfig from cluster via talos API kubeconfigData, err := getKubeconfigFromTalos() if err != nil { - return nil, nil, fmt.Errorf("failed to get kubeconfig: %w", err) + return nil, nil, errors.Wrap(err, "failed to get kubeconfig") } // Update kubeconfig server endpoint to use our endpoint instead of VIP if len(GlobalArgs.Endpoints) > 0 { kubeconfigData, err = updateKubeconfigEndpoint(kubeconfigData, GlobalArgs.Endpoints[0]) if err != nil { - return nil, nil, fmt.Errorf("failed to update kubeconfig endpoint: %w", err) + return nil, nil, errors.Wrap(err, "failed to update kubeconfig endpoint") } } // Create kubernetes client config, err := clientcmd.RESTConfigFromKubeConfig(kubeconfigData) if err != nil { - return nil, nil, fmt.Errorf("failed to create kubernetes config: %w", err) + return nil, nil, errors.Wrap(err, "failed to create kubernetes config") } clientset, err := kubernetes.NewForConfig(config) if err != nil { - return nil, nil, fmt.Errorf("failed to create kubernetes client: %w", err) + return nil, nil, errors.Wrap(err, "failed to create kubernetes client") } // List nodes nodes, err := clientset.CoreV1().Nodes().List(context.Background(), metav1.ListOptions{}) if err != nil { - return nil, nil, fmt.Errorf("failed to list nodes: %w", err) + return nil, nil, errors.Wrap(err, "failed to list nodes") } + //nolint:gocritic,varnamelen // 768-byte v1.Node copy is acceptable on rare CA rotation; loop variable name preserved for documentation. for _, node := range nodes.Items { // Get internal IP var ip string + for _, addr := range node.Status.Addresses { if addr.Type == v1.NodeInternalIP { ip = addr.Address + break } } + if ip == "" { continue } @@ -265,7 +290,7 @@ func discoverClusterNodes() (controlPlane []string, workers []string, err error) } if len(controlPlane) == 0 { - return nil, nil, fmt.Errorf("no control plane nodes found") + return nil, nil, errors.New("no control plane nodes found") } return controlPlane, workers, nil @@ -277,13 +302,16 @@ func getKubeconfigFromTalos() ([]byte, error) { err := GlobalArgs.WithClient(func(ctx context.Context, c *client.Client) error { var err error + kubeconfigData, err = c.Kubeconfig(ctx) if err != nil { - return fmt.Errorf("failed to get kubeconfig: %w", err) + return errors.Wrap(err, "failed to get kubeconfig") } + return nil }) + //nolint:wrapcheck // forwarding talos/cobra error verbatim per the wrapper contract. return kubeconfigData, err } @@ -291,17 +319,15 @@ func getKubeconfigFromTalos() ([]byte, error) { func updateKubeconfigEndpoint(kubeconfigData []byte, endpoint string) ([]byte, error) { config, err := clientcmd.Load(kubeconfigData) if err != nil { - return nil, fmt.Errorf("failed to parse kubeconfig: %w", err) + return nil, errors.Wrap(err, "failed to parse kubeconfig") } - // Normalize endpoint to https://host:6443 - host := endpoint - host = strings.TrimPrefix(host, "https://") - host = strings.TrimPrefix(host, "http://") - if h, _, err := net.SplitHostPort(host); err == nil { - host = h - } - k8sEndpoint := fmt.Sprintf("https://%s:6443", host) + // Delegate to normalizeEndpoint so the canonical form matches the + // k8s-endpoint flag set above (and every other normaliser in the + // package). Re-implementing the trim-and-rejoin logic here used to + // drop the IPv6 `[host]` no-port branch — same class of bug + // nosprintfhostport surfaced for the talosctl wrapper. + k8sEndpoint := normalizeEndpoint(endpoint) // Update server for all clusters for _, cluster := range config.Clusters { @@ -309,30 +335,36 @@ func updateKubeconfigEndpoint(kubeconfigData []byte, endpoint string) ([]byte, e } // Marshal back to bytes + //nolint:wrapcheck // forwarding talos/cobra error verbatim per the wrapper contract. return clientcmd.Write(*config) } // updateSecretsFromCluster fetches new CA certificates from cluster and updates secrets.yaml. -// targetNode specifies which node to query for COSI resources (required for non-proxy connections). -func updateSecretsFromCluster(updateTalos, updateKubernetes bool, targetNode string) error { +// The third parameter (target node) is reserved for the future per-node +// mode but unused today (the call goes through WithClientNoNodes to +// avoid the multi-node proxying COSI does not support). +// +//nolint:funlen // single linear secrets.yaml regeneration (load bundle, fetch CA via WithClient, encode, write) — extracting helpers would split the error-context wrapping across files. +func updateSecretsFromCluster(updateTalos, updateKubernetes bool, _ string) error { secretsPath := ResolveSecretsPath(Config.TemplateOptions.WithSecrets) // Load existing secrets bundle, err := secrets.LoadBundle(secretsPath) if err != nil { - return fmt.Errorf("failed to load secrets bundle: %w", err) + return errors.Wrap(err, "failed to load secrets bundle") } // Use WithClientNoNodes to avoid automatic node setting - COSI doesn't support multi-node proxying err = WithClientNoNodes(func(ctx context.Context, c *client.Client) error { - // Fetch Talos CA if needed if updateTalos { fmt.Fprintf(os.Stderr, " Fetching Talos CA from cluster...\n") + osRoot, err := safe.StateGetByID[*secretsres.OSRoot](ctx, c.COSI, secretsres.OSRootID) if err != nil { - return fmt.Errorf("failed to get OSRoot: %w", err) + return errors.Wrap(err, "failed to get OSRoot") } + bundle.Certs.OS = &x509.PEMEncodedCertificateAndKey{ Crt: osRoot.TypedSpec().IssuingCA.Crt, Key: osRoot.TypedSpec().IssuingCA.Key, @@ -342,10 +374,12 @@ func updateSecretsFromCluster(updateTalos, updateKubernetes bool, targetNode str // Fetch Kubernetes CA if needed if updateKubernetes { fmt.Fprintf(os.Stderr, " Fetching Kubernetes CA from cluster...\n") + k8sRoot, err := safe.StateGetByID[*secretsres.KubernetesRoot](ctx, c.COSI, secretsres.KubernetesRootID) if err != nil { - return fmt.Errorf("failed to get KubernetesRoot: %w", err) + return errors.Wrap(err, "failed to get KubernetesRoot") } + bundle.Certs.K8s = &x509.PEMEncodedCertificateAndKey{ Crt: k8sRoot.TypedSpec().IssuingCA.Crt, Key: k8sRoot.TypedSpec().IssuingCA.Key, @@ -361,21 +395,24 @@ func updateSecretsFromCluster(updateTalos, updateKubernetes bool, targetNode str // Save secrets.yaml data, err := yaml.Marshal(bundle) if err != nil { - return fmt.Errorf("failed to marshal secrets: %w", err) + return errors.Wrap(err, "failed to marshal secrets") } if err := secureperm.WriteFile(secretsPath, data); err != nil { - return fmt.Errorf("failed to write secrets.yaml: %w", err) + return errors.Wrap(err, "failed to write secrets.yaml") } + fmt.Fprintf(os.Stderr, " Updated secrets.yaml\n") // Update secrets.encrypted.yaml if it exists encryptedPath := filepath.Join(Config.RootDir, "secrets.encrypted.yaml") + keyFile := filepath.Join(Config.RootDir, "talm.key") if fileExists(encryptedPath) && fileExists(keyFile) { if err := age.EncryptSecretsFile(Config.RootDir); err != nil { - return fmt.Errorf("failed to encrypt secrets.yaml: %w", err) + return errors.Wrap(err, "failed to encrypt secrets.yaml") } + fmt.Fprintf(os.Stderr, " Updated secrets.encrypted.yaml\n") } @@ -392,8 +429,9 @@ func updateTalosconfigEncryption() error { } fmt.Fprintf(os.Stderr, " Updating talosconfig.encrypted...\n") + if err := age.EncryptYAMLFile(Config.RootDir, "talosconfig", "talosconfig.encrypted"); err != nil { - return fmt.Errorf("failed to encrypt talosconfig: %w", err) + return errors.Wrap(err, "failed to encrypt talosconfig") } return nil @@ -402,15 +440,18 @@ func updateTalosconfigEncryption() error { // runKubeconfigCmd runs the wrapped talosctl kubeconfig command. func runKubeconfigCmd() error { for _, cmd := range Commands { - if cmd.Name() == "kubeconfig" { + if cmd.Name() == defaultKubeconfigName { // Set --force to avoid interactive prompt if cmd.Flags().Lookup("force") != nil { if err := cmd.Flags().Set("force", "true"); err != nil { - return fmt.Errorf("failed to set force flag: %w", err) + return errors.Wrap(err, "failed to set force flag") } } + + //nolint:wrapcheck // forwarding talos/cobra error verbatim per the wrapper contract. return cmd.RunE(cmd, []string{}) } } - return fmt.Errorf("kubeconfig command not found") + + return errors.New("kubeconfig command not found") } diff --git a/pkg/commands/talosconfig.go b/pkg/commands/talosconfig.go index c54eff8b..b256f0ae 100644 --- a/pkg/commands/talosconfig.go +++ b/pkg/commands/talosconfig.go @@ -19,8 +19,8 @@ import ( "os" "path/filepath" + "github.com/cockroachdb/errors" "github.com/cozystack/talm/pkg/secureperm" - "github.com/siderolabs/talos/cmd/talosctl/cmd/mgmt/gen" "github.com/siderolabs/talos/pkg/machinery/client/config" machineconfig "github.com/siderolabs/talos/pkg/machinery/config" @@ -31,8 +31,10 @@ import ( ) // talosconfigCmd represents the `talosconfig` command. +// +//nolint:gochecknoglobals // cobra command, idiomatic for cobra-based CLIs. var talosconfigCmd = &cobra.Command{ - Use: "talosconfig", + Use: talosconfigName, Short: "Regenerate talosconfig with new client certificates", Long: `Regenerate talosconfig from secrets.yaml with fresh client certificates. @@ -44,7 +46,7 @@ This command: Use this command when your client certificate has expired.`, Args: cobra.NoArgs, - PreRunE: func(cmd *cobra.Command, args []string) error { + PreRunE: func(_ *cobra.Command, _ []string) error { // Ensure project root is detected if !Config.RootDirExplicit { detectedRoot, err := detectRootFromCWD() @@ -52,9 +54,10 @@ Use this command when your client certificate has expired.`, Config.RootDir = detectedRoot } } + return nil }, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, _ []string) error { // First, decrypt talosconfig if encrypted version exists if _, err := handleTalosconfigEncryption(false); err != nil { // If decryption fails, continue - we may be able to regenerate @@ -87,44 +90,56 @@ func init() { // regenerateTalosconfig regenerates the talosconfig file from secrets.yaml, // preserving endpoints and nodes from the existing config. +// +//nolint:funlen // talosconfig regeneration is one linear pipeline (load, validate, generate, write); extracting helpers would scatter the operator-facing error wrapping across files without simplifying. func regenerateTalosconfig() error { - talosconfigFile := filepath.Join(Config.RootDir, "talosconfig") + talosconfigFile := filepath.Join(Config.RootDir, talosconfigName) // Load existing talosconfig to preserve endpoints and nodes - var oldConfig *config.Config - var clusterName string + var ( + oldConfig *config.Config + clusterName string + ) if fileExists(talosconfigFile) { var err error + oldConfig, err = config.Open(talosconfigFile) if err != nil { - return fmt.Errorf("failed to read existing talosconfig: %w", err) + return errors.Wrap(err, "failed to read existing talosconfig") } + clusterName = oldConfig.Context } // Resolve secrets path secretsPath := ResolveSecretsPath(Config.TemplateOptions.WithSecrets) if !fileExists(secretsPath) { - return fmt.Errorf("secrets.yaml not found at %s. Run 'talm init' or restore secrets.yaml", secretsPath) + //nolint:wrapcheck // cockroachdb/errors.WithHint at boundary. + return errors.WithHint( + errors.Newf("secrets.yaml not found at %s", secretsPath), + "run 'talm init' or restore secrets.yaml", + ) } // Load secrets bundle secretsBundle, err := secrets.LoadBundle(secretsPath) if err != nil { - return fmt.Errorf("failed to load secrets bundle: %w", err) + return errors.Wrap(err, "failed to load secrets bundle") } // Build generate options var genOptions []generate.Option + genOptions = append(genOptions, generate.WithSecretsBundle(secretsBundle)) // Add version contract if configured if Config.TemplateOptions.TalosVersion != "" { versionContract, err := machineconfig.ParseContractFromVersion(Config.TemplateOptions.TalosVersion) if err != nil { - return fmt.Errorf("invalid talos-version: %w", err) + return errors.Wrap(err, "invalid talos-version") } + genOptions = append(genOptions, generate.WithVersionContract(versionContract)) } @@ -149,7 +164,7 @@ func regenerateTalosconfig() error { []string{}, ) if err != nil { - return fmt.Errorf("failed to generate config bundle: %w", err) + return errors.Wrap(err, "failed to generate config bundle") } // Get the new talosconfig @@ -174,23 +189,23 @@ func regenerateTalosconfig() error { fmt.Fprintf(os.Stderr, "Preserved endpoints and nodes from existing config\n") } else { // No old config, set default endpoint - newConfig.Contexts[clusterName].Endpoints = []string{"127.0.0.1"} + newConfig.Contexts[clusterName].Endpoints = []string{defaultLocalEndpoint} } // Marshal and write the new talosconfig data, err := yaml.Marshal(newConfig) if err != nil { - return fmt.Errorf("failed to marshal talosconfig: %w", err) + return errors.Wrap(err, "failed to marshal talosconfig") } if err := secureperm.WriteFile(talosconfigFile, data); err != nil { - return fmt.Errorf("failed to write talosconfig: %w", err) + return errors.Wrap(err, "failed to write talosconfig") } return nil } -// getClusterNameFromChart reads the cluster name from values.yaml or Chart.yaml +// getClusterNameFromChart reads the cluster name from values.yaml or Chart.yaml. func getClusterNameFromChart() string { valuesYamlPath := filepath.Join(Config.RootDir, "values.yaml") if data, err := os.ReadFile(valuesYamlPath); err == nil { @@ -203,7 +218,8 @@ func getClusterNameFromChart() string { } } - chartYamlPath := filepath.Join(Config.RootDir, "Chart.yaml") + chartYamlPath := filepath.Join(Config.RootDir, chartYamlName) + data, err := os.ReadFile(chartYamlPath) if err != nil { return "" diff --git a/pkg/commands/talosctl_wrapper.go b/pkg/commands/talosctl_wrapper.go index 8a0b0f14..6cc4cf3e 100644 --- a/pkg/commands/talosctl_wrapper.go +++ b/pkg/commands/talosctl_wrapper.go @@ -21,13 +21,16 @@ import ( "path/filepath" "strings" + "github.com/cockroachdb/errors" taloscommands "github.com/siderolabs/talos/cmd/talosctl/cmd/talos" "github.com/spf13/cobra" "github.com/spf13/pflag" "k8s.io/client-go/tools/clientcmd" ) -// wrapTalosCommand wraps a talosctl command to add --file flag support +// wrapTalosCommand wraps a talosctl command to add --file flag support. +// +//nolint:gocognit,gocyclo,cyclop,funlen // cobra wrapper for talosctl forward; branching over (--insecure, --talosconfig override, file/template flags, modeline) is each one short branch. func wrapTalosCommand(cmd *cobra.Command, cmdName string) *cobra.Command { // Create a copy of the command to avoid modifying the original wrappedCmd := &cobra.Command{ @@ -47,6 +50,7 @@ func wrapTalosCommand(cmd *cobra.Command, cmdName string) *cobra.Command { // Copy all flags from original command and handle -f flag conflict fileFlagExists := false + cmd.Flags().VisitAll(func(flag *pflag.Flag) { // Check if this is the --file flag if flag.Name == "file" { @@ -76,6 +80,7 @@ func wrapTalosCommand(cmd *cobra.Command, cmdName string) *cobra.Command { // Add --file flag only if it doesn't already exist in the original command var configFiles []string + if !fileFlagExists { // Double-check that the flag doesn't exist after copying if wrappedCmd.Flags().Lookup("file") == nil { @@ -92,8 +97,10 @@ func wrapTalosCommand(cmd *cobra.Command, cmdName string) *cobra.Command { if idx := strings.Index(cmdName, " "); idx > 0 { baseCmdName = cmdName[:idx] } - if baseCmdName == "kubeconfig" { + + if baseCmdName == defaultKubeconfigName { if !cmd.Flags().Changed("force") && cmd.Flags().Lookup("force") != nil { + //nolint:wrapcheck // pflag.Set typed error surfaced verbatim. if err := cmd.Flags().Set("force", "true"); err != nil { return err } @@ -143,6 +150,7 @@ func wrapTalosCommand(cmd *cobra.Command, cmdName string) *cobra.Command { if originalPreRunE != nil { return originalPreRunE(cmd, args) } + return nil } @@ -155,7 +163,7 @@ func wrapTalosCommand(cmd *cobra.Command, cmdName string) *cobra.Command { originalRunE := wrappedCmd.RunE // Special handling for kubeconfig command - if baseCmdName == "kubeconfig" { + if baseCmdName == defaultKubeconfigName { wrapKubeconfigCommand(wrappedCmd, originalRunE) } @@ -181,11 +189,11 @@ func init() { // Import all commands from talosctl package, except those in the exclusion list // Commands to exclude (these are talm-specific or should not be exposed) excludedCommands := map[string]bool{ - "apply-config": true, // talm has its own apply command - "config": true, // talm manages config differently - "patch": true, // not needed in talm - "upgrade-k8s": true, // not needed in talm - "talosconfig": true, // talm has its own talosconfig command + "apply-config": true, // talm has its own apply command + "config": true, // talm manages config differently + "patch": true, // not needed in talm + "upgrade-k8s": true, // not needed in talm + talosconfigName: true, // talm has its own talosconfig command } // Import and wrap each command from talosctl @@ -197,6 +205,7 @@ func init() { for i, r := range baseName { if r == ' ' || r == '<' || r == '[' { baseName = baseName[:i] + break } } @@ -253,12 +262,12 @@ func normalizeEndpoint(endpoint string) string { return "https://" + net.JoinHostPort(host, "6443") } -// updateKubeconfigServer updates the server field in all clusters of the kubeconfig file +// updateKubeconfigServer updates the server field in all clusters of the kubeconfig file. func updateKubeconfigServer(kubeconfigPath, endpoint string) error { // Load kubeconfig config, err := clientcmd.LoadFromFile(kubeconfigPath) if err != nil { - return fmt.Errorf("failed to load kubeconfig: %w", err) + return errors.Wrap(err, "failed to load kubeconfig") } // Normalize endpoint @@ -266,10 +275,12 @@ func updateKubeconfigServer(kubeconfigPath, endpoint string) error { // Update server for all clusters updated := false + for clusterName, cluster := range config.Clusters { if cluster.Server != normalizedEndpoint { cluster.Server = normalizedEndpoint updated = true + fmt.Fprintf(os.Stderr, "Updated cluster %s server to %s\n", clusterName, normalizedEndpoint) } } @@ -277,24 +288,26 @@ func updateKubeconfigServer(kubeconfigPath, endpoint string) error { // Save kubeconfig if updated if updated { if err := clientcmd.WriteToFile(*config, kubeconfigPath); err != nil { - return fmt.Errorf("failed to write kubeconfig: %w", err) + return errors.Wrap(err, "failed to write kubeconfig") } } return nil } -// addToGitignore adds an entry to .gitignore if it doesn't already exist +// addToGitignore adds an entry to .gitignore if it doesn't already exist. func addToGitignore(entry string) error { gitignoreFile := filepath.Join(Config.RootDir, ".gitignore") // Read existing .gitignore if it exists var content string + if _, err := os.Stat(gitignoreFile); err == nil { existingContent, err := os.ReadFile(gitignoreFile) if err != nil { - return fmt.Errorf("failed to read .gitignore: %w", err) + return errors.Wrap(err, "failed to read .gitignore") } + content = string(existingContent) // Check if entry already exists @@ -311,8 +324,9 @@ func addToGitignore(entry string) error { if content != "" && !strings.HasSuffix(content, "\n") { content += "\n" } + content += entry + "\n" // Write back - return os.WriteFile(gitignoreFile, []byte(content), 0o644) + return os.WriteFile(gitignoreFile, []byte(content), 0o644) //nolint:gosec,mnd,wrapcheck // talosconfig is intentionally readable by sibling tooling. } diff --git a/pkg/commands/template.go b/pkg/commands/template.go index fbe57ecc..5287acfe 100644 --- a/pkg/commands/template.go +++ b/pkg/commands/template.go @@ -16,11 +16,11 @@ package commands import ( "context" - "errors" "fmt" "os" "path/filepath" + "github.com/cockroachdb/errors" "github.com/cozystack/talm/pkg/engine" "github.com/cozystack/talm/pkg/modeline" "github.com/cozystack/talm/pkg/secureperm" @@ -30,6 +30,7 @@ import ( "github.com/siderolabs/talos/pkg/machinery/constants" ) +//nolint:gochecknoglobals // cobra command flag struct, idiomatic for cobra-based CLIs var templateCmdFlags struct { insecure bool configFiles []string // -f/--files @@ -52,42 +53,50 @@ var templateCmdFlags struct { templatesFromArgs bool } +//nolint:gochecknoglobals // cobra command, idiomatic for cobra-based CLIs var templateCmd = &cobra.Command{ Use: "template", Short: "Render templates locally and display the output", Long: ``, Args: cobra.NoArgs, - PreRunE: func(cmd *cobra.Command, args []string) error { + PreRunE: func(cmd *cobra.Command, _ []string) error { templateCmdFlags.valueFiles = append(Config.TemplateOptions.ValueFiles, templateCmdFlags.valueFiles...) templateCmdFlags.values = append(Config.TemplateOptions.Values, templateCmdFlags.values...) templateCmdFlags.stringValues = append(Config.TemplateOptions.StringValues, templateCmdFlags.stringValues...) templateCmdFlags.fileValues = append(Config.TemplateOptions.FileValues, templateCmdFlags.fileValues...) templateCmdFlags.jsonValues = append(Config.TemplateOptions.JsonValues, templateCmdFlags.jsonValues...) + templateCmdFlags.literalValues = append(Config.TemplateOptions.LiteralValues, templateCmdFlags.literalValues...) if !cmd.Flags().Changed("talos-version") { templateCmdFlags.talosVersion = Config.TemplateOptions.TalosVersion } + if !cmd.Flags().Changed("with-secrets") { templateCmdFlags.withSecrets = Config.TemplateOptions.WithSecrets } + if !cmd.Flags().Changed("kubernetes-version") { templateCmdFlags.kubernetesVersion = Config.TemplateOptions.KubernetesVersion } + if !cmd.Flags().Changed("full") { templateCmdFlags.full = Config.TemplateOptions.Full } + if !cmd.Flags().Changed("debug") { templateCmdFlags.debug = Config.TemplateOptions.Debug } + if !cmd.Flags().Changed("offline") { templateCmdFlags.offline = Config.TemplateOptions.Offline } + templateCmdFlags.templatesFromArgs = len(templateCmdFlags.templateFiles) > 0 templateCmdFlags.nodesFromArgs = len(GlobalArgs.Nodes) > 0 templateCmdFlags.endpointsFromArgs = len(GlobalArgs.Endpoints) > 0 // Set dummy endpoint to avoid errors on building clinet if len(GlobalArgs.Endpoints) == 0 { - GlobalArgs.Endpoints = append(GlobalArgs.Endpoints, "127.0.0.1") + GlobalArgs.Endpoints = append(GlobalArgs.Endpoints, defaultLocalEndpoint) } return nil @@ -99,11 +108,13 @@ var templateCmd = &cobra.Command{ } if templateCmdFlags.offline { - return templateFunc(args)(context.Background(), nil) + return templateFunc(args)(cmd.Context(), nil) } + if templateCmdFlags.insecure { return WithClientMaintenance(nil, templateFunc(args)) } + if GlobalArgs.SkipVerify { return WithClientSkipVerify(templateFunc(args)) } @@ -119,13 +130,15 @@ func template(args []string) func(ctx context.Context, c *client.Client) error { return err } + //nolint:forbidigo // CLI command output is the user-facing rendered config fmt.Println(output) + return nil } } func templateWithFiles(args []string) func(ctx context.Context, c *client.Client) error { - return func(ctx context.Context, c *client.Client) error { + return func(ctx context.Context, _ *client.Client) error { // Expand directories to YAML files expandedFiles, err := ExpandFilePaths(templateCmdFlags.configFiles) if err != nil { @@ -133,90 +146,136 @@ func templateWithFiles(args []string) func(ctx context.Context, c *client.Client } // Detect root from files if specified, otherwise fallback to cwd - if err := DetectAndSetRootFromFiles(expandedFiles); err != nil { + err = DetectAndSetRootFromFiles(expandedFiles) + if err != nil { return err } firstFileProcessed := false - for _, configFile := range expandedFiles { - modelineConfig, err := modeline.ReadAndParseModeline(configFile) - if err != nil { - return fmt.Errorf("modeline parsing failed: %w", err) - } - if !templateCmdFlags.templatesFromArgs { - if len(modelineConfig.Templates) == 0 { - return fmt.Errorf("modeline does not contain templates information") - } else { - templateCmdFlags.templateFiles = modelineConfig.Templates - } - } - if !templateCmdFlags.nodesFromArgs { - GlobalArgs.Nodes = modelineConfig.Nodes - } - if !templateCmdFlags.endpointsFromArgs { - GlobalArgs.Endpoints = modelineConfig.Endpoints - } - fmt.Printf("- talm: file=%s, nodes=%s, endpoints=%s, templates=%s\n", configFile, GlobalArgs.Nodes, GlobalArgs.Endpoints, templateCmdFlags.templateFiles) - - if len(GlobalArgs.Nodes) < 1 { - return errors.New("nodes are not set for the command: please use `--nodes` flag or configuration file to set the nodes to run the command against") - } - if len(templateCmdFlags.configFiles) != 0 && len(templateCmdFlags.templateFiles) < 1 { - return errors.New("templates are not set for the command: please use `--template` flag to set the templates to render manifest from") - } - - template := func(args []string) func(ctx context.Context, c *client.Client) error { - return func(ctx context.Context, c *client.Client) error { - output, err := generateOutput(ctx, c, args) - if err != nil { - return err - } - - if templateCmdFlags.inplace { - if err := writeInplaceRendered(configFile, output); err != nil { - return err - } - } else { - if firstFileProcessed { - fmt.Println("---") - } - fmt.Printf("%s", output) - } - - return nil - } - } - if templateCmdFlags.offline { - err = template(args)(context.Background(), nil) - } else if templateCmdFlags.insecure { - err = WithClientMaintenance(nil, template(args)) - } else if GlobalArgs.SkipVerify { - err = WithClientSkipVerify(template(args)) - } else { - err = WithClient(template(args)) - } + for _, configFile := range expandedFiles { + err = templateOneFile(ctx, args, configFile, &firstFileProcessed) if err != nil { return err } // Reset args firstFileProcessed = true + if !templateCmdFlags.templatesFromArgs { templateCmdFlags.templateFiles = []string{} } - if !templateCmdFlags.nodesFromArgs { - GlobalArgs.Nodes = []string{} - } - if !templateCmdFlags.endpointsFromArgs { - GlobalArgs.Endpoints = []string{} - } + + resetGlobalArgsBetweenFiles(templateCmdFlags.nodesFromArgs, templateCmdFlags.endpointsFromArgs) + } + + return nil + } +} + +// templateOneFile renders one config file: parses its modeline, +// updates the package-level state for nodes/endpoints/templates, +// then dispatches the per-file render through the appropriate client +// mode. Splitting the per-file work out of templateWithFiles keeps +// the outer function's cognitive complexity within the linter's gate. +func templateOneFile(ctx context.Context, args []string, configFile string, firstFileProcessed *bool) error { + modelineConfig, err := modeline.ReadAndParseModeline(configFile) + if err != nil { + return errors.Wrap(err, "modeline parsing failed") + } + + if !templateCmdFlags.templatesFromArgs { + if len(modelineConfig.Templates) == 0 { + //nolint:wrapcheck // sentinel constructed in-place; WithHint attaches operator guidance + return errors.WithHint( + errors.New("modeline does not contain templates information"), + "add a `# talm: templates=[...]` modeline at the top of the node file or pass --template explicitly", + ) + } + + templateCmdFlags.templateFiles = modelineConfig.Templates + } + + if !templateCmdFlags.nodesFromArgs { + GlobalArgs.Nodes = modelineConfig.Nodes + } + + if !templateCmdFlags.endpointsFromArgs && len(modelineConfig.Endpoints) > 0 { + // Only overwrite the PreRunE-seeded defaultLocalEndpoint when the + // modeline actually provides endpoints. An empty modelineConfig.Endpoints + // would otherwise wipe the fallback and leave client construction + // without any endpoint at all. + GlobalArgs.Endpoints = modelineConfig.Endpoints + } + + //nolint:forbidigo // CLI progress line surfaces the file-to-target mapping for the operator + fmt.Printf("- talm: file=%s, nodes=%s, endpoints=%s, templates=%s\n", configFile, GlobalArgs.Nodes, GlobalArgs.Endpoints, templateCmdFlags.templateFiles) + + if len(GlobalArgs.Nodes) < 1 { + //nolint:wrapcheck // sentinel constructed in-place; WithHint attaches operator guidance + return errors.WithHint( + errors.New("nodes are not set for the command"), + "set the targets via --nodes, a `# talm: nodes=[...]` modeline at the top of the node file, or the talosconfig context", + ) + } + + if len(templateCmdFlags.configFiles) != 0 && len(templateCmdFlags.templateFiles) < 1 { + //nolint:wrapcheck // sentinel constructed in-place; WithHint attaches operator guidance + return errors.WithHint( + errors.New("templates are not set for the command"), + "set the templates via --template or a `# talm: templates=[...]` modeline at the top of the node file", + ) + } + + tmpl := buildTemplateRunner(args, configFile, firstFileProcessed) + + return runTemplate(ctx, tmpl) +} + +// buildTemplateRunner returns the per-file render-and-emit closure. +// Extracted so both templateOneFile and the dispatcher in runTemplate +// can stay flat. +func buildTemplateRunner(args []string, configFile string, firstFileProcessed *bool) func(ctx context.Context, c *client.Client) error { + return func(ctx context.Context, c *client.Client) error { + output, err := generateOutput(ctx, c, args) + if err != nil { + return err + } + + if templateCmdFlags.inplace { + return writeInplaceRendered(configFile, output) + } + + if *firstFileProcessed { + //nolint:forbidigo // multi-document YAML separator is part of the user-facing output stream + fmt.Println("---") } + + //nolint:forbidigo // CLI command output is the user-facing rendered config + fmt.Printf("%s", output) + return nil } } -func generateOutput(ctx context.Context, c *client.Client, args []string) (string, error) { +// runTemplate dispatches the per-file template runner across the four +// possible client modes (offline, insecure maintenance, skip-verify, +// authenticated). The dispatch is a switch over package-level flag +// state; the per-file logic sits in tmpl. +func runTemplate(ctx context.Context, tmpl func(ctx context.Context, c *client.Client) error) error { + switch { + case templateCmdFlags.offline: + return tmpl(ctx, nil) + case templateCmdFlags.insecure: + return WithClientMaintenance(nil, tmpl) + case GlobalArgs.SkipVerify: + return WithClientSkipVerify(tmpl) + default: + return WithClient(tmpl) + } +} + +func generateOutput(ctx context.Context, c *client.Client, _ []string) (string, error) { // Resolve secrets.yaml path relative to project root if not absolute withSecretsPath := ResolveSecretsPath(templateCmdFlags.withSecrets) @@ -243,106 +302,148 @@ func generateOutput(ctx context.Context, c *client.Client, args []string) (strin result, err := engine.Render(ctx, c, opts) if err != nil { - return "", fmt.Errorf("failed to render templates: %w", err) + return "", errors.Wrap(err, "failed to render templates") } - // Convert template paths to relative paths from project root for modeline - templatePathsForModeline := make([]string, len(templateCmdFlags.templateFiles)) - absRootDirModeline, err := filepath.Abs(Config.RootDir) + templatePathsForModeline := buildModelineTemplatePaths(templateCmdFlags.templateFiles, Config.RootDir) + + mline, err := modeline.GenerateModeline(GlobalArgs.Nodes, GlobalArgs.Endpoints, templatePathsForModeline) + if err != nil { + return "", errors.Wrap(err, "failed to generate modeline") + } + + warn := "# THIS FILE IS AUTOGENERATED. PREFER TEMPLATE EDITS OVER MANUAL ONES." + + output := fmt.Sprintf("%s\n%s\n%s\n", mline, warn, string(result)) + + return output, nil +} + +// buildModelineTemplatePaths converts each template path into the +// forward-slash, root-relative form to be embedded in the generated +// modeline. Splits the per-path branching out of generateOutput so +// the outer function's cognitive complexity stays within the +// linter's gate. +func buildModelineTemplatePaths(templateFiles []string, rootDir string) []string { + out := make([]string, len(templateFiles)) + + absRootDir, err := filepath.Abs(rootDir) if err != nil { // If we can't get absolute root, normalize original paths for modeline - for i, p := range templateCmdFlags.templateFiles { - templatePathsForModeline[i] = engine.NormalizeTemplatePath(p) + for i, p := range templateFiles { + out[i] = engine.NormalizeTemplatePath(p) } - } else { - for i, templatePath := range templateCmdFlags.templateFiles { - var absTemplatePath string - if filepath.IsAbs(templatePath) { - // Already absolute, use as is - absTemplatePath = templatePath - } else { - // Resolve relative path from current working directory - absTemplatePath, err = filepath.Abs(templatePath) - if err != nil { - // If we can't get absolute path, use original (normalized) - templatePathsForModeline[i] = engine.NormalizeTemplatePath(templatePath) - continue - } - } - // Check if the resolved path is inside root project - relPath, err := filepath.Rel(absRootDirModeline, absTemplatePath) - if err != nil { - // If we can't get relative path, use original (normalized) - templatePathsForModeline[i] = engine.NormalizeTemplatePath(templatePath) - continue - } - // Normalize the path (remove .. and .) - relPath = filepath.Clean(relPath) - // Check if path goes outside root - if isOutsideRoot(relPath) { - // Path goes outside root, try to find file in templates/ relative to root - // This handles cases like "../templates/controlplane.yaml" when file is actually in root/templates/ - templateName := filepath.Base(templatePath) - // Try common template locations - possiblePaths := []string{ - filepath.Join("templates", templateName), - templateName, - } - found := false - for _, possiblePath := range possiblePaths { - fullPath := filepath.Join(absRootDirModeline, possiblePath) - if _, err := os.Stat(fullPath); err == nil { - relPath = possiblePath - found = true - break - } - } - if !found { - // Can't resolve, use original (normalized) - templatePathsForModeline[i] = engine.NormalizeTemplatePath(templatePath) - continue - } - } else { - // Path is inside root, but check if file actually exists - // If not, try to find it in templates/ relative to root - if _, errModeline := os.Stat(absTemplatePath); errModeline != nil { - templateName := filepath.Base(templatePath) - possiblePath := filepath.Join("templates", templateName) - fullPath := filepath.Join(absRootDirModeline, possiblePath) - if _, errModeline := os.Stat(fullPath); errModeline == nil { - relPath = possiblePath - } - } else { - // File exists, but check if there's a shorter/canonical path - // For example, if we have "nodes/templates/controlplane.yaml" but file is actually in "templates/controlplane.yaml" - templateName := filepath.Base(templatePath) - canonicalPath := filepath.Join("templates", templateName) - canonicalFullPath := filepath.Join(absRootDirModeline, canonicalPath) - // Check if canonical path exists and points to the same file - if canonicalInfo, errModeline := os.Stat(canonicalFullPath); errModeline == nil { - if originalInfo, errModeline := os.Stat(absTemplatePath); errModeline == nil { - // Check if they point to the same file (same inode on Unix) - if os.SameFile(originalInfo, canonicalInfo) { - // Use canonical path (shorter, cleaner) - relPath = canonicalPath - } - } - } - } - } - // Normalize path separators for cross-platform compatibility - templatePathsForModeline[i] = engine.NormalizeTemplatePath(relPath) + + return out + } + + for i, templatePath := range templateFiles { + out[i] = engine.NormalizeTemplatePath(modelinePathFor(templatePath, absRootDir)) + } + + return out +} + +// modelinePathFor returns the path that should be embedded in the +// modeline for a single template entry. Resolves the path relative +// to rootDir, handles outside-root inputs by falling back to a +// canonical templates/, and prefers a canonical +// templates/ when an inside-root absolute path resolves to +// the same file. Returns the original path on any unrecoverable +// error. +func modelinePathFor(templatePath, absRootDir string) string { + absTemplatePath, ok := absTemplatePathFor(templatePath) + if !ok { + return templatePath + } + + relPath, err := filepath.Rel(absRootDir, absTemplatePath) + if err != nil { + return templatePath + } + + relPath = filepath.Clean(relPath) + if isOutsideRoot(relPath) { + fallback, found := findOutsideRootFallback(templatePath, absRootDir) + if !found { + return templatePath } + + return fallback + } + + return canonicalizeInsideRootPath(templatePath, absTemplatePath, absRootDir, relPath) +} + +// absTemplatePathFor resolves templatePath to an absolute filesystem +// path. Returns ok=false if the input is relative and cannot be +// promoted to absolute. +func absTemplatePathFor(templatePath string) (string, bool) { + if filepath.IsAbs(templatePath) { + return templatePath, true } - modeline, err := modeline.GenerateModeline(GlobalArgs.Nodes, GlobalArgs.Endpoints, templatePathsForModeline) + abs, err := filepath.Abs(templatePath) if err != nil { - return "", fmt.Errorf("failed to generate modeline: %w", err) + return "", false } - warn := "# THIS FILE IS AUTOGENERATED. PREFER TEMPLATE EDITS OVER MANUAL ONES." - output := fmt.Sprintf("%s\n%s\n%s\n", modeline, warn, string(result)) - return output, nil + return abs, true +} + +// findOutsideRootFallback handles the case where templatePath +// resolves outside absRootDir. It looks for a file with the same +// basename under /templates/ or /, returning the +// matching relative path if found. +func findOutsideRootFallback(templatePath, absRootDir string) (string, bool) { + templateName := filepath.Base(templatePath) + for _, possiblePath := range []string{filepath.Join("templates", templateName), templateName} { + _, err := os.Stat(filepath.Join(absRootDir, possiblePath)) + if err == nil { + return possiblePath, true + } + } + + return "", false +} + +// canonicalizeInsideRootPath, given an inside-root templatePath, +// picks the cleanest path to embed in the modeline. If templatePath +// does not exist on disk but a sibling under +// /templates/ does, the latter is used. If both +// exist and point to the same file (e.g. +// nodes/templates/controlplane.yaml symlinked to +// templates/controlplane.yaml), the shorter canonical form wins. +func canonicalizeInsideRootPath(templatePath, absTemplatePath, absRootDir, relPath string) string { + templateName := filepath.Base(templatePath) + canonicalPath := filepath.Join("templates", templateName) + canonicalFullPath := filepath.Join(absRootDir, canonicalPath) + + _, err := os.Stat(absTemplatePath) + if err != nil { + _, statErr := os.Stat(canonicalFullPath) + if statErr == nil { + return canonicalPath + } + + return relPath + } + + canonicalInfo, err := os.Stat(canonicalFullPath) + if err != nil { + return relPath + } + + originalInfo, err := os.Stat(absTemplatePath) + if err != nil { + return relPath + } + + if os.SameFile(originalInfo, canonicalInfo) { + return canonicalPath + } + + return relPath } func init() { @@ -371,11 +472,13 @@ func init() { // machine config embeds certs, PKI keys, and cluster join tokens — // exactly the material that must not end up readable by other users // on Windows (inherited DACL) or Unix (0o644). -func writeInplaceRendered(configFile string, output string) error { +func writeInplaceRendered(configFile, output string) error { if err := secureperm.WriteFile(configFile, []byte(output)); err != nil { - return fmt.Errorf("failed to write file %s: %w", configFile, err) + return errors.Wrapf(err, "failed to write file %s", configFile) } + _, _ = fmt.Fprintf(os.Stderr, "Updated.\n") + return nil } @@ -395,43 +498,68 @@ func writeInplaceRendered(configFile string, output string) error { // observable at integration time. func resolveEngineTemplatePaths(templateFiles []string, rootDir string) []string { resolved := make([]string, len(templateFiles)) + absRootDir, rootErr := filepath.Abs(rootDir) if rootErr != nil { for i, p := range templateFiles { resolved[i] = engine.NormalizeTemplatePath(p) } + return resolved } + for i, templatePath := range templateFiles { var absTemplatePath string if filepath.IsAbs(templatePath) { absTemplatePath = templatePath } else { var absErr error + absTemplatePath, absErr = filepath.Abs(templatePath) if absErr != nil { resolved[i] = engine.NormalizeTemplatePath(templatePath) + continue } } + relPath, relErr := filepath.Rel(absRootDir, absTemplatePath) if relErr != nil { resolved[i] = engine.NormalizeTemplatePath(templatePath) + continue } + relPath = filepath.Clean(relPath) if isOutsideRoot(relPath) { + // findOutsideRootFallback (the modeline-side path generator) + // emits either templates/ or root-level . + // The resolver must accept both shapes for round-trip parity: + // a modeline written from a root-level fallback would otherwise + // be unreachable on the next read. templateName := filepath.Base(templatePath) - possiblePath := filepath.Join("templates", templateName) - fullPath := filepath.Join(absRootDir, possiblePath) - if _, statErr := os.Stat(fullPath); statErr == nil { - relPath = possiblePath - } else { + + var found bool + + for _, possiblePath := range []string{filepath.Join("templates", templateName), templateName} { + fullPath := filepath.Join(absRootDir, possiblePath) + if _, statErr := os.Stat(fullPath); statErr == nil { + relPath = possiblePath + found = true + + break + } + } + + if !found { resolved[i] = engine.NormalizeTemplatePath(templatePath) + continue } } + resolved[i] = engine.NormalizeTemplatePath(relPath) } + return resolved } diff --git a/pkg/commands/template_test.go b/pkg/commands/template_test.go index 437ac455..c8977d88 100644 --- a/pkg/commands/template_test.go +++ b/pkg/commands/template_test.go @@ -52,20 +52,13 @@ func TestResolveEngineTemplatePaths_DotDotPrefixedDir(t *testing.T) { t.Fatalf("seed templates/controlplane.yaml: %v", err) } - origCwd, err := os.Getwd() - if err != nil { - t.Fatalf("getwd: %v", err) - } - if err := os.Chdir(rootDir); err != nil { - t.Fatalf("chdir: %v", err) - } - t.Cleanup(func() { _ = os.Chdir(origCwd) }) + t.Chdir(rootDir) - got := resolveEngineTemplatePaths([]string{"..templates/controlplane.yaml"}, rootDir) + got := resolveEngineTemplatePaths([]string{testTemplateControlplane}, rootDir) if len(got) != 1 { t.Fatalf("len = %d, want 1", len(got)) } - if got[0] != "..templates/controlplane.yaml" { - t.Errorf("got %q, want %q (the ..templates dir was misclassified as outside-root and routed through the basename fallback)", got[0], "..templates/controlplane.yaml") + if got[0] != testTemplateControlplane { + t.Errorf("got %q, want %q (the ..templates dir was misclassified as outside-root and routed through the basename fallback)", got[0], testTemplateControlplane) } } diff --git a/pkg/commands/template_windows_test.go b/pkg/commands/template_windows_test.go index 6e3a546c..124ce379 100644 --- a/pkg/commands/template_windows_test.go +++ b/pkg/commands/template_windows_test.go @@ -59,15 +59,9 @@ func TestResolveEngineTemplatePaths_BackslashInput(t *testing.T) { // Chdir so relative paths resolve against rootDir exactly as they // would when a user cd's into their project directory and runs - // `talm template -t templates\controlplane.yaml`. - origCwd, err := os.Getwd() - if err != nil { - t.Fatalf("getwd: %v", err) - } - if err := os.Chdir(rootDir); err != nil { - t.Fatalf("chdir: %v", err) - } - t.Cleanup(func() { _ = os.Chdir(origCwd) }) + // `talm template -t templates\controlplane.yaml`. t.Chdir handles + // the cleanup itself. + t.Chdir(rootDir) inputs := []string{ `templates\controlplane.yaml`, diff --git a/pkg/commands/upgrade_handler.go b/pkg/commands/upgrade_handler.go index aa7849ac..9c5a4366 100644 --- a/pkg/commands/upgrade_handler.go +++ b/pkg/commands/upgrade_handler.go @@ -15,20 +15,23 @@ package commands import ( - "context" "fmt" "os" + "github.com/cockroachdb/errors" "github.com/cozystack/talm/pkg/engine" "github.com/siderolabs/talos/pkg/machinery/config/configloader" "github.com/spf13/cobra" ) // wrapUpgradeCommand adds special handling for upgrade command: extract image from config and set --image flag +// +//nolint:gocognit,gocyclo,cyclop,funlen,nestif // cobra wrapper branching over (image extraction, file paths, modeline) for the upgrade flow; each branch is short. func wrapUpgradeCommand(wrappedCmd *cobra.Command, originalRunE func(*cobra.Command, []string) error) { wrappedCmd.RunE = func(cmd *cobra.Command, args []string) error { // Get config files from --file flag var filesToProcess []string + if fileFlag := cmd.Flags().Lookup("file"); fileFlag != nil { if fileFlagValue, err := cmd.Flags().GetStringSlice("file"); err == nil { filesToProcess = fileFlagValue @@ -40,6 +43,7 @@ func wrapUpgradeCommand(wrappedCmd *cobra.Command, originalRunE func(*cobra.Comm if err != nil { return err } + filesToProcess = expandedFiles // Detect root from files if specified, otherwise fallback to cwd @@ -54,13 +58,15 @@ func wrapUpgradeCommand(wrappedCmd *cobra.Command, originalRunE func(*cobra.Comm // Process modeline to update GlobalArgs nodesFromArgs := len(GlobalArgs.Nodes) > 0 + endpointsFromArgs := len(GlobalArgs.Endpoints) > 0 if _, err := processModelineAndUpdateGlobals(configFile, nodesFromArgs, endpointsFromArgs, true); err != nil { - return fmt.Errorf("failed to process modeline: %w", err) + return errors.Wrap(err, "failed to process modeline") } // Get talos-version, with-secrets, kubernetes-version from flags or config talosVersion := Config.TemplateOptions.TalosVersion + if cmd.Flags().Changed("talos-version") { if val, err := cmd.Flags().GetString("talos-version"); err == nil { talosVersion = val @@ -68,6 +74,7 @@ func wrapUpgradeCommand(wrappedCmd *cobra.Command, originalRunE func(*cobra.Comm } withSecrets := Config.TemplateOptions.WithSecrets + if cmd.Flags().Changed("with-secrets") { if val, err := cmd.Flags().GetString("with-secrets"); err == nil { withSecrets = val @@ -78,6 +85,7 @@ func wrapUpgradeCommand(wrappedCmd *cobra.Command, originalRunE func(*cobra.Comm withSecrets = ResolveSecretsPath(withSecrets) kubernetesVersion := Config.TemplateOptions.KubernetesVersion + if cmd.Flags().Changed("kubernetes-version") { if val, err := cmd.Flags().GetString("kubernetes-version"); err == nil { kubernetesVersion = val @@ -85,7 +93,6 @@ func wrapUpgradeCommand(wrappedCmd *cobra.Command, originalRunE func(*cobra.Comm } // Process config to extract image - ctx := context.Background() eopts := engine.Options{ TalosVersion: talosVersion, WithSecrets: withSecrets, @@ -93,24 +100,25 @@ func wrapUpgradeCommand(wrappedCmd *cobra.Command, originalRunE func(*cobra.Comm } patches := []string{"@" + configFile} - configBundle, machineType, err := engine.FullConfigProcess(ctx, eopts, patches) + + configBundle, machineType, err := engine.FullConfigProcess(eopts, patches) if err != nil { - return fmt.Errorf("full config processing error: %s", err) + return errors.Wrap(err, "full config processing error") } result, err := engine.SerializeConfiguration(configBundle, machineType) if err != nil { - return fmt.Errorf("error serializing configuration: %s", err) + return errors.Wrap(err, "error serializing configuration") } config, err := configloader.NewFromBytes(result) if err != nil { - return fmt.Errorf("error loading config: %w", err) + return errors.Wrap(err, "error loading config") } image := config.Machine().Install().Image() if image == "" { - return fmt.Errorf("error getting image from config") + return errors.New("error getting image from config") } // Set --image flag with extracted image @@ -127,9 +135,10 @@ func wrapUpgradeCommand(wrappedCmd *cobra.Command, originalRunE func(*cobra.Comm return originalRunE(cmd, args) } else if wrappedCmd.Run != nil { wrappedCmd.Run(cmd, args) + return nil } + return nil } } - diff --git a/pkg/engine/contract_debugphase_test.go b/pkg/engine/contract_debugphase_test.go index 9be136c4..ef707de9 100644 --- a/pkg/engine/contract_debugphase_test.go +++ b/pkg/engine/contract_debugphase_test.go @@ -56,7 +56,7 @@ func TestContract_DebugPhase_TolerantOfEmptyPatch(t *testing.T) { return // unreachable, debugPhase calls os.Exit(0) } - cmd := exec.Command(os.Args[0], "-test.run=^TestContract_DebugPhase_TolerantOfEmptyPatch$") + cmd := exec.CommandContext(t.Context(), os.Args[0], "-test.run=^TestContract_DebugPhase_TolerantOfEmptyPatch$") cmd.Env = append(os.Environ(), "TEST_DEBUG_PHASE_HELPER=1") out, err := cmd.CombinedOutput() if err != nil { @@ -93,7 +93,7 @@ func TestContract_DebugPhase_HandlesAllEmpty(t *testing.T) { return } - cmd := exec.Command(os.Args[0], "-test.run=^TestContract_DebugPhase_HandlesAllEmpty$") + cmd := exec.CommandContext(t.Context(), os.Args[0], "-test.run=^TestContract_DebugPhase_HandlesAllEmpty$") cmd.Env = append(os.Environ(), "TEST_DEBUG_PHASE_HELPER_ALL_EMPTY=1") out, err := cmd.CombinedOutput() if err != nil { diff --git a/pkg/engine/contract_errors_test.go b/pkg/engine/contract_errors_test.go index 342fe5d9..0642d892 100644 --- a/pkg/engine/contract_errors_test.go +++ b/pkg/engine/contract_errors_test.go @@ -70,6 +70,7 @@ func renderExpectingError(t *testing.T, chartPath, talosVersion string, lookup f "Values": merged, "TalosVersion": talosVersion, }) + //nolint:wrapcheck // helm.Engine.Render returns a typed error; tests assert via require.Error/NoError. return err } @@ -160,7 +161,7 @@ func legacyInterfacesLookup() func(string, string, string) (map[string]any, erro }, }, } - return func(resource, namespace, id string) (map[string]any, error) { + return func(resource, _, id string) (map[string]any, error) { if resource == "machineconfig" && id == "v1alpha1" { return machineconfig, nil } @@ -253,7 +254,7 @@ func bridgeAsGatewayLookup() func(string, string, string) (map[string]any, error }, }, } - return func(resource, namespace, id string) (map[string]any, error) { + return func(resource, _, id string) (map[string]any, error) { switch resource { case "links": if id == "br0" { @@ -342,7 +343,7 @@ func vlanMissingParentLookup() func(string, string, string) (map[string]any, err }, }, } - return func(resource, namespace, id string) (map[string]any, error) { + return func(resource, _, id string) (map[string]any, error) { switch resource { case "links": if id == "vlan100" { @@ -410,7 +411,7 @@ func vlanMissingVlanIDLookup() func(string, string, string) (map[string]any, err "kind": "vlan", "index": 20, "linkIndex": 1, // points at parent eth0 - "vlan": map[string]any{ + "vlan": map[string]any{ // vlanID intentionally absent }, }, @@ -435,7 +436,7 @@ func vlanMissingVlanIDLookup() func(string, string, string) (map[string]any, err }, }, } - return func(resource, namespace, id string) (map[string]any, error) { + return func(resource, _, id string) (map[string]any, error) { switch resource { case "links": if id == "eth0" { diff --git a/pkg/engine/contract_extractresource_test.go b/pkg/engine/contract_extractresource_test.go index 491ec669..a1cca7c8 100644 --- a/pkg/engine/contract_extractresource_test.go +++ b/pkg/engine/contract_extractresource_test.go @@ -34,6 +34,7 @@ import ( // so test bodies stay tight. func makeProtoResource(t *testing.T, namespace, kind, id, yamlSpec string) (*cosiproto.Resource, error) { t.Helper() + //nolint:wrapcheck // protobuf.Unmarshal returns a typed error verified by the test's require.Error/NoError contract. return cosiproto.Unmarshal(&cosiv1alpha1.Resource{ Metadata: &cosiv1alpha1.Metadata{ Namespace: namespace, diff --git a/pkg/engine/contract_loadvalues_test.go b/pkg/engine/contract_loadvalues_test.go index a6191aff..d9484844 100644 --- a/pkg/engine/contract_loadvalues_test.go +++ b/pkg/engine/contract_loadvalues_test.go @@ -149,10 +149,12 @@ func TestContract_LoadValues_ValueFilesOrderPrecedence(t *testing.T) { dir := t.TempDir() first := filepath.Join(dir, "first.yaml") second := filepath.Join(dir, "second.yaml") - if err := os.WriteFile(first, []byte("a: 1\nshared: from-first\n"), 0o644); err != nil { + err := os.WriteFile(first, []byte("a: 1\nshared: from-first\n"), 0o644) + if err != nil { t.Fatal(err) } - if err := os.WriteFile(second, []byte("b: 2\nshared: from-second\n"), 0o644); err != nil { + err = os.WriteFile(second, []byte("b: 2\nshared: from-second\n"), 0o644) + if err != nil { t.Fatal(err) } out, err := loadValues(Options{ValueFiles: []string{first, second}}) @@ -173,7 +175,8 @@ func TestContract_LoadValues_ValueFilesOrderPrecedence(t *testing.T) { func TestContract_LoadValues_JsonValues(t *testing.T) { dir := t.TempDir() file := filepath.Join(dir, "v.yaml") - if err := os.WriteFile(file, []byte("a: 1\n"), 0o644); err != nil { + err := os.WriteFile(file, []byte("a: 1\n"), 0o644) + if err != nil { t.Fatal(err) } out, err := loadValues(Options{ diff --git a/pkg/engine/contract_machine_test.go b/pkg/engine/contract_machine_test.go index 418424f0..9b74da16 100644 --- a/pkg/engine/contract_machine_test.go +++ b/pkg/engine/contract_machine_test.go @@ -238,14 +238,14 @@ func TestContract_Machine_CertSANs_LoopbackUnconditional_Cozystack(t *testing.T) } // Contract: cozystack emits two `machine.files[]` entries: -// 1. /etc/cri/conf.d/20-customization.part — sets -// device_ownership_from_security_context = true on both legacy -// (io.containerd.grpc.v1.cri) and v2 (io.containerd.cri.v1.runtime) -// plugin paths. Required for SR-IOV / GPU device plugins to -// surface inside privileged containers. -// 2. /etc/lvm/lvm.conf — disables LVM backup/archive and sets a -// global_filter that excludes drbd, dm-, zd- devices. Required so -// LVM does not race the storage stack at boot. +// 1. /etc/cri/conf.d/20-customization.part — sets +// device_ownership_from_security_context = true on both legacy +// (io.containerd.grpc.v1.cri) and v2 (io.containerd.cri.v1.runtime) +// plugin paths. Required for SR-IOV / GPU device plugins to +// surface inside privileged containers. +// 2. /etc/lvm/lvm.conf — disables LVM backup/archive and sets a +// global_filter that excludes drbd, dm-, zd- devices. Required so +// LVM does not race the storage stack at boot. // // Both files use op: create / op: overwrite respectively. A regression // removing either silently breaks GPU/SRIOV or LVM ordering. diff --git a/pkg/engine/contract_network_legacy_test.go b/pkg/engine/contract_network_legacy_test.go index e4cdc1ed..aad3018d 100644 --- a/pkg/engine/contract_network_legacy_test.go +++ b/pkg/engine/contract_network_legacy_test.go @@ -43,7 +43,7 @@ func renderLegacyCozystackControlplane(t *testing.T, lookup func(string, string, // (quoted) and nameservers (JSON array). hostname uses the same // discovery / placeholder fallback as the multi-doc path. func TestContract_NetworkLegacy_HostnameAndNameserversAlwaysEmitted(t *testing.T) { - lookup := func(resource, namespace, id string) (map[string]any, error) { + lookup := func(resource, _, id string) (map[string]any, error) { switch { case resource == "hostname" && id == "hostname": return map[string]any{"spec": map[string]any{"hostname": "node-prod-1"}}, nil diff --git a/pkg/engine/contract_network_multidoc_test.go b/pkg/engine/contract_network_multidoc_test.go index 54b65389..ff9f52f8 100644 --- a/pkg/engine/contract_network_multidoc_test.go +++ b/pkg/engine/contract_network_multidoc_test.go @@ -48,7 +48,7 @@ import ( // localhost, localhost.localdomain). Operators who set a meaningful // hostname on the host get it surfaced in the rendered config. func TestContract_NetworkMultidoc_HostnameUsesDiscoveredName(t *testing.T) { - lookup := func(resource, namespace, id string) (map[string]any, error) { + lookup := func(resource, _, id string) (map[string]any, error) { if resource == "hostname" && id == "hostname" { return map[string]any{ "spec": map[string]any{"hostname": "node-prod-1"}, @@ -72,7 +72,7 @@ func TestContract_NetworkMultidoc_HostnameUsesDiscoveredName(t *testing.T) { // survives changes to the hashed input. func TestContract_NetworkMultidoc_HostnameFallbackToSynthesized(t *testing.T) { cases := []struct { - name string + name string discoveredHostname string }{ {"placeholder/talos", "talos"}, @@ -82,7 +82,7 @@ func TestContract_NetworkMultidoc_HostnameFallbackToSynthesized(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - lookup := func(resource, namespace, id string) (map[string]any, error) { + lookup := func(resource, _, id string) (map[string]any, error) { if resource == "hostname" && id == "hostname" && tc.discoveredHostname != "" { return map[string]any{ "spec": map[string]any{"hostname": tc.discoveredHostname}, @@ -107,7 +107,7 @@ func TestContract_NetworkMultidoc_HostnameFallbackToSynthesized(t *testing.T) { // resolvers are unknown. The empty fallback keeps the document // well-formed; Talos accepts empty nameservers (DHCP-supplied). func TestContract_NetworkMultidoc_ResolverConfigPopulated(t *testing.T) { - lookup := func(resource, namespace, id string) (map[string]any, error) { + lookup := func(resource, _, id string) (map[string]any, error) { if resource == "resolvers" && id == "resolvers" { return map[string]any{ "spec": map[string]any{ diff --git a/pkg/engine/contract_render_test.go b/pkg/engine/contract_render_test.go index 6e995d72..f77cb5c3 100644 --- a/pkg/engine/contract_render_test.go +++ b/pkg/engine/contract_render_test.go @@ -266,7 +266,7 @@ func TestContract_SerializeConfiguration_ControlplaneVsWorker(t *testing.T) { // machine.TypeUnknown — a reduced state operators do not normally // reach. func TestContract_FullConfigProcess_NoPatchesUsesBundleDefault(t *testing.T) { - bundle, mtype, err := FullConfigProcess(context.Background(), Options{}, nil) + bundle, mtype, err := FullConfigProcess(Options{}, nil) if err != nil { t.Fatalf("FullConfigProcess: %v", err) } @@ -286,7 +286,7 @@ func TestContract_FullConfigProcess_NoPatchesUsesBundleDefault(t *testing.T) { // own machineType inference does not interfere. func TestContract_FullConfigProcess_ControlplaneFromPatch(t *testing.T) { patch := "machine:\n type: controlplane\n" - _, mtype, err := FullConfigProcess(context.Background(), Options{}, []string{patch}) + _, mtype, err := FullConfigProcess(Options{}, []string{patch}) if err != nil { t.Fatalf("FullConfigProcess: %v", err) } @@ -300,7 +300,7 @@ func TestContract_FullConfigProcess_ControlplaneFromPatch(t *testing.T) { // silently swallows malformed patches surfaces here. func TestContract_FullConfigProcess_MalformedPatchError(t *testing.T) { bad := "this is not valid YAML\n : :" - _, _, err := FullConfigProcess(context.Background(), Options{}, []string{bad}) + _, _, err := FullConfigProcess(Options{}, []string{bad}) if err == nil { t.Fatal("expected error for malformed patch") } @@ -309,7 +309,7 @@ func TestContract_FullConfigProcess_MalformedPatchError(t *testing.T) { // Contract: FullConfigProcess with a malformed TalosVersion option // surfaces InitializeConfigBundle's error path. func TestContract_FullConfigProcess_BadTalosVersionError(t *testing.T) { - _, _, err := FullConfigProcess(context.Background(), Options{TalosVersion: "garbage"}, nil) + _, _, err := FullConfigProcess(Options{TalosVersion: "garbage"}, nil) if err == nil { t.Fatal("expected error") } diff --git a/pkg/engine/contract_yaml_encoder_test.go b/pkg/engine/contract_yaml_encoder_test.go new file mode 100644 index 00000000..0758d500 --- /dev/null +++ b/pkg/engine/contract_yaml_encoder_test.go @@ -0,0 +1,141 @@ +// Copyright Cozystack Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package engine + +import ( + "bytes" + "errors" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +// errFakeWriterFailure is the sentinel returned by alwaysFailWriter +// once its budget is exhausted. Distinct from any error +// gopkg.in/yaml.v3 may produce so the assertion below can verify +// the wrap chain reaches the underlying writer. +var errFakeWriterFailure = errors.New("fake writer failure") + +// alwaysFailWriter returns errFakeWriterFailure on every Write +// call. yaml.Encoder.Encode fails on its first emit attempt, so +// this drives the encode-error branch of encodeYAMLNodeIndented. +type alwaysFailWriter struct{} + +func (alwaysFailWriter) Write(_ []byte) (int, error) { + return 0, errFakeWriterFailure +} + +// TestEncodeYAMLNodeIndented_WrapsEncodeError pins the +// encode-error wrap chain. yaml.Encoder.Encode emits the document +// body during the call, so an immediate-fail writer surfaces the +// failure on Encode and encodeYAMLNodeIndented must wrap it as +// "encoding target config". +func TestEncodeYAMLNodeIndented_WrapsEncodeError(t *testing.T) { + node := &yaml.Node{ + Kind: yaml.MappingNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "foo"}, + {Kind: yaml.ScalarNode, Value: "bar"}, + }, + } + + err := encodeYAMLNodeIndented(alwaysFailWriter{}, node) + if err == nil { + t.Fatal("expected error from alwaysFailWriter, got nil") + } + if !strings.Contains(err.Error(), "encoding target config") { + t.Errorf("expected wrap with %q, got %v", "encoding target config", err) + } + // gopkg.in/yaml.v3 wraps the underlying writer error as + // "yaml: write error: " via fmt.Errorf with %v + // rather than %w, so errors.Is does not chain through. Assert + // on the message substring instead — drops to a string check + // only because of the upstream wrapping idiom, not because the + // wrap chain is loose at our boundary. + if !strings.Contains(err.Error(), errFakeWriterFailure.Error()) { + t.Errorf("error message does not surface underlying writer failure %q; got %v", errFakeWriterFailure.Error(), err) + } +} + +// TestEncodeYAMLNodeIndented_HappyPath pins the success contract +// AND the 2-space indent the helper installs via SetIndent(2). A +// nested mapping is used because indentation only manifests at +// nesting depth ≥ 1: top-level keys never carry leading spaces +// regardless of the indent setting, so a flat mapping cannot +// distinguish 2-space from 4-space output. The nested shape pins +// both the canonical " inner: leaf" line and the absence of any +// 4-space-indented variant — guarding against a refactor that +// swaps SetIndent(2) for SetIndent(4) or drops the call entirely +// (yaml.v3 default is 4). +func TestEncodeYAMLNodeIndented_HappyPath(t *testing.T) { + node := &yaml.Node{ + Kind: yaml.MappingNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "outer"}, + { + Kind: yaml.MappingNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "inner"}, + {Kind: yaml.ScalarNode, Value: "leaf"}, + }, + }, + }, + } + + var buf bytes.Buffer + if err := encodeYAMLNodeIndented(&buf, node); err != nil { + t.Fatalf("encodeYAMLNodeIndented: %v", err) + } + + got := buf.String() + + // The nested key must appear with exactly two leading spaces. + // "\n inner: leaf" — anchored on the preceding newline so a + // stray " inner: leaf" (4-space indent) does not satisfy + // the substring match by accident. + const want2Space = "\n inner: leaf" + if !strings.Contains(got, want2Space) { + t.Errorf("encoded output missing expected 2-space indented %q in:\n%s", want2Space, got) + } + + // Reject a 4-space match outright. yaml.v3's default indent + // is 4, so this catches the regression that drops + // SetIndent(2). + if strings.Contains(got, "\n inner: leaf") { + t.Errorf("encoded output uses 4-space indent (yaml.v3 default), want 2-space:\n%s", got) + } +} + +// Note on the close-error branch: +// +// encodeYAMLNodeIndented also wraps yaml.Encoder.Close failures as +// "closing target config encoder", matching the sister sites at +// engine.go:585 ("closing YAML encoder after stripping +// $patch:delete directives") and engine.go:765 ("closing encoder +// for pruned body"). That branch is NOT covered by a runtime test +// because gopkg.in/yaml.v3's Encoder.Close performs no Write call +// on the underlying writer for any input the engine produces in +// practice — the document body and document-end marker both go +// out during Encode, leaving Close as a stream-finalisation no-op +// against any Write-succeeding writer (verified empirically with a +// 1024-key mapping and a multi-doc stream — both cases yield zero +// writes during Close). Triggering a real Close failure would +// require either a yaml.v3 internal-state hack (not supported) or +// substituting yaml.Encoder for a mock (rejected as architectural +// scope). The wrap stays in production code as defensive symmetry +// with the sister sites; a pin lives in this comment so a future +// reader does not interpret the absence of a runtime test as +// confidence that the path is dead. diff --git a/pkg/engine/engine.go b/pkg/engine/engine.go index 522c01c4..26a67186 100644 --- a/pkg/engine/engine.go +++ b/pkg/engine/engine.go @@ -46,7 +46,7 @@ type Options struct { StringValues []string Values []string FileValues []string - JsonValues []string + JsonValues []string `yaml:"jsonValues"` //nolint:revive // public field name kept for backwards compatibility with existing consumers in pkg/commands/template.go and Chart.yaml LiteralValues []string TalosVersion string WithSecrets string @@ -72,18 +72,22 @@ func NormalizeTemplatePath(p string) string { // debugPhase is a unified debug function that prints debug information based on the given stage and context, // then exits the program. +// +//nolint:gocritic // hugeParam: Options carries flag-aggregated config and is consumed read-only along the debug path; converting to a pointer here would require changing every public signature in this file (Render, InitializeConfigBundle, FullConfigProcess) without runtime benefit, since debugPhase exits the process. func debugPhase(opts Options, patches []string, clusterName string, clusterEndpoint string, mType machine.Type) { phase := 2 + if clusterName == "" { clusterName = "dummy" phase = 1 } + if clusterEndpoint == "" { clusterEndpoint = "clusterEndpoint" phase = 1 } - fmt.Printf( + fmt.Fprintf(os.Stdout, "# DEBUG(phase %d): talosctl gen config %s %s -t %s --with-secrets=%s --talos-version=%s --kubernetes-version=%s -o -", phase, clusterName, clusterEndpoint, mType, opts.WithSecrets, opts.TalosVersion, opts.KubernetesVersion, @@ -102,12 +106,12 @@ func debugPhase(opts Options, patches []string, clusterName string, clusterEndpo if patch == "" { continue } + if patch[0] == '@' { // Apply patch is always one - fmt.Printf(" %s=%s\n", patchOption, patch) + fmt.Fprintf(os.Stdout, " %s=%s\n", patchOption, patch) } else { - fmt.Printf("\n---") - fmt.Printf("\n# DEBUG(phase %d): %s=\n%s", phase, patchOption, patch) + fmt.Fprintf(os.Stdout, "\n---\n# DEBUG(phase %d): %s=\n%s", phase, patchOption, patch) } } @@ -115,10 +119,18 @@ func debugPhase(opts Options, patches []string, clusterName string, clusterEndpo } // FullConfigProcess handles the full process of creating and updating the Bundle. -func FullConfigProcess(ctx context.Context, opts Options, patches []string) (*bundle.Bundle, machine.Type, error) { +// +// The function performs no I/O that would respect a context; the +// ctx parameter that callers used to pass in was always discarded +// inside. Dropping the parameter makes the contract honest. If a +// future caller needs cancellation (e.g. a future remote +// configpatcher), reintroduce it as a typed first argument. +// +//nolint:gocritic // hugeParam: Options is the package's public facing configuration carrier; converting this to a pointer would propagate the change across every caller in pkg/commands and break the API for external consumers. +func FullConfigProcess(opts Options, patches []string) (*bundle.Bundle, machine.Type, error) { configBundle, err := InitializeConfigBundle(opts) if err != nil { - return nil, machine.TypeUnknown, fmt.Errorf("initial config bundle error: %w", err) + return nil, machine.TypeUnknown, errors.Wrap(err, "initial config bundle error") } loadedPatches, err := configpatcher.LoadPatches(patches) @@ -126,7 +138,8 @@ func FullConfigProcess(ctx context.Context, opts Options, patches []string) (*bu if opts.Debug { debugPhase(opts, patches, "", "", machine.TypeUnknown) } - return nil, machine.TypeUnknown, err + + return nil, machine.TypeUnknown, errors.Wrap(err, "loading patches") } err = configBundle.ApplyPatches(loadedPatches, true, false) @@ -134,7 +147,8 @@ func FullConfigProcess(ctx context.Context, opts Options, patches []string) (*bu if opts.Debug { debugPhase(opts, patches, "", "", machine.TypeUnknown) } - return nil, machine.TypeUnknown, fmt.Errorf("apply initial patches error: %w", err) + + return nil, machine.TypeUnknown, errors.Wrap(err, "apply initial patches error") } // Updating parameters after applying patches @@ -158,37 +172,42 @@ func FullConfigProcess(ctx context.Context, opts Options, patches []string) (*bu ClusterName: clusterName, Endpoint: clusterEndpoint.String(), } + configBundle, err = InitializeConfigBundle(updatedOpts) if err != nil { - return nil, machineType, fmt.Errorf("reinit config bundle error: %w", err) + return nil, machineType, errors.Wrap(err, "reinit config bundle error") } // Applying updated patches err = configBundle.ApplyPatches(loadedPatches, (machineType == machine.TypeControlPlane), (machineType == machine.TypeWorker)) if err != nil { - return nil, machineType, fmt.Errorf("apply updated patches error: %w", err) + return nil, machineType, errors.Wrap(err, "apply updated patches error") } return configBundle, machineType, nil } -// Function to initialize configuration settings +// InitializeConfigBundle initializes a Talos configuration bundle from opts. +// +//nolint:gocritic // hugeParam: Options is the package's public facing configuration carrier; converting this to a pointer would propagate the change across every caller in pkg/commands and break the API for external consumers. func InitializeConfigBundle(opts Options) (*bundle.Bundle, error) { genOptions := []generate.Option{} if opts.TalosVersion != "" { versionContract, err := config.ParseContractFromVersion(opts.TalosVersion) if err != nil { - return nil, fmt.Errorf("invalid talos-version: %w", err) + return nil, errors.Wrap(err, "invalid talos-version") } + genOptions = append(genOptions, generate.WithVersionContract(versionContract)) } if opts.WithSecrets != "" { secretsBundle, err := secrets.LoadBundle(opts.WithSecrets) if err != nil { - return nil, fmt.Errorf("failed to load secrets bundle: %w", err) + return nil, errors.Wrap(err, "failed to load secrets bundle") } + genOptions = append(genOptions, generate.WithSecretsBundle(secretsBundle)) } @@ -204,12 +223,22 @@ func InitializeConfigBundle(opts Options) (*bundle.Bundle, error) { bundle.WithVerbose(false), } - return bundle.NewBundle(configBundleOpts...) + configBundle, err := bundle.NewBundle(configBundleOpts...) + if err != nil { + return nil, errors.Wrap(err, "creating config bundle") + } + + return configBundle, nil } -// Function for serializing the configuration +// SerializeConfiguration serializes the configuration bundle for machineType. func SerializeConfiguration(configBundle *bundle.Bundle, machineType machine.Type) ([]byte, error) { - return configBundle.Serialize(encoder.CommentsDisabled, machineType) + out, err := configBundle.Serialize(encoder.CommentsDisabled, machineType) + if err != nil { + return nil, errors.Wrap(err, "serializing config bundle") + } + + return out, nil } // MergeFileAsPatch overlays the YAML body of patchFile onto rendered using @@ -230,73 +259,81 @@ func SerializeConfiguration(configBundle *bundle.Bundle, machineType machine.Typ // ApplyConfiguration) but unsuitable for human-facing output such as // `talm template` — which is why the template subcommand does not call // this helper. +// +//nolint:funlen // 73 lines: each step (read, hint, strip three classes of patch directives, prune identities, apply, decode, encode) is a single linear pipeline; extracting helpers would scatter the contextual error wrapping across files without simplifying the algorithm. func MergeFileAsPatch(rendered []byte, patchFile string) ([]byte, error) { patchBytes, err := os.ReadFile(patchFile) if err != nil { - return nil, errors.WithHint( - errors.Wrapf(err, "reading patch %q", patchFile), - "verify the path is correct and the file is readable by the user running talm", + return nil, errors.Wrapf( + errors.WithHint(err, "verify the path is correct and the file is readable by the user running talm"), + "reading patch %q", patchFile, ) } + if isEffectivelyEmptyYAML(patchBytes) { return rendered, nil } + cleanedRendered, renderedDirectivePaths, err := stripAllPatchDeleteDirectives(rendered) if err != nil { - return nil, errors.WithHint( - errors.Wrap(err, "stripping $patch:delete directives from rendered"), - "the rendered template did not parse as YAML; this points at a chart-helper bug, not a user input issue", + return nil, errors.Wrap( + errors.WithHint(err, "the rendered template did not parse as YAML; this points at a chart-helper bug, not a user input issue"), + "stripping $patch:delete directives from rendered", ) } + cleanedPatch, err := stripPatchDeleteDirectivesAtPaths(patchBytes, renderedDirectivePaths) if err != nil { - return nil, errors.WithHintf( - errors.Wrapf(err, "stripping redundant $patch:delete directives from %q", patchFile), - "the node body did not parse as YAML; verify %q is well-formed", - patchFile, + return nil, errors.Wrapf( + errors.WithHintf(err, "the node body did not parse as YAML; verify %q is well-formed", patchFile), + "stripping redundant $patch:delete directives from %q", patchFile, ) } + cleanedPatch, err = stripPatchDeleteDirectivesAbsentInTarget(cleanedPatch, cleanedRendered) if err != nil { - return nil, errors.WithHintf( - errors.Wrapf(err, "stripping no-op $patch:delete directives from %q", patchFile), - "the node body did not parse as YAML; verify %q is well-formed", - patchFile, + return nil, errors.Wrapf( + errors.WithHintf(err, "the node body did not parse as YAML; verify %q is well-formed", patchFile), + "stripping no-op $patch:delete directives from %q", patchFile, ) } + prunedBytes, allPruned, err := pruneBodyIdentitiesAgainstRendered(cleanedPatch, cleanedRendered) if err != nil { - return nil, errors.WithHintf( - errors.Wrapf(err, "pruning identity overlap in %q", patchFile), - "the prune walk failed; the input is likely malformed YAML or has an unexpected document shape; inspect %q", - patchFile, + return nil, errors.Wrapf( + errors.WithHintf(err, "the prune walk failed; the input is likely malformed YAML or has an unexpected document shape; inspect %q", patchFile), + "pruning identity overlap in %q", patchFile, ) } + if allPruned { return cleanedRendered, nil } + patch, err := configpatcher.LoadPatch(prunedBytes) if err != nil { - return nil, errors.WithHint( - errors.Wrapf(err, "loading patch from %q", patchFile), - "the node body must be a Talos config (full or partial), a JSON Patch list, or a YAML patch list — see https://www.talos.dev/latest/talos-guides/configuration/patching/", + return nil, errors.Wrapf( + errors.WithHint(err, "the node body must be a Talos config (full or partial), a JSON Patch list, or a YAML patch list — see https://www.talos.dev/latest/talos-guides/configuration/patching/"), + "loading patch from %q", patchFile, ) } + out, err := configpatcher.Apply(configpatcher.WithBytes(cleanedRendered), []configpatcher.Patch{patch}) if err != nil { - return nil, errors.WithHintf( - errors.Wrapf(err, "applying patch from %q", patchFile), - "the patch references a path the rendered template does not contain; check the output of: talm template -f %q", - patchFile, + return nil, errors.Wrapf( + errors.WithHintf(err, "the patch references a path the rendered template does not contain; check the output of: talm template -f %q", patchFile), + "applying patch from %q", patchFile, ) } + merged, err := out.Bytes() if err != nil { - return nil, errors.WithHintf( - errors.Wrapf(err, "encoding merged config from %q", patchFile), - "configpatcher.Apply succeeded but the result could not be serialised back to YAML; this is internal — file an issue if reproducible", + return nil, errors.Wrapf( + errors.WithHint(err, "configpatcher.Apply succeeded but the result could not be serialised back to YAML; this is internal — file an issue if reproducible"), + "encoding merged config from %q", patchFile, ) } + return merged, nil } @@ -337,20 +374,25 @@ func stripAllPatchDeleteDirectives(data []byte) ([]byte, []string, error) { if err != nil { return nil, nil, err } + if len(docs) == 0 { return data, nil, nil } + var stripped []string for _, doc := range docs { stripped = append(stripped, removePatchDeleteFromNode(doc, "/"+documentIdentityFromNode(doc), nil)...) } + if len(stripped) == 0 { return data, nil, nil } + out, err := encodeAllYAMLDocuments(docs) if err != nil { return nil, nil, err } + return out, stripped, nil } @@ -376,24 +418,30 @@ func stripPatchDeleteDirectivesAtPaths(data []byte, paths []string) ([]byte, err if len(paths) == 0 { return data, nil } + docs, err := decodeAllYAMLDocuments(data) if err != nil { return nil, err } + if len(docs) == 0 { return data, nil } + pruneSet := make(map[string]struct{}, len(paths)) for _, p := range paths { pruneSet[p] = struct{}{} } + stripped := 0 for _, doc := range docs { stripped += len(removePatchDeleteFromNode(doc, "/"+documentIdentityFromNode(doc), pruneSet)) } + if stripped == 0 { return data, nil } + return encodeAllYAMLDocuments(docs) } @@ -425,20 +473,26 @@ func stripPatchDeleteDirectivesAbsentInTarget(data, target []byte) ([]byte, erro if err != nil { return nil, err } + if len(bodyDocs) == 0 { return data, nil } + targetDocs, err := decodeAllYAMLDocuments(target) if err != nil { return nil, err } + targetByID := make(map[string]*yaml.Node, len(targetDocs)) for _, doc := range targetDocs { targetByID[documentIdentityFromNode(doc)] = doc } + pruneSet := make(map[string]struct{}) + for _, bdoc := range bodyDocs { id := documentIdentityFromNode(bdoc) + targetDoc := targetByID[id] for _, rel := range collectDeleteDirectivePaths(bdoc, "") { if !pathExistsInDoc(targetDoc, rel) { @@ -446,16 +500,20 @@ func stripPatchDeleteDirectivesAbsentInTarget(data, target []byte) ([]byte, erro } } } + if len(pruneSet) == 0 { return data, nil } + stripped := 0 for _, doc := range bodyDocs { stripped += len(removePatchDeleteFromNode(doc, "/"+documentIdentityFromNode(doc), pruneSet)) } + if stripped == 0 { return data, nil } + return encodeAllYAMLDocuments(bodyDocs) } @@ -468,7 +526,9 @@ func collectDeleteDirectivePaths(node *yaml.Node, parentRel string) []string { if node == nil { return nil } + var found []string + switch node.Kind { case yaml.DocumentNode: for _, child := range node.Content { @@ -478,19 +538,27 @@ func collectDeleteDirectivePaths(node *yaml.Node, parentRel string) []string { for i := 0; i+1 < len(node.Content); i += 2 { keyNode := node.Content[i] valueNode := node.Content[i+1] + if keyNode.Kind != yaml.ScalarNode { continue } + childRel := joinYAMLPath(parentRel, jsonPointerEscape(keyNode.Value)) if isPatchDeleteDirective(valueNode) { found = append(found, childRel) + continue } + if valueNode.Kind == yaml.MappingNode { found = append(found, collectDeleteDirectivePaths(valueNode, childRel)...) } } + case yaml.SequenceNode, yaml.ScalarNode, yaml.AliasNode: + // Directives live only inside mappings (as values of named keys). + // Sequences carry no key, scalars/aliases carry no children to walk. } + return found } @@ -507,37 +575,47 @@ func collectDeleteDirectivePaths(node *yaml.Node, parentRel string) []string { // segment regardless of the target's kind below it. This helper // reproduces the same predicate so a path declared no-op here is // guaranteed to be the same path the apply RPC would have erred on. -func pathExistsInDoc(doc *yaml.Node, path string) bool { +func pathExistsInDoc(doc *yaml.Node, pathStr string) bool { if doc == nil { return false } + cur := doc if cur.Kind == yaml.DocumentNode && len(cur.Content) > 0 { cur = cur.Content[0] } + if cur == nil || cur.Kind != yaml.MappingNode { return false } - if path == "" { + + if pathStr == "" { return true } - for _, escaped := range strings.Split(path, "/") { + + for escaped := range strings.SplitSeq(pathStr, "/") { seg := jsonPointerUnescape(escaped) + if cur.Kind != yaml.MappingNode { return false } + found := false + for i := 0; i+1 < len(cur.Content); i += 2 { if cur.Content[i].Value == seg { cur = cur.Content[i+1] found = true + break } } + if !found { return false } } + return true } @@ -547,47 +625,60 @@ func pathExistsInDoc(doc *yaml.Node, path string) bool { func jsonPointerUnescape(s string) string { s = strings.ReplaceAll(s, "~1", "/") s = strings.ReplaceAll(s, "~0", "~") + return s } func decodeAllYAMLDocuments(data []byte) ([]*yaml.Node, error) { dec := yaml.NewDecoder(bytes.NewReader(data)) + var docs []*yaml.Node + for { var doc yaml.Node + err := dec.Decode(&doc) if err != nil { if errors.Is(err, io.EOF) { break } - return nil, errors.WithHint( - errors.Wrap(err, "decoding YAML before stripping $patch:delete directives"), - "the input is malformed YAML; check for unbalanced quotes or stray indentation in the rendered template or node body", + + return nil, errors.Wrap( + errors.WithHint(err, "the input is malformed YAML; check for unbalanced quotes or stray indentation in the rendered template or node body"), + "decoding YAML before stripping $patch:delete directives", ) } + docs = append(docs, &doc) } + return docs, nil } func encodeAllYAMLDocuments(docs []*yaml.Node) ([]byte, error) { var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) enc.SetIndent(2) + for _, doc := range docs { - if err := enc.Encode(doc); err != nil { - return nil, errors.WithHint( - errors.Wrap(err, "re-encoding YAML after stripping $patch:delete directives"), - "the YAML.v3 encoder rejected the post-strip tree; file an issue with the rendered+body that triggered it", + err := enc.Encode(doc) + if err != nil { + return nil, errors.Wrap( + errors.WithHint(err, "the YAML.v3 encoder rejected the post-strip tree; file an issue with the rendered+body that triggered it"), + "re-encoding YAML after stripping $patch:delete directives", ) } } - if err := enc.Close(); err != nil { - return nil, errors.WithHint( - errors.Wrap(err, "closing YAML encoder after stripping $patch:delete directives"), - "the YAML.v3 encoder failed to flush; file an issue with the rendered+body that triggered it", + + err := enc.Close() + if err != nil { + return nil, errors.Wrap( + errors.WithHint(err, "the YAML.v3 encoder failed to flush; file an issue with the rendered+body that triggered it"), + "closing YAML encoder after stripping $patch:delete directives", ) } + return buf.Bytes(), nil } @@ -602,7 +693,9 @@ func removePatchDeleteFromNode(node *yaml.Node, parentPath string, prunePaths ma if node == nil { return nil } + var removed []string + switch node.Kind { case yaml.DocumentNode: for _, child := range node.Content { @@ -614,25 +707,35 @@ func removePatchDeleteFromNode(node *yaml.Node, parentPath string, prunePaths ma keyNode := node.Content[i] valueNode := node.Content[i+1] childPath := parentPath + "/" + jsonPointerEscape(keyNode.Value) + if isPatchDeleteDirective(valueNode) { if prunePaths == nil { removed = append(removed, childPath) + continue } + if _, prune := prunePaths[childPath]; prune { removed = append(removed, childPath) + continue } } + removed = append(removed, removePatchDeleteFromNode(valueNode, childPath, prunePaths)...) kept = append(kept, keyNode, valueNode) } + node.Content = kept case yaml.SequenceNode: for i, child := range node.Content { removed = append(removed, removePatchDeleteFromNode(child, fmt.Sprintf("%s/%d", parentPath, i), prunePaths)...) } + case yaml.ScalarNode, yaml.AliasNode: + // Scalars and aliases have no children, so they cannot host a + // $patch:delete directive — nothing to remove. } + return removed } @@ -644,6 +747,7 @@ func removePatchDeleteFromNode(node *yaml.Node, parentPath string, prunePaths ma func jsonPointerEscape(s string) string { s = strings.ReplaceAll(s, "~", "~0") s = strings.ReplaceAll(s, "/", "~1") + return s } @@ -654,10 +758,13 @@ func isPatchDeleteDirective(n *yaml.Node) bool { if n == nil || n.Kind != yaml.MappingNode { return false } + if len(n.Content) != 2 { return false } + k, v := n.Content[0], n.Content[1] + return k.Kind == yaml.ScalarNode && k.Value == "$patch" && v.Kind == yaml.ScalarNode && v.Value == "delete" } @@ -686,14 +793,17 @@ func isPatchDeleteDirective(n *yaml.Node) bool { // modeline) — configpatcher.LoadPatch reads structure, not comments, so // this is fine for the apply path; do not feed the output back into a // human-facing rendering surface. +// +//nolint:funlen // 72 lines: per-doc identity match + recursive prune + emit-only-non-empty pipeline; the steps share the same identity bookkeeping (renderedByIdentity, renderedConsumed, allPruned) so extracting helpers would either pass that state through every signature or hoist it to package level. func pruneBodyIdentitiesAgainstRendered(body, rendered []byte) ([]byte, bool, error) { bodyDocs, bodyAllMaps, err := decodeAsMaps(body) if err != nil { - return nil, false, errors.WithHint( - errors.Wrap(err, "parsing body"), - "the node body did not parse as YAML; check the file referenced by the modeline for unbalanced quotes or stray indentation", + return nil, false, errors.Wrap( + errors.WithHint(err, "the node body did not parse as YAML; check the file referenced by the modeline for unbalanced quotes or stray indentation"), + "parsing body", ) } + if !bodyAllMaps { // JSON Patch / YAML patch-list bodies: top-level is a sequence, // not a mapping, so the identity-prune step has no map keys to @@ -701,6 +811,7 @@ func pruneBodyIdentitiesAgainstRendered(body, rendered []byte) ([]byte, bool, er // route it through the JSON Patch path (load.go: jsonpatch.DecodePatch). return body, false, nil } + renderedDocs, _, err := decodeAsMaps(rendered) if err != nil { // Rendered should always parse — engine.Render produced it from @@ -708,11 +819,12 @@ func pruneBodyIdentitiesAgainstRendered(body, rendered []byte) ([]byte, bool, er // directly: continuing on to LoadPatch with the original body // would mask the real failure as a downstream configpatcher // error against malformed bytes. - return nil, false, errors.WithHint( - errors.Wrap(err, "parsing rendered template for identity prune"), - "the rendered template did not parse as YAML; this points at a chart-helper bug, not a user input issue", + return nil, false, errors.Wrap( + errors.WithHint(err, "the rendered template did not parse as YAML; this points at a chart-helper bug, not a user input issue"), + "parsing rendered template for identity prune", ) } + if len(bodyDocs) == 0 { return nil, true, nil } @@ -724,8 +836,8 @@ func pruneBodyIdentitiesAgainstRendered(body, rendered []byte) ([]byte, bool, er keptDocs := make([]map[string]any, 0, len(bodyDocs)) for _, bdoc := range bodyDocs { - id := documentIdentity(bdoc) - if rdoc, ok := renderedByID[id]; ok { + docID := documentIdentity(bdoc) + if rdoc, ok := renderedByID[docID]; ok { pruneIdenticalKeys(bdoc, rdoc) // Typed multi-doc bodies use apiVersion/kind/name as the // identity tuple configpatcher.LoadPatch routes on. Those @@ -739,35 +851,43 @@ func pruneBodyIdentitiesAgainstRendered(body, rendered []byte) ([]byte, bool, er // version field, which is at the same nesting level as the // machine/cluster blocks), so this only fires for the typed // multi-doc shape. - if id != legacyRootIdentity && len(bdoc) > 0 { + if docID != legacyRootIdentity && len(bdoc) > 0 { reattachIdentityKeys(bdoc, rdoc) } } + if len(bdoc) > 0 { keptDocs = append(keptDocs, bdoc) } } + if len(keptDocs) == 0 { return nil, true, nil } var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) enc.SetIndent(2) + for _, doc := range keptDocs { - if err := enc.Encode(doc); err != nil { - return nil, false, errors.WithHint( - errors.Wrap(err, "re-encoding pruned body"), - "the YAML.v3 encoder rejected the post-prune body; file an issue with the rendered+body that triggered it", + err := enc.Encode(doc) + if err != nil { + return nil, false, errors.Wrap( + errors.WithHint(err, "the YAML.v3 encoder rejected the post-prune body; file an issue with the rendered+body that triggered it"), + "re-encoding pruned body", ) } } - if err := enc.Close(); err != nil { - return nil, false, errors.WithHint( - errors.Wrap(err, "closing encoder for pruned body"), - "the YAML.v3 encoder failed to flush; file an issue with the rendered+body that triggered it", + + err = enc.Close() + if err != nil { + return nil, false, errors.Wrap( + errors.WithHint(err, "the YAML.v3 encoder failed to flush; file an issue with the rendered+body that triggered it"), + "closing encoder for pruned body", ) } + return buf.Bytes(), false, nil } @@ -808,6 +928,8 @@ func pruneBodyIdentitiesAgainstRendered(body, rendered []byte) ([]byte, bool, er // the body key reduces body to the zero value at that path, and the // upstream replace then leaves rendered untouched. Skipping kicks in // only on the partial-edit branches below the deep-equal check. +// +//nolint:gochecknoglobals,goconst // immutable lookup table consulted by pruneIdenticalKeysAt; init-time literal, never mutated. The second occurrence of each path string is in the docstring above (where it documents the v1alpha1 merge behaviour); making it a const just to satisfy goconst would split documentation from data without runtime benefit. var replaceSemanticPaths = map[string]struct{}{ "cluster/network/podSubnets": {}, "cluster/network/serviceSubnets": {}, @@ -869,6 +991,8 @@ var replaceSemanticPaths = map[string]struct{}{ // Routes (machine.network.interfaces[].routes) sit in this same fallback // bucket: the schema declares no single primary key for a route, so the // only "same item" semantic available is byte-equality across all fields. +// +//nolint:gochecknoglobals,goconst // immutable lookup table consulted by matchObjectArrayItem; init-time literal mirroring Talos's strategic-merge keys. The second occurrence of each path / field name is in the docstring above; making them consts to satisfy goconst would split documentation from data without runtime benefit. var objectArrayMergeKeys = map[string][]string{ "machine/network/interfaces": {"interface", "deviceSelector"}, "machine/network/interfaces/vlans": {"vlanId"}, @@ -913,23 +1037,29 @@ func pruneIdenticalKeys(body, rendered map[string]any) { // // The parameter is named yamlPath rather than path to avoid shadowing // the stdlib path package imported elsewhere in this file. +// +//nolint:gocognit,nestif // dispatch over (missing-key | replace-semantic | object-array | nested-map | primitive-slice) inside one walk; extracting any branch into a helper would need to thread the per-pair (body, rendered, parent, key) state, growing the surface without simplifying. func pruneIdenticalKeysAt(body, rendered map[string]any, yamlPath string) { - for k, bodyV := range body { - renderedV, exists := rendered[k] + for key, bodyV := range body { + renderedV, exists := rendered[key] if !exists { continue } + if reflect.DeepEqual(bodyV, renderedV) { - delete(body, k) + delete(body, key) + continue } - childPath := joinYAMLPath(yamlPath, k) + + childPath := joinYAMLPath(yamlPath, key) if _, replace := replaceSemanticPaths[childPath]; replace { // Upstream `merge:"replace"` overwrites rendered with body // verbatim. Any prune at this path leaks rendered-side // entries out of the final config — see replaceSemanticPaths. continue } + if bodySub, ok := bodyV.(map[string]any); ok { if renderedSub, ok2 := renderedV.(map[string]any); ok2 { // Only delete when the recursive prune actually @@ -940,28 +1070,33 @@ func pruneIdenticalKeysAt(body, rendered map[string]any, yamlPath string) { // rendered's populated value wins. before := len(bodySub) pruneIdenticalKeysAt(bodySub, renderedSub, childPath) + if before > 0 && len(bodySub) == 0 { - delete(body, k) + delete(body, key) } + continue } } + if bodySlice, ok := bodyV.([]any); ok { if renderedSlice, ok2 := renderedV.([]any); ok2 { if isPrimitiveSlice(bodySlice) && isPrimitiveSlice(renderedSlice) { diff := primitiveSliceDifference(bodySlice, renderedSlice) if len(diff) == 0 { - delete(body, k) + delete(body, key) } else { - body[k] = diff + body[key] = diff } + continue } + pruned := pruneObjectArrayItems(bodySlice, renderedSlice, childPath) if len(pruned) == 0 { - delete(body, k) + delete(body, key) } else { - body[k] = pruned + body[key] = pruned } } } @@ -985,33 +1120,43 @@ func pruneIdenticalKeysAt(body, rendered map[string]any, yamlPath string) { // rendered counterpart are user-adds and are kept verbatim. func pruneObjectArrayItems(body, rendered []any, yamlPath string) []any { keys := objectArrayMergeKeys[yamlPath] + out := make([]any, 0, len(body)) for _, bElem := range body { bMap, ok := bElem.(map[string]any) if !ok { out = append(out, bElem) + continue } + rMap := matchObjectArrayItem(bMap, rendered, keys) if rMap == nil { out = append(out, bElem) + continue } + before := len(bMap) pruneIdenticalKeysAt(bMap, rMap, yamlPath) + if before > 0 && len(bMap) == 0 { continue } + for _, idKey := range keys { if _, hasInBody := bMap[idKey]; hasInBody { continue } + if v, ok := rMap[idKey]; ok { bMap[idKey] = v } } + out = append(out, bMap) } + return out } @@ -1042,33 +1187,42 @@ func matchObjectArrayItem(body map[string]any, rendered []any, keys []string) ma chosenKey string chosenVal any ) + for _, k := range keys { if v, ok := body[k]; ok && hasIdentityValue(v) { chosenKey = k chosenVal = v + break } } + if chosenKey == "" { return nil } + for _, rElem := range rendered { rMap, ok := rElem.(map[string]any) if !ok { continue } + if rv, hasR := rMap[chosenKey]; hasR && reflect.DeepEqual(rv, chosenVal) { return rMap } } + return nil } + for _, rElem := range rendered { if reflect.DeepEqual(rElem, body) { rMap, _ := rElem.(map[string]any) + return rMap } } + return nil } @@ -1083,13 +1237,14 @@ func hasIdentityValue(v any) bool { if v == nil { return false } - switch x := v.(type) { + + switch typed := v.(type) { case string: - return x != "" + return typed != "" case map[string]any: - return len(x) > 0 + return len(typed) > 0 case []any: - return len(x) > 0 + return len(typed) > 0 default: return true } @@ -1103,6 +1258,7 @@ func joinYAMLPath(parent, key string) string { if parent == "" { return key } + return parent + "/" + key } @@ -1126,6 +1282,7 @@ func isPrimitiveSlice(s []any) bool { return false } } + return true } @@ -1141,8 +1298,9 @@ func isPrimitiveSlice(s []any) bool { // and rendered's `[a, b]` order survives untouched. Strategic-merge's // own primitive-array semantics already cannot replace, only append, // so a body cannot impose a new order on a rendered list anyway — -// even without this prune, the merge result would have been -// `[a, b, b, a]` (rendered prepended, body appended in body order). +// even without this prune, the merge result would have been the +// concatenation `[a, b]` ++ `[b, a]` (rendered prepended, body appended +// in body order). // The dedup makes the silent-undo more visible because it now reaches // the partial-edit case, but the underlying constraint is upstream. // Callers that need ordered overrides have to model the field as a @@ -1153,16 +1311,20 @@ func primitiveSliceDifference(body, rendered []any) []any { out := make([]any, 0, len(body)) for _, b := range body { found := false + for _, r := range rendered { if reflect.DeepEqual(b, r) { found = true + break } } + if !found { out = append(out, b) } } + return out } @@ -1181,27 +1343,39 @@ func decodeAsMaps(data []byte) ([]map[string]any, bool, error) { if len(data) == 0 { return nil, true, nil } + dec := yaml.NewDecoder(bytes.NewReader(data)) + var docs []map[string]any + allMaps := true + for { var doc any - if err := dec.Decode(&doc); err != nil { + + err := dec.Decode(&doc) + if err != nil { if errors.Is(err, io.EOF) { break } - return nil, false, err + + return nil, false, errors.Wrap(err, "decoding YAML document") } + if doc == nil { continue } + asMap, ok := doc.(map[string]any) if !ok { allMaps = false + continue } + docs = append(docs, asMap) } + return docs, allMaps, nil } @@ -1212,7 +1386,44 @@ func decodeAsMaps(data []byte) ([]map[string]any, bool, error) { // with — its only top-level identifier is `version`, which is at the // same nesting level as the machine/cluster blocks rather than peer // to a routable apiVersion/kind/name. -const legacyRootIdentity = "__legacy_root__" +const ( + legacyRootIdentity = "__legacy_root__" + + // yamlDocSep is the YAML document separator at column 0. + yamlDocSep = "---" + // helmKeyValues is the chart-rendering top-level Values context key. + helmKeyValues = "Values" + // helmKeyTalosVer is the chart-rendering top-level TalosVersion context key. + helmKeyTalosVer = "TalosVersion" + // cosiKindList is the COSI Kind value emitted when newLookupFunction + // wraps multi-item lookups into a List envelope for template iteration. + cosiKindList = "List" + // k8sKeyAPIVersion is the standard Kubernetes/COSI document key + // used as part of the (apiVersion, kind, name) identity tuple. + k8sKeyAPIVersion = "apiVersion" + // cmdNameTalm is the binary name used for FailIfMultiNodes + // error wording when Options.CommandName is empty. + cmdNameTalm = "talm" + // k8sKeyKind is the standard Kubernetes/COSI document key + // used as part of the (apiVersion, kind, name) identity tuple. + k8sKeyKind = "kind" + // k8sAPIVersionV1 is the value of apiVersion for the synthetic + // List envelope newLookupFunction emits when wrapping multi-item + // COSI lookup results. + k8sAPIVersionV1 = "v1" + // k8sKeyItems is the field name for the synthetic List envelope's + // items slice in newLookupFunction's response. + k8sKeyItems = "items" + // cosiMetaKeyNamespace / Type / ID / Version / Phase / Owner are + // the COSI metadata field names exposed in the rendered template + // context's metadata map. + cosiMetaKeyNamespace = "namespace" + cosiMetaKeyType = "type" + cosiMetaKeyID = "id" + cosiMetaKeyVersion = "version" + cosiMetaKeyPhase = "phase" + cosiMetaKeyOwner = "owner" +) // documentIdentity returns a stable string identifying a Talos config // document. The legacy v1alpha1 root config (a single document with @@ -1230,15 +1441,18 @@ const legacyRootIdentity = "__legacy_root__" // without a `name` field collides with itself across body and rendered // streams instead of with every other unnamed doc of the same kind. func documentIdentity(doc map[string]any) string { - apiVersion, _ := doc["apiVersion"].(string) - kind, _ := doc["kind"].(string) + apiVersion, _ := doc[k8sKeyAPIVersion].(string) + + kind, _ := doc[k8sKeyKind].(string) if apiVersion == "" && kind == "" { return legacyRootIdentity } + id := apiVersion + "/" + kind if name, _ := doc["name"].(string); name != "" { id += "/" + name } + return id } @@ -1254,33 +1468,41 @@ func documentIdentityFromNode(doc *yaml.Node) string { if root != nil && root.Kind == yaml.DocumentNode && len(root.Content) > 0 { root = root.Content[0] } + if root == nil || root.Kind != yaml.MappingNode { return legacyRootIdentity } + var apiVersion, kind, name string + for i := 0; i+1 < len(root.Content); i += 2 { - k := root.Content[i] - v := root.Content[i+1] - if k.Kind != yaml.ScalarNode || v.Kind != yaml.ScalarNode { + key := root.Content[i] + + val := root.Content[i+1] + if key.Kind != yaml.ScalarNode || val.Kind != yaml.ScalarNode { continue } - switch k.Value { - case "apiVersion": - apiVersion = v.Value - case "kind": - kind = v.Value + + switch key.Value { + case k8sKeyAPIVersion: + apiVersion = val.Value + case k8sKeyKind: + kind = val.Value case "name": - name = v.Value + name = val.Value } } + if apiVersion == "" && kind == "" { return legacyRootIdentity } - id := apiVersion + "/" + kind + + docID := apiVersion + "/" + kind if name != "" { - id += "/" + name + docID += "/" + name } - return id + + return docID } // reattachIdentityKeys copies apiVersion / kind / name from rendered @@ -1291,12 +1513,13 @@ func documentIdentityFromNode(doc *yaml.Node) string { // operator pinning a different name on a Layer2VIPConfig) keeps its // override. func reattachIdentityKeys(body, rendered map[string]any) { - for _, k := range []string{"apiVersion", "kind", "name"} { - if _, has := body[k]; has { + for _, key := range []string{k8sKeyAPIVersion, k8sKeyKind, "name"} { + if _, has := body[key]; has { continue } - if v, ok := rendered[k]; ok { - body[k] = v + + if val, ok := rendered[key]; ok { + body[key] = val } } } @@ -1308,8 +1531,9 @@ func reattachIdentityKeys(body, rendered map[string]any) { func NodeFileHasOverlay(patchFile string) (bool, error) { data, err := os.ReadFile(patchFile) if err != nil { - return false, fmt.Errorf("reading node file %s: %w", patchFile, err) + return false, errors.Wrapf(err, "reading node file %s", patchFile) } + return !isEffectivelyEmptyYAML(data), nil } @@ -1323,32 +1547,38 @@ func NodeFileHasOverlay(patchFile string) (bool, error) { // separator, so the comparison is against the line minus only trailing // whitespace rather than against the fully trimmed form. func isEffectivelyEmptyYAML(data []byte) bool { - for _, line := range bytes.Split(data, []byte("\n")) { + for line := range bytes.SplitSeq(data, []byte("\n")) { trimmed := bytes.TrimSpace(line) if len(trimmed) == 0 { continue } + if trimmed[0] == '#' { continue } + untrailed := string(bytes.TrimRight(line, " \t\r")) - if untrailed == "---" || untrailed == "..." { + if untrailed == yamlDocSep || untrailed == "..." { continue } + return false } + return true } // Render executes the rendering of templates based on the provided options. +// +//nolint:funlen,gocritic // funlen: 75-line linear dispatch over (Full ? FullConfigProcess : ApplyPatches) with per-branch cluster-meta hydration and per-mode serialisation; splitting either branch would scatter the shared FailIfMultiNodes/loadValues/SerializeConfiguration steps across helpers without simplifying control flow. hugeParam: Options is the package's public configuration carrier; passing by pointer would propagate across pkg/commands and external consumers. func Render(ctx context.Context, c *client.Client, opts Options) ([]byte, error) { - // Validate TalosVersion early so malformed values surface a user-friendly // error instead of an opaque "semverCompare: invalid semantic version" from // inside template rendering. if opts.TalosVersion != "" { - if _, err := config.ParseContractFromVersion(opts.TalosVersion); err != nil { - return nil, fmt.Errorf("invalid talos-version: %w", err) + _, err := config.ParseContractFromVersion(opts.TalosVersion) + if err != nil { + return nil, errors.Wrap(err, "invalid talos-version") } } @@ -1356,25 +1586,29 @@ func Render(ctx context.Context, c *client.Client, opts Options) ([]byte, error) if !opts.Offline { cmdName := opts.CommandName if cmdName == "" { - cmdName = "talm" + cmdName = cmdNameTalm } - if err := helpers.FailIfMultiNodes(ctx, cmdName); err != nil { - return nil, err + + err := helpers.FailIfMultiNodes(ctx, cmdName) + if err != nil { + return nil, errors.Wrap(err, "checking node selector") } + helmEngine.LookupFunc = newLookupFunction(ctx, c) } chartPath, err := os.Getwd() if err != nil { - return nil, err + return nil, errors.Wrap(err, "resolving working directory") } + if opts.Root != "" { chartPath = opts.Root } chrt, err := loader.LoadDir(chartPath) if err != nil { - return nil, err + return nil, errors.Wrapf(err, "loading chart from %q", chartPath) } values, err := loadValues(opts) @@ -1383,34 +1617,38 @@ func Render(ctx context.Context, c *client.Client, opts Options) ([]byte, error) } rootValues := map[string]any{ - "Values": mergeMaps(chrt.Values, values), - "TalosVersion": opts.TalosVersion, + helmKeyValues: mergeMaps(chrt.Values, values), + helmKeyTalosVer: opts.TalosVersion, } eng := helmEngine.Engine{} + out, err := eng.Render(chrt, rootValues) if err != nil { - return nil, err + return nil, errors.Wrap(err, "rendering chart") } if len(opts.TemplateFiles) == 0 { - return nil, fmt.Errorf("templates are not set for the command: please use `--file` or `--template` flag") + return nil, errors.New("templates are not set for the command: please use `--file` or `--template` flag") } configPatches := []string{} + for _, templateFile := range opts.TemplateFiles { // Use path.Join (not filepath.Join) because helm engine keys always use forward slashes requestedTemplate := path.Join(chrt.Name(), NormalizeTemplatePath(templateFile)) + configPatch, ok := out[requestedTemplate] if !ok { - return nil, fmt.Errorf("template %s not found", templateFile) + //nolint:wrapcheck // cockroachdb/errors.Newf produces a stable typed error; wrapcheck's default ignore-sigs cover .New() but not .Newf(). + return nil, errors.Newf("template %s not found", templateFile) } + configPatches = append(configPatches, configPatch) } finalConfig, err := applyPatchesAndRenderConfig(opts, configPatches) if err != nil { - // TODO return nil, err } @@ -1419,6 +1657,8 @@ func Render(ctx context.Context, c *client.Client, opts Options) ([]byte, error) // Imported from Helm // https://github.com/helm/helm/blob/c6beb169d26751efd8131a5d65abe75c81a334fb/pkg/cli/values/options.go#L44 +// +//nolint:funlen,gocritic // funlen: linear dispatch over six independent value-source kinds (files, --set-json, --set, --set-string, --set-file, --set-literal); each branch is a 4-line guarded call and extracting any subset would only fragment the logic. hugeParam: Options is the public configuration carrier; passing by pointer would propagate across pkg/commands and external consumers. func loadValues(opts Options) (map[string]any, error) { // Base map to hold the merged values base := make(map[string]any) @@ -1426,36 +1666,45 @@ func loadValues(opts Options) (map[string]any, error) { // Load values from files specified with -f or --values for _, filePath := range opts.ValueFiles { currentMap := make(map[string]any) - bytes, err := os.ReadFile(filePath) + + buf, err := os.ReadFile(filePath) if err != nil { - return nil, fmt.Errorf("failed to read values file %s: %w", filePath, err) + return nil, errors.Wrapf(err, "failed to read values file %s", filePath) } - if err := yaml.Unmarshal(bytes, ¤tMap); err != nil { - return nil, fmt.Errorf("failed to unmarshal values from file %s: %w", filePath, err) + + err = yaml.Unmarshal(buf, ¤tMap) + if err != nil { + return nil, errors.Wrapf(err, "failed to unmarshal values from file %s", filePath) } + base = mergeMaps(base, currentMap) } // Parse and merge values from --set-json for _, value := range opts.JsonValues { currentMap := make(map[string]any) - if err := json.Unmarshal([]byte(value), ¤tMap); err != nil { - return nil, fmt.Errorf("failed to unmarshal JSON value '%s': %w", value, err) + + err := json.Unmarshal([]byte(value), ¤tMap) + if err != nil { + return nil, errors.Wrapf(err, "failed to unmarshal JSON value '%s'", value) } + base = mergeMaps(base, currentMap) } // Parse and merge values from --set for _, value := range opts.Values { - if err := strvals.ParseInto(value, base); err != nil { - return nil, fmt.Errorf("failed to parse set value '%s': %w", value, err) + err := strvals.ParseInto(value, base) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse set value '%s'", value) } } // Parse and merge values from --set-string for _, value := range opts.StringValues { - if err := strvals.ParseIntoString(value, base); err != nil { - return nil, fmt.Errorf("failed to parse set-string value '%s': %w", value, err) + err := strvals.ParseIntoString(value, base) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse set-string value '%s'", value) } } @@ -1463,17 +1712,20 @@ func loadValues(opts Options) (map[string]any, error) { for _, value := range opts.FileValues { content, err := os.ReadFile(value) if err != nil { - return nil, fmt.Errorf("failed to read file for set-file value '%s': %w", value, err) + return nil, errors.Wrapf(err, "failed to read file for set-file value '%s'", value) } - if err := strvals.ParseInto(fmt.Sprintf("%s=%s", value, content), base); err != nil { - return nil, fmt.Errorf("failed to parse set-file value '%s': %w", value, err) + + err = strvals.ParseInto(fmt.Sprintf("%s=%s", value, content), base) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse set-file value '%s'", value) } } // Parse and merge values from --set-literal for _, value := range opts.LiteralValues { - if err := strvals.ParseInto(value, base); err != nil { - return nil, fmt.Errorf("failed to parse set-literal value '%s': %w", value, err) + err := strvals.ParseInto(value, base) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse set-literal value '%s'", value) } } @@ -1485,17 +1737,21 @@ func loadValues(opts Options) (map[string]any, error) { func mergeMaps(a, b map[string]any) map[string]any { out := make(map[string]any, len(a)) maps.Copy(out, a) - for k, v := range b { - if vm, ok := v.(map[string]any); ok { - if bv, ok := out[k]; ok { + + for key, val := range b { + if vm, ok := val.(map[string]any); ok { + if bv, ok := out[key]; ok { if bvm, ok := bv.(map[string]any); ok { - out[k] = mergeMaps(bvm, vm) + out[key] = mergeMaps(bvm, vm) + continue } } } - out[k] = v + + out[key] = val } + return out } @@ -1503,11 +1759,15 @@ func mergeMaps(a, b map[string]any) map[string]any { // Returns (isTalosPatch, parseError) - parseError is non-nil if YAML is invalid. func isTalosConfigPatch(doc string) (bool, error) { var parsed map[string]any - if err := yaml.Unmarshal([]byte(doc), &parsed); err != nil { - return false, err + + err := yaml.Unmarshal([]byte(doc), &parsed) + if err != nil { + return false, errors.Wrap(err, "unmarshaling YAML document") } + _, hasMachine := parsed["machine"] _, hasCluster := parsed["cluster"] + return hasMachine || hasCluster, nil } @@ -1517,7 +1777,9 @@ var yamlDocSeparator = regexp.MustCompile(`(?m)^---[ \t]*$`) // extractExtraDocuments separates Talos config patches from other YAML documents. // Returns the Talos patches to be processed, extra documents to be appended to output, and any error. -func extractExtraDocuments(patches []string) (talosPatches []string, extraDocs []string, err error) { +func extractExtraDocuments(patches []string) ([]string, []string, error) { + var talosPatches, extraDocs []string + for _, patch := range patches { // Normalize CRLF to LF for consistent splitting patch = strings.ReplaceAll(patch, "\r\n", "\n") @@ -1528,10 +1790,12 @@ func extractExtraDocuments(patches []string) (talosPatches []string, extraDocs [ if doc == "" { continue } + isTalos, parseErr := isTalosConfigPatch(doc) if parseErr != nil { - return nil, nil, fmt.Errorf("invalid YAML in template output: %w\n\nTemplate output:\n%s", parseErr, doc) + return nil, nil, errors.Wrapf(parseErr, "invalid YAML in template output\n\nTemplate output:\n%s", doc) } + if isTalos { talosPatches = append(talosPatches, doc) } else { @@ -1539,9 +1803,16 @@ func extractExtraDocuments(patches []string) (talosPatches []string, extraDocs [ } } } + return talosPatches, extraDocs, nil } +// applyPatchesAndRenderConfig assembles the final Talos config bytes +// for the non-Full template path: split out extra documents, run two +// bundle-rebuild passes (TypeUnknown then resolved machine type), +// apply patches in dependency order, serialise. +// +//nolint:funlen,gocognit,gocyclo,cyclop,nestif,gocritic // single linear pipeline (extract -> hydrate cluster meta -> reinit bundle for the resolved machine type -> serialise -> reattach extra docs); each branch error path wraps with its own context. hugeParam: Options is the public configuration carrier; passing by pointer would propagate across pkg/commands and external consumers. func applyPatchesAndRenderConfig(opts Options, configPatches []string) ([]byte, error) { // Separate Talos config patches from extra documents (like UserVolumeConfig) talosPatches, extraDocs, err := extractExtraDocuments(configPatches) @@ -1555,16 +1826,18 @@ func applyPatchesAndRenderConfig(opts Options, configPatches []string) ([]byte, if opts.TalosVersion != "" { versionContract, err := config.ParseContractFromVersion(opts.TalosVersion) if err != nil { - return nil, fmt.Errorf("invalid talos-version: %w", err) + return nil, errors.Wrap(err, "invalid talos-version") } + genOptions = append(genOptions, generate.WithVersionContract(versionContract)) } if opts.WithSecrets != "" { secretsBundle, err := secrets.LoadBundle(opts.WithSecrets) if err != nil { - return nil, fmt.Errorf("failed to load secrets bundle: %w", err) + return nil, errors.Wrap(err, "failed to load secrets bundle") } + genOptions = append(genOptions, generate.WithSecretsBundle(secretsBundle)) } @@ -1581,7 +1854,7 @@ func applyPatchesAndRenderConfig(opts Options, configPatches []string) ([]byte, // Load and apply patches to discover the machine type configBundle, err := bundle.NewBundle(configBundleOpts...) if err != nil { - return nil, err + return nil, errors.Wrap(err, "creating initial config bundle") } patches, err := configpatcher.LoadPatches(talosPatches) @@ -1589,7 +1862,8 @@ func applyPatchesAndRenderConfig(opts Options, configPatches []string) ([]byte, if opts.Debug { debugPhase(opts, configPatches, "", "", machine.TypeUnknown) } - return nil, err + + return nil, errors.Wrap(err, "loading patches") } err = configBundle.ApplyPatches(patches, true, false) @@ -1597,11 +1871,14 @@ func applyPatchesAndRenderConfig(opts Options, configPatches []string) ([]byte, if opts.Debug { debugPhase(opts, configPatches, "", "", machine.TypeUnknown) } - return nil, err + + return nil, errors.Wrap(err, "applying initial patches") } + machineType := configBundle.ControlPlaneCfg.Machine().Type() clusterName := configBundle.ControlPlaneCfg.Cluster().Name() clusterEndpoint := configBundle.ControlPlaneCfg.Cluster().Endpoint() + if machineType == machine.TypeUnknown { machineType = machine.TypeWorker } @@ -1622,49 +1899,57 @@ func applyPatchesAndRenderConfig(opts Options, configPatches []string) ([]byte, ), bundle.WithVerbose(false), } + configBundle, err = bundle.NewBundle(configBundleOpts...) if err != nil { - return nil, err + return nil, errors.Wrap(err, "creating reloaded config bundle") } var configOrigin, configFull []byte if !opts.Full { configOrigin, err = configBundle.Serialize(encoder.CommentsDisabled, machineType) if err != nil { - return nil, err + return nil, errors.Wrap(err, "serializing original config bundle") } // Overwrite some fields to preserve them for diff - var config map[string]any - if err := yaml.Unmarshal(configOrigin, &config); err != nil { - return nil, err + var cfg map[string]any + + err = yaml.Unmarshal(configOrigin, &cfg) + if err != nil { + return nil, errors.Wrap(err, "unmarshaling original config") } - if machine, ok := config["machine"].(map[string]any); ok { - machine["type"] = "unknown" + + if mtype, ok := cfg["machine"].(map[string]any); ok { + mtype[cosiMetaKeyType] = "unknown" } - if cluster, ok := config["cluster"].(map[string]any); ok { + + if cluster, ok := cfg["cluster"].(map[string]any); ok { cluster["clusterName"] = "" + controlPlane, ok := cluster["controlPlane"].(map[string]any) if !ok { controlPlane = map[string]any{} cluster["controlPlane"] = controlPlane } + controlPlane["endpoint"] = "" } - configOrigin, err = yaml.Marshal(&config) + + configOrigin, err = yaml.Marshal(&cfg) if err != nil { - return nil, err + return nil, errors.Wrap(err, "marshaling original config") } } err = configBundle.ApplyPatches(patches, (machineType == machine.TypeControlPlane), (machineType == machine.TypeWorker)) if err != nil { - return nil, err + return nil, errors.Wrap(err, "applying patches to reloaded bundle") } configFull, err = configBundle.Serialize(encoder.CommentsDisabled, machineType) if err != nil { - return nil, err + return nil, errors.Wrap(err, "serializing patched config bundle") } var target []byte @@ -1673,33 +1958,35 @@ func applyPatchesAndRenderConfig(opts Options, configPatches []string) ([]byte, } else { target, err = yamltools.DiffYAMLs(configOrigin, configFull) if err != nil { - return nil, err + return nil, errors.Wrap(err, "diffing original and patched configs") } } var targetNode yaml.Node - if err := yaml.Unmarshal(target, &targetNode); err != nil { - return nil, err + + err = yaml.Unmarshal(target, &targetNode) + if err != nil { + return nil, errors.Wrap(err, "unmarshaling target config") } // Copy comments from source configuration to the final output for _, configPatch := range talosPatches { var sourceNode yaml.Node - if err := yaml.Unmarshal([]byte(configPatch), &sourceNode); err != nil { - return nil, err + + err = yaml.Unmarshal([]byte(configPatch), &sourceNode) + if err != nil { + return nil, errors.Wrap(err, "unmarshaling source patch for comment propagation") } + dstPaths := make(map[string]*yaml.Node) yamltools.CopyComments(&sourceNode, &targetNode, "", dstPaths) yamltools.ApplyComments(&targetNode, "", dstPaths) } buf := &bytes.Buffer{} - encoder := yaml.NewEncoder(buf) - encoder.SetIndent(2) - if err := encoder.Encode(&targetNode); err != nil { + if err := encodeYAMLNodeIndented(buf, &targetNode); err != nil { return nil, err } - _ = encoder.Close() // Append extra documents (like UserVolumeConfig) that are not part of Talos config for _, extraDoc := range extraDocs { @@ -1711,23 +1998,43 @@ func applyPatchesAndRenderConfig(opts Options, configPatches []string) ([]byte, return buf.Bytes(), nil } +// encodeYAMLNodeIndented writes node to w as 2-space-indented YAML +// and returns wrapped errors for both the encode and close phases. +// Hoisted out of applyPatchesAndRenderConfig so the close-error wrap +// is unit-testable via a fault-injecting writer; sister sites at +// engine.go:585 and :765 use the same encode+close idiom inline. +func encodeYAMLNodeIndented(w io.Writer, node *yaml.Node) error { + enc := yaml.NewEncoder(w) + enc.SetIndent(2) + + if err := enc.Encode(node); err != nil { + return errors.Wrap(err, "encoding target config") + } + + if err := enc.Close(); err != nil { + return errors.Wrap(err, "closing target config encoder") + } + + return nil +} + func readUnexportedField(field reflect.Value) any { return reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Interface() } -// builds resource with metadata, spec and stringSpec fields +// extractResourceData builds a resource map with metadata and spec fields. func extractResourceData(r resource.Resource) (map[string]any, error) { res := make(map[string]any) // Extract metadata directly from resource methods - md := r.Metadata() + rmd := r.Metadata() metadata := map[string]any{ - "namespace": string(md.Namespace()), - "type": string(md.Type()), - "id": string(md.ID()), - "version": md.Version().String(), - "phase": md.Phase().String(), - "owner": string(md.Owner()), + cosiMetaKeyNamespace: rmd.Namespace(), + cosiMetaKeyType: rmd.Type(), + cosiMetaKeyID: rmd.ID(), + cosiMetaKeyVersion: rmd.Version().String(), + cosiMetaKeyPhase: rmd.Phase().String(), + cosiMetaKeyOwner: rmd.Owner(), } res["metadata"] = metadata @@ -1738,65 +2045,92 @@ func extractResourceData(r resource.Resource) (map[string]any, error) { val = val.Elem() } - if val.Kind() == reflect.Struct { - if yamlField := val.FieldByName("yaml"); yamlField.IsValid() { - yamlValue := readUnexportedField(yamlField) - var unmarshalledData any - if err := yaml.Unmarshal([]byte(yamlValue.(string)), &unmarshalledData); err != nil { - return res, fmt.Errorf("error unmarshaling yaml: %w", err) - } - res["spec"] = unmarshalledData - } else { - return res, fmt.Errorf("field 'yaml' not found") - } + if val.Kind() != reflect.Struct { + return res, nil } + yamlField := val.FieldByName("yaml") + if !yamlField.IsValid() { + return res, errors.New("field 'yaml' not found") + } + + yamlValue := readUnexportedField(yamlField) + + yamlString, ok := yamlValue.(string) + if !ok { + //nolint:wrapcheck // cockroachdb/errors.Newf produces a stable typed error; wrapcheck's default ignore-sigs cover .New() but not .Newf(). + return res, errors.Newf("field 'yaml' is not a string (got %T)", yamlValue) + } + + var unmarshalledData any + + err := yaml.Unmarshal([]byte(yamlString), &unmarshalledData) + if err != nil { + return res, errors.Wrap(err, "unmarshaling yaml") + } + + res["spec"] = unmarshalledData + return res, nil } +// newLookupFunction returns the implementation of the chart `lookup` +// template function, dispatching across COSI resource kinds and emitting +// a deterministic error envelope on miss. +// +//nolint:funlen // 62 lines: closure over ctx/c with a single linear dispatch over resource kinds; extracting helpers would either thread (ctx, c) through every signature or hoist the closure body to package level. func newLookupFunction(ctx context.Context, c *client.Client) func(resource string, namespace string, id string) (map[string]any, error) { - return func(kind string, namespace string, id string) (map[string]any, error) { + return func(kind string, namespace string, docID string) (map[string]any, error) { var multiErr *multierror.Error var resources []map[string]any - callbackResource := func(parentCtx context.Context, hostname string, r resource.Resource, callError error) error { + callbackResource := func(_ context.Context, _ string, r resource.Resource, callError error) error { if callError != nil { // Ignore NotFound and PermissionDenied errors - resource doesn't exist or is not accessible errCode := status.Code(callError) + errStr := callError.Error() if errCode == codes.NotFound || errCode == codes.PermissionDenied || strings.Contains(errStr, "code = NotFound") || strings.Contains(errStr, "code = PermissionDenied") { return nil } + multiErr = multierror.Append(multiErr, callError) + return nil } res, err := extractResourceData(r) if err != nil { - multiErr = multierror.Append(multiErr, fmt.Errorf("resource %s/%s: %w", r.Metadata().Type(), r.Metadata().ID(), err)) + multiErr = multierror.Append(multiErr, errors.Wrapf(err, "resource %s/%s", r.Metadata().Type(), r.Metadata().ID())) + return nil } resources = append(resources, res) + return nil } - callbackRD := func(definition *meta.ResourceDefinition) error { + callbackRD := func(_ *meta.ResourceDefinition) error { return nil } - helperErr := helpers.ForEachResource(ctx, c, callbackRD, callbackResource, namespace, kind, id) + helperErr := helpers.ForEachResource(ctx, c, callbackRD, callbackResource, namespace, kind, docID) if helperErr != nil { - return map[string]any{}, helperErr + return map[string]any{}, errors.Wrap(helperErr, "iterating resources") } - if err := multiErr.ErrorOrNil(); err != nil { - return map[string]any{}, err + + err := multiErr.ErrorOrNil() + if err != nil { + return map[string]any{}, errors.Wrap(err, "collecting resource lookup errors") } + if len(resources) == 0 { return map[string]any{}, nil } - if id != "" && len(resources) == 1 { + + if docID != "" && len(resources) == 1 { return resources[0], nil } // Return items as a slice for proper range iteration in templates @@ -1804,10 +2138,11 @@ func newLookupFunction(ctx context.Context, c *client.Client) func(resource stri for i, res := range resources { items[i] = res } + return map[string]any{ - "apiVersion": "v1", - "kind": "List", - "items": items, + k8sKeyAPIVersion: k8sAPIVersionV1, + k8sKeyKind: cosiKindList, + k8sKeyItems: items, }, nil } } diff --git a/pkg/engine/engine_test.go b/pkg/engine/engine_test.go index 6c3cd555..9f46d930 100644 --- a/pkg/engine/engine_test.go +++ b/pkg/engine/engine_test.go @@ -16,6 +16,7 @@ package engine import ( "bytes" + "context" "go/ast" "go/parser" "go/token" @@ -40,15 +41,15 @@ import ( // Uses `git ls-files -z` to get the index-tracked file list. Returns // an error if the command fails (no git, not a git repo). Empty or // non-matching extensions yield an empty list, not an error. -func committedTextFiles(root string, exts map[string]bool) ([]string, error) { - cmd := exec.Command("git", "ls-files", "-z") +func committedTextFiles(ctx context.Context, root string, exts map[string]bool) ([]string, error) { + cmd := exec.CommandContext(ctx, "git", "ls-files", "-z") cmd.Dir = root out, err := cmd.Output() if err != nil { return nil, errors.Wrapf(err, "git ls-files in %s", root) } var files []string - for _, rel := range bytes.Split(out, []byte{0}) { + for rel := range bytes.SplitSeq(out, []byte{0}) { if len(rel) == 0 { continue } @@ -594,7 +595,7 @@ func TestCommittedTextFilesIgnoresUntrackedArtefacts(t *testing.T) { repo := t.TempDir() runGit := func(args ...string) { t.Helper() - cmd := exec.Command("git", args...) + cmd := exec.CommandContext(t.Context(), "git", args...) cmd.Dir = repo // Disable any user gitconfig that could interfere with the // minimal test repo (commit signing, hooks, etc.). @@ -622,7 +623,7 @@ func TestCommittedTextFilesIgnoresUntrackedArtefacts(t *testing.T) { t.Fatalf("write untracked: %v", err) } - files, err := committedTextFiles(repo, map[string]bool{".md": true}) + files, err := committedTextFiles(t.Context(), repo, map[string]bool{".md": true}) if err != nil { t.Fatalf("committedTextFiles: %v", err) } @@ -750,7 +751,7 @@ func TestNoWorkflowLeakageInRepoSource(t *testing.T) { ".yml": true, ".md": true, } - files, err := committedTextFiles(moduleRoot, scanExt) + files, err := committedTextFiles(t.Context(), moduleRoot, scanExt) if err != nil { t.Fatalf("list committed files: %v", err) } diff --git a/pkg/engine/helm/consts_test.go b/pkg/engine/helm/consts_test.go new file mode 100644 index 00000000..156a4f00 --- /dev/null +++ b/pkg/engine/helm/consts_test.go @@ -0,0 +1,46 @@ +// Copyright Cozystack Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package engine + +// Test fixture literals hoisted from engine_test.go (mirrored from +// upstream Helm engine_test) so goconst's per-package threshold is +// satisfied with a single source of truth and a future Helm-bump +// rename ripples through one file. +const ( + tplFooDirect = "/mychart/templates/foo.tpl" + tplBarDirect = "/mychart/templates/bar.tpl" + tplFooNested = "/mychart/templates/charts/foo/charts/bar/templates/foo.tpl" + tplFooSubChartFoo = "/mychart/templates/charts/foo/templates/foo.tpl" + tplBarSubChartFoo = "/mychart/templates/charts/foo/templates/bar.tpl" + tplFooSubChartBar = "/mychart/templates/charts/bar/templates/foo.tpl" + tplFooHelpersDirect = "/mychart/templates/_foo.tpl" + helmFuncFromJSON = "fromJson" + helmTestVersion = "1.2.3" + helmTestDefaultValue = "DEFAULT" + helmKeyValues = "Values" + helmKeyChartChild = "child" + helmKeyName = "Name" + helmKeyChart = "Chart" + helmKeyRelease = "Release" + helmKeyBasePath = "BasePath" + helmFixtureIssue9981 = "issue9981" + helmFixtureBaseAreUs = "All your base are belong to us" + helmFixtureDeepest = "deepest" + helmFixtureHerrick = "herrick" + helmFixture90sMeme = "That 90s meme" + helmFixtureTplFunction = "TplFunction" + helmFixtureTestRelease = "TestRelease" + helmFixtureExtraField = "extra-field" +) diff --git a/pkg/engine/helm/engine.go b/pkg/engine/helm/engine.go index 540274fa..d064efbd 100644 --- a/pkg/engine/helm/engine.go +++ b/pkg/engine/helm/engine.go @@ -27,14 +27,25 @@ import ( "strings" "text/template" - "github.com/pkg/errors" + "github.com/cockroachdb/errors" "helm.sh/helm/v3/pkg/chart" "helm.sh/helm/v3/pkg/chartutil" ) -var Disks map[string]any = map[string]any{} -var LookupFunc func(resource string, namespace string, name string) (map[string]any, error) = func(string, string, string) (map[string]any, error) { +// Disks is a package-level lookup table consulted by chart templates that +// reference {{ .Disks }}. It is populated by callers that drive the engine +// (e.g. talm's apply path) before Render is invoked. +// +//nolint:gochecknoglobals // mutable hook into rendering shared across callers; matches upstream lookup wiring. +var Disks = map[string]any{} + +// LookupFunc is the package-level implementation of the `lookup` template +// function used when the engine is not in lint mode. It is overridden by +// callers that have a live Kubernetes connection. +// +//nolint:gochecknoglobals // mutable hook into rendering shared across callers; matches upstream lookup wiring. +var LookupFunc = func(string, string, string) (map[string]any, error) { return map[string]any{}, nil } @@ -70,6 +81,7 @@ type Engine struct { // bar chart during render time. func (e Engine) Render(chrt *chart.Chart, values chartutil.Values) (map[string]string, error) { tmap := allTemplates(chrt, values) + return e.render(tmap) } @@ -89,9 +101,26 @@ type renderable struct { basePath string } -const warnStartDelim = "HELM_ERR_START" -const warnEndDelim = "HELM_ERR_END" -const recursionMaxNums = 1000 +const ( + warnStartDelim = "HELM_ERR_START" + warnEndDelim = "HELM_ERR_END" + recursionMaxNums = 1000 + + // Helm template function names registered both in initFunMap + // and re-injected per render in tplFun for the closure capture. + helmFuncInclude = "include" + helmFuncTpl = "tpl" + helmFuncRequired = "required" + helmFuncLookup = "lookup" + helmFuncToToml = "toToml" + helmFuncToYAML = "toYaml" + helmFuncFromYAML = "fromYaml" + helmFuncToJSON = "toJson" + + // helmKeyTalosVersion is the engine-injected template key + // for the Talos version of the cluster being rendered. + helmKeyTalosVersion = "TalosVersion" +) var warnRegex = regexp.MustCompile(warnStartDelim + `((?s).*)` + warnEndDelim) @@ -101,19 +130,23 @@ func warnWrap(warn string) string { // 'include' needs to be defined in the scope of a 'tpl' template as // well as regular file-loaded templates. -func includeFun(t *template.Template, includedNames map[string]int) func(string, any) (string, error) { +func includeFun(tmpl *template.Template, includedNames map[string]int) func(string, any) (string, error) { return func(name string, data any) (string, error) { var buf strings.Builder + if v, ok := includedNames[name]; ok { if v > recursionMaxNums { - return "", errors.Wrapf(fmt.Errorf("unable to execute template"), "rendering template has a nested reference name: %s", name) + return "", errors.Wrapf(errors.New("unable to execute template"), "rendering template has a nested reference name: %s", name) } + includedNames[name]++ } else { includedNames[name] = 1 } - err := t.ExecuteTemplate(&buf, name, data) + + err := tmpl.ExecuteTemplate(&buf, name, data) includedNames[name]-- + return buf.String(), err } } @@ -122,25 +155,26 @@ func includeFun(t *template.Template, includedNames map[string]int) func(string, // defined by their enclosing contexts. func tplFun(parent *template.Template, includedNames map[string]int, strict bool) func(string, any) (string, error) { return func(tpl string, vals any) (string, error) { - t, err := parent.Clone() + tmpl, err := parent.Clone() if err != nil { return "", errors.Wrapf(err, "cannot clone template") } // Re-inject the missingkey option, see text/template issue https://github.com/golang/go/issues/43022 // We have to go by strict from our engine configuration, as the option fields are private in Template. + //nolint:godox // upstream Helm tracks a Go stdlib workaround; comment is intentional. // TODO: Remove workaround (and the strict parameter) once we build only with golang versions with a fix. if strict { - t.Option("missingkey=error") + tmpl.Option("missingkey=error") } else { - t.Option("missingkey=zero") + tmpl.Option("missingkey=zero") } - // Re-inject 'include' so that it can close over our clone of t; + // Re-inject 'include' so that it can close over our clone of tmpl; // this lets any 'define's inside tpl be 'include'd. - t.Funcs(template.FuncMap{ - "include": includeFun(t, includedNames), - "tpl": tplFun(t, includedNames, strict), + tmpl.Funcs(template.FuncMap{ + helmFuncInclude: includeFun(tmpl, includedNames), + helmFuncTpl: tplFun(tmpl, includedNames, strict), }) // We need a .New template, as template text which is just blanks @@ -149,13 +183,15 @@ func tplFun(parent *template.Template, includedNames map[string]int, strict bool // https://pkg.go.dev/text/template#Template.Parse // Use the parent's name for lack of a better way to identify the tpl // text string. (Maybe we could use a hash appended to the name?) - t, err = t.New(parent.Name()).Parse(tpl) + tmpl, err = tmpl.New(parent.Name()).Parse(tpl) if err != nil { return "", errors.Wrapf(err, "cannot parse template %q", tpl) } var buf strings.Builder - if err := t.Execute(&buf, vals); err != nil { + + err = tmpl.Execute(&buf, vals) + if err != nil { return "", errors.Wrapf(err, "error during tpl function execution for %q", tpl) } @@ -165,34 +201,37 @@ func tplFun(parent *template.Template, includedNames map[string]int, strict bool } // initFunMap creates the Engine's FuncMap and adds context-specific functions. -func (e Engine) initFunMap(t *template.Template) { +func (e Engine) initFunMap(tmpl *template.Template) { funcMap := funcMap() includedNames := make(map[string]int) - // Add the template-rendering functions here so we can close over t. - funcMap["include"] = includeFun(t, includedNames) - funcMap["tpl"] = tplFun(t, includedNames, e.Strict) + // Add the template-rendering functions here so we can close over tmpl. + funcMap[helmFuncInclude] = includeFun(tmpl, includedNames) + funcMap[helmFuncTpl] = tplFun(tmpl, includedNames, e.Strict) // Add the `required` function here so we can use lintMode - funcMap["required"] = func(warn string, val any) (any, error) { - if val == nil { - if e.LintMode { - // Don't fail on missing required values when linting - log.Printf("[INFO] Missing required value: %s", warn) - return "", nil - } - return val, errors.New(warnWrap(warn)) - } else if _, ok := val.(string); ok { - if val == "" { - if e.LintMode { - // Don't fail on missing required values when linting - log.Printf("[INFO] Missing required value: %s", warn) - return "", nil - } - return val, errors.New(warnWrap(warn)) + funcMap[helmFuncRequired] = func(warn string, val any) (any, error) { + // A required value is considered missing when it is nil, or + // when it is the empty string. Anything else passes through. + missing := val == nil + if !missing { + if str, ok := val.(string); ok && str == "" { + missing = true } } - return val, nil + + if !missing { + return val, nil + } + + if e.LintMode { + // Don't fail on missing required values when linting + log.Printf("[INFO] Missing required value: %s", warn) + + return "", nil + } + + return val, errors.New(warnWrap(warn)) } // Override sprig fail function for linting and wrapping message @@ -200,21 +239,23 @@ func (e Engine) initFunMap(t *template.Template) { if e.LintMode { // Don't fail when linting log.Printf("[INFO] Fail: %s", msg) + return "", nil } + return "", errors.New(warnWrap(msg)) } // If we are not linting and have a cluster connection, provide a Kubernetes-backed // implementation. if !e.LintMode { - funcMap["lookup"] = LookupFunc + funcMap[helmFuncLookup] = LookupFunc } // When DNS lookups are not enabled override the sprig function and return // an empty string. if !e.EnableDNS { - funcMap["getHostByName"] = func(name string) string { + funcMap["getHostByName"] = func(_ string) string { return "" } } @@ -229,14 +270,16 @@ func (e Engine) initFunMap(t *template.Template) { if err != nil { return "", fmt.Errorf("cidrNetwork: %w", err) } + return p.Masked().String(), nil } - t.Funcs(funcMap) + tmpl.Funcs(funcMap) } -// render takes a map of templates/values and renders them. -func (e Engine) render(tpls map[string]renderable) (rendered map[string]string, err error) { +// render takes a map of templates/values and renders them. The err return is +// named on purpose: the deferred recover below assigns to it. +func (e Engine) render(tpls map[string]renderable) (_ map[string]string, err error) { // Basically, what we do here is start with an empty parent template and then // build up a list of templates -- one for each file. Once all of the templates // have been parsed, we loop through again and execute every template. @@ -249,16 +292,17 @@ func (e Engine) render(tpls map[string]renderable) (rendered map[string]string, err = errors.Errorf("rendering template failed: %v", r) } }() - t := template.New("gotpl") + + tmpl := template.New("gotpl") if e.Strict { - t.Option("missingkey=error") + tmpl.Option("missingkey=error") } else { // Not that zero will attempt to add default values for types it knows, // but will still emit for others. We mitigate that later. - t.Option("missingkey=zero") + tmpl.Option("missingkey=zero") } - e.initFunMap(t) + e.initFunMap(tmpl) // We want to parse the templates in a predictable order. The order favors // higher-level (in file system) templates over deeply nested templates. @@ -266,12 +310,14 @@ func (e Engine) render(tpls map[string]renderable) (rendered map[string]string, for _, filename := range keys { r := tpls[filename] - if _, err := t.New(filename).Parse(r.tpl); err != nil { + + _, err := tmpl.New(filename).Parse(r.tpl) + if err != nil { return map[string]string{}, cleanupParseError(filename, err) } } - rendered = make(map[string]string, len(keys)) + rendered := make(map[string]string, len(keys)) for _, filename := range keys { // Don't render partials. We don't care out the direct output of partials. // They are only included from other templates. @@ -281,8 +327,11 @@ func (e Engine) render(tpls map[string]renderable) (rendered map[string]string, // At render time, add information about the template that is being rendered. vals := tpls[filename].vals vals["Template"] = chartutil.Values{"Name": filename, "BasePath": tpls[filename].basePath} + var buf strings.Builder - if err := t.ExecuteTemplate(&buf, filename, vals); err != nil { + + err := tmpl.ExecuteTemplate(&buf, filename, vals) + if err != nil { return map[string]string{}, cleanupExecError(filename, err) } @@ -295,29 +344,46 @@ func (e Engine) render(tpls map[string]renderable) (rendered map[string]string, return rendered, nil } +// errParseTemplate and errExecTemplate are the sentinel errors that +// cleanupParseError and cleanupExecError wrap when reformatting the +// raw text/template diagnostics produced during chart rendering. The +// sentinels exist so err113 (no dynamic errors) is satisfied while +// preserving the exact " error (...)" text the public +// tests assert against. +var ( + errParseTemplate = errors.New("parse error") + errExecTemplate = errors.New("execution error") +) + func cleanupParseError(filename string, err error) error { tokens := strings.Split(err.Error(), ": ") if len(tokens) == 1 { - // This might happen if a non-templating error occurs - return fmt.Errorf("parse error in (%s): %s", filename, err) + // This might happen if a non-templating error occurs. + return fmt.Errorf("%w in (%s): %w", errParseTemplate, filename, err) } // The first token is "template" // The second token is either "filename:lineno" or "filename:lineNo:columnNo" location := tokens[1] // The remaining tokens make up a stacktrace-like chain, ending with the relevant error errMsg := tokens[len(tokens)-1] - return fmt.Errorf("parse error at (%s): %s", string(location), errMsg) + + return fmt.Errorf("%w at (%s): %s", errParseTemplate, location, errMsg) } +// execErrorTokenCount is the number of colon-separated segments produced by +// text/template's ExecError formatter: "template", location, message. +const execErrorTokenCount = 3 + func cleanupExecError(filename string, err error) error { - if _, isExecError := err.(template.ExecError); !isExecError { + var execErr template.ExecError + if !errors.As(err, &execErr) { return err } - tokens := strings.SplitN(err.Error(), ": ", 3) - if len(tokens) != 3 { - // This might happen if a non-templating error occurs - return fmt.Errorf("execution error in (%s): %s", filename, err) + tokens := strings.SplitN(err.Error(), ": ", execErrorTokenCount) + if len(tokens) != execErrorTokenCount { + // This might happen if a non-templating error occurs. + return fmt.Errorf("%w in (%s): %w", errExecTemplate, filename, err) } // The first token is "template" @@ -326,7 +392,7 @@ func cleanupExecError(filename string, err error) error { parts := warnRegex.FindStringSubmatch(tokens[2]) if len(parts) >= 2 { - return fmt.Errorf("execution error at (%s): %s", string(location), parts[1]) + return fmt.Errorf("%w at (%s): %s", errExecTemplate, location, parts[1]) } return err @@ -334,12 +400,15 @@ func cleanupExecError(filename string, err error) error { func sortTemplates(tpls map[string]renderable) []string { keys := make([]string, len(tpls)) + i := 0 for key := range tpls { keys[i] = key i++ } + sort.Sort(sort.Reverse(byPathLen(keys))) + return keys } @@ -349,10 +418,12 @@ func (p byPathLen) Len() int { return len(p) } func (p byPathLen) Swap(i, j int) { p[j], p[i] = p[i], p[j] } func (p byPathLen) Less(i, j int) bool { a, b := p[i], p[j] + ca, cb := strings.Count(a, "/"), strings.Count(b, "/") if ca == cb { - return strings.Compare(a, b) == -1 + return a < b } + return ca < cb } @@ -362,6 +433,7 @@ func (p byPathLen) Less(i, j int) bool { func allTemplates(c *chart.Chart, vals chartutil.Values) map[string]renderable { templates := make(map[string]renderable) recAllTpls(c, templates, vals) + return templates } @@ -373,26 +445,31 @@ func recAllTpls(c *chart.Chart, templates map[string]renderable, vals chartutil. subCharts := make(map[string]any) chartMetaData := struct { chart.Metadata + IsRoot bool }{*c.Metadata, c.IsRoot()} next := map[string]any{ - "Chart": chartMetaData, - "Files": newFiles(c.Files), - "Release": vals["Release"], - "Capabilities": vals["Capabilities"], - "Values": make(chartutil.Values), - "Subcharts": subCharts, - "Disks": Disks, - "TalosVersion": vals["TalosVersion"], + "Chart": chartMetaData, + "Files": newFiles(c.Files), + "Release": vals["Release"], + "Capabilities": vals["Capabilities"], + "Values": make(chartutil.Values), + "Subcharts": subCharts, + "Disks": Disks, + helmKeyTalosVersion: vals[helmKeyTalosVersion], } // If there is a {{.Values.ThisChart}} in the parent metadata, // copy that into the {{.Values}} for this template. - if c.IsRoot() { + switch { + case c.IsRoot(): next["Values"] = vals["Values"] - } else if vs, err := vals.Table("Values." + c.Name()); err == nil { - next["Values"] = vs + default: + vs, err := vals.Table("Values." + c.Name()) + if err == nil { + next["Values"] = vs + } } for _, child := range c.Dependencies() { @@ -400,15 +477,17 @@ func recAllTpls(c *chart.Chart, templates map[string]renderable, vals chartutil. } newParentID := c.ChartFullPath() - for _, t := range c.Templates { - if t == nil { + for _, tplFile := range c.Templates { + if tplFile == nil { continue } - if !isTemplateValid(c, t.Name) { + + if !isTemplateValid(c, tplFile.Name) { continue } - templates[path.Join(newParentID, t.Name)] = renderable{ - tpl: string(t.Data), + + templates[path.Join(newParentID, tplFile.Name)] = renderable{ + tpl: string(tplFile.Data), vals: next, basePath: path.Join(newParentID, "templates"), } @@ -417,15 +496,16 @@ func recAllTpls(c *chart.Chart, templates map[string]renderable, vals chartutil. return next } -// isTemplateValid returns true if the template is valid for the chart type +// isTemplateValid returns true if the template is valid for the chart type. func isTemplateValid(ch *chart.Chart, templateName string) bool { if isLibraryChart(ch) { return strings.HasPrefix(filepath.Base(templateName), "_") } + return true } -// isLibraryChart returns true if the chart is a library chart +// isLibraryChart returns true if the chart is a library chart. func isLibraryChart(c *chart.Chart) bool { return strings.EqualFold(c.Metadata.Type, "library") } diff --git a/pkg/engine/helm/engine_test.go b/pkg/engine/helm/engine_test.go index 7c5d4c40..f01e02d8 100644 --- a/pkg/engine/helm/engine_test.go +++ b/pkg/engine/helm/engine_test.go @@ -24,19 +24,21 @@ import ( "testing" "text/template" + "github.com/cockroachdb/errors" + "helm.sh/helm/v3/pkg/chart" "helm.sh/helm/v3/pkg/chartutil" ) func TestSortTemplates(t *testing.T) { tpls := map[string]renderable{ - "/mychart/templates/foo.tpl": {}, - "/mychart/templates/charts/foo/charts/bar/templates/foo.tpl": {}, - "/mychart/templates/bar.tpl": {}, - "/mychart/templates/charts/foo/templates/bar.tpl": {}, - "/mychart/templates/_foo.tpl": {}, - "/mychart/templates/charts/foo/templates/foo.tpl": {}, - "/mychart/templates/charts/bar/templates/foo.tpl": {}, + tplFooDirect: {}, + tplFooNested: {}, + tplBarDirect: {}, + tplBarSubChartFoo: {}, + tplFooHelpersDirect: {}, + tplFooSubChartFoo: {}, + tplFooSubChartBar: {}, } got := sortTemplates(tpls) if len(got) != len(tpls) { @@ -44,13 +46,13 @@ func TestSortTemplates(t *testing.T) { } expect := []string{ - "/mychart/templates/charts/foo/charts/bar/templates/foo.tpl", - "/mychart/templates/charts/foo/templates/foo.tpl", - "/mychart/templates/charts/foo/templates/bar.tpl", - "/mychart/templates/charts/bar/templates/foo.tpl", - "/mychart/templates/foo.tpl", - "/mychart/templates/bar.tpl", - "/mychart/templates/_foo.tpl", + tplFooNested, + tplFooSubChartFoo, + tplBarSubChartFoo, + tplFooSubChartBar, + tplFooDirect, + tplBarDirect, + tplFooHelpersDirect, } for i, e := range expect { if got[i] != e { @@ -72,7 +74,7 @@ func TestFuncMap(t *testing.T) { } // Test for Engine-specific template functions. - expect := []string{"include", "required", "tpl", "toYaml", "fromYaml", "toToml", "toJson", "fromJson", "lookup"} + expect := []string{"include", "required", "tpl", "toYaml", "fromYaml", "toToml", "toJson", helmFuncFromJSON, "lookup"} for _, f := range expect { if _, ok := fns[f]; !ok { t.Errorf("Expected add-on function %q", f) @@ -84,7 +86,7 @@ func TestRender(t *testing.T) { c := &chart.Chart{ Metadata: &chart.Metadata{ Name: "moby", - Version: "1.2.3", + Version: helmTestVersion, }, Templates: []*chart.File{ {Name: "templates/test1", Data: []byte("{{.Values.outer | title }} {{.Values.inner | title}}")}, @@ -93,11 +95,11 @@ func TestRender(t *testing.T) { {Name: "templates/test4", Data: []byte("{{toJson .Values}}")}, {Name: "templates/test5", Data: []byte("{{getHostByName \"helm.sh\"}}")}, }, - Values: map[string]any{"outer": "DEFAULT", "inner": "DEFAULT"}, + Values: map[string]any{"outer": helmTestDefaultValue, "inner": helmTestDefaultValue}, } vals := map[string]any{ - "Values": map[string]any{ + helmKeyValues: map[string]any{ "outer": "spouter", "inner": "inn", "global": map[string]any{ @@ -134,7 +136,7 @@ func TestRenderRefsOrdering(t *testing.T) { parentChart := &chart.Chart{ Metadata: &chart.Metadata{ Name: "parent", - Version: "1.2.3", + Version: helmTestVersion, }, Templates: []*chart.File{ {Name: "templates/_helpers.tpl", Data: []byte(`{{- define "test" -}}parent value{{- end -}}`)}, @@ -143,8 +145,8 @@ func TestRenderRefsOrdering(t *testing.T) { } childChart := &chart.Chart{ Metadata: &chart.Metadata{ - Name: "child", - Version: "1.2.3", + Name: helmKeyChartChild, + Version: helmTestVersion, }, Templates: []*chart.File{ {Name: "templates/_helpers.tpl", Data: []byte(`{{- define "test" -}}child value{{- end -}}`)}, @@ -173,7 +175,7 @@ func TestRenderRefsOrdering(t *testing.T) { func TestRenderInternals(t *testing.T) { // Test the internals of the rendering tool. - vals := chartutil.Values{"Name": "one", "Value": "two"} + vals := chartutil.Values{helmKeyName: "one", "Value": "two"} tpls := map[string]renderable{ "one": {tpl: `Hello {{title .Name}}`, vals: vals}, "two": {tpl: `Goodbye {{upper .Value}}`, vals: vals}, @@ -208,7 +210,7 @@ func TestRenderWithDNS(t *testing.T) { c := &chart.Chart{ Metadata: &chart.Metadata{ Name: "moby", - Version: "1.2.3", + Version: helmTestVersion, }, Templates: []*chart.File{ {Name: "templates/test1", Data: []byte("{{getHostByName \"helm.sh\"}}")}, @@ -217,7 +219,7 @@ func TestRenderWithDNS(t *testing.T) { } vals := map[string]any{ - "Values": map[string]any{}, + helmKeyValues: map[string]any{}, } v, err := chartutil.CoalesceValues(c, vals) @@ -268,7 +270,7 @@ func TestParallelRenderInternals(t *testing.T) { } func TestParseErrors(t *testing.T) { - vals := chartutil.Values{"Values": map[string]any{}} + vals := chartutil.Values{helmKeyValues: map[string]any{}} tplsUndefinedFunction := map[string]renderable{ "undefined_function": {tpl: `{{foo}}`, vals: vals}, @@ -284,7 +286,7 @@ func TestParseErrors(t *testing.T) { } func TestExecErrors(t *testing.T) { - vals := chartutil.Values{"Values": map[string]any{}} + vals := chartutil.Values{helmKeyValues: map[string]any{}} cases := []struct { name string tpls map[string]renderable @@ -319,7 +321,7 @@ func TestExecErrors(t *testing.T) { { name: "MissingRequiredWithNewlines", tpls: map[string]renderable{ - "issue9981": {tpl: `{{required "foo is required\nmore info after the break" .Values.foo}}`, vals: vals}, + helmFixtureIssue9981: {tpl: `{{required "foo is required\nmore info after the break" .Values.foo}}`, vals: vals}, }, expected: `execution error at (issue9981:1:2): foo is required more info after the break`, @@ -327,7 +329,7 @@ more info after the break`, { name: "FailWithNewlines", tpls: map[string]renderable{ - "issue9981": {tpl: `{{fail "something is wrong\nlinebreak"}}`, vals: vals}, + helmFixtureIssue9981: {tpl: `{{fail "something is wrong\nlinebreak"}}`, vals: vals}, }, expected: `execution error at (issue9981:1:2): something is wrong linebreak`, @@ -348,7 +350,7 @@ linebreak`, } func TestFailErrors(t *testing.T) { - vals := chartutil.Values{"Values": map[string]any{}} + vals := chartutil.Values{helmKeyValues: map[string]any{}} failtpl := `All your base are belong to us{{ fail "This is an error" }}` tplsFailed := map[string]renderable{ @@ -370,7 +372,7 @@ func TestFailErrors(t *testing.T) { t.Fatal(err) } - expectStr := "All your base are belong to us" + expectStr := helmFixtureBaseAreUs if gotStr := out["failtpl"]; gotStr != expectStr { t.Errorf("Expected %q, got %q (%v)", expectStr, gotStr, out) } @@ -415,7 +417,7 @@ func TestChartValuesContainsIsRoot(t *testing.T) { }, } dep1 := &chart.Chart{ - Metadata: &chart.Metadata{Name: "child"}, + Metadata: &chart.Metadata{Name: helmKeyChartChild}, Templates: []*chart.File{ {Name: "templates/isroot", Data: []byte("{{.Chart.IsRoot}}")}, }, @@ -466,7 +468,6 @@ func TestRenderDependency(t *testing.T) { if out["outerchart/templates/outer"] != expect { t.Errorf("Expected %q, got %q", expect, out["outer"]) } - } func TestRenderNestedValues(t *testing.T) { @@ -479,7 +480,7 @@ func TestRenderNestedValues(t *testing.T) { subchartspath := "templates/subcharts.tpl" deepest := &chart.Chart{ - Metadata: &chart.Metadata{Name: "deepest"}, + Metadata: &chart.Metadata{Name: helmFixtureDeepest}, Templates: []*chart.File{ {Name: deepestpath, Data: []byte(`And this same {{.Values.what}} that smiles {{.Values.global.when}}`)}, {Name: checkrelease, Data: []byte(`Tomorrow will be {{default "happy" .Release.Name }}`)}, @@ -488,7 +489,7 @@ func TestRenderNestedValues(t *testing.T) { } inner := &chart.Chart{ - Metadata: &chart.Metadata{Name: "herrick"}, + Metadata: &chart.Metadata{Name: helmFixtureHerrick}, Templates: []*chart.File{ {Name: innerpath, Data: []byte(`Old {{.Values.who}} is still a-flyin'`)}, }, @@ -505,7 +506,7 @@ func TestRenderNestedValues(t *testing.T) { Values: map[string]any{ "what": "stinkweed", "who": "me", - "herrick": map[string]any{ + helmFixtureHerrick: map[string]any{ "who": "time", "what": "Sun", }, @@ -515,8 +516,8 @@ func TestRenderNestedValues(t *testing.T) { injValues := map[string]any{ "what": "rosebuds", - "herrick": map[string]any{ - "deepest": map[string]any{ + helmFixtureHerrick: map[string]any{ + helmFixtureDeepest: map[string]any{ "what": "flower", "where": "Heaven", }, @@ -532,10 +533,10 @@ func TestRenderNestedValues(t *testing.T) { } inject := chartutil.Values{ - "Values": tmp, - "Chart": outer.Metadata, - "Release": chartutil.Values{ - "Name": "dyin", + helmKeyValues: tmp, + helmKeyChart: outer.Metadata, + helmKeyRelease: chartutil.Values{ + helmKeyName: "dyin", }, } @@ -595,10 +596,10 @@ func TestRenderBuiltinValues(t *testing.T) { outer.AddDependency(inner) inject := chartutil.Values{ - "Values": "", - "Chart": outer.Metadata, - "Release": chartutil.Values{ - "Name": "Aeneid", + helmKeyValues: "", + helmKeyChart: outer.Metadata, + helmKeyRelease: chartutil.Values{ + helmKeyName: "Aeneid", }, } @@ -620,7 +621,6 @@ func TestRenderBuiltinValues(t *testing.T) { t.Errorf("Expected %q, got %q", expect, out[file]) } } - } func TestAlterFuncMap_include(t *testing.T) { @@ -642,10 +642,10 @@ func TestAlterFuncMap_include(t *testing.T) { } v := chartutil.Values{ - "Values": "", - "Chart": c.Metadata, - "Release": chartutil.Values{ - "Name": "Mistah Kurtz", + helmKeyValues: "", + helmKeyChart: c.Metadata, + helmKeyRelease: chartutil.Values{ + helmKeyName: "Mistah Kurtz", }, } @@ -676,13 +676,13 @@ func TestAlterFuncMap_require(t *testing.T) { } v := chartutil.Values{ - "Values": chartutil.Values{ + helmKeyValues: chartutil.Values{ "who": "us", "bases": 2, }, - "Chart": c.Metadata, - "Release": chartutil.Values{ - "Name": "That 90s meme", + helmKeyChart: c.Metadata, + helmKeyRelease: chartutil.Values{ + helmKeyName: helmFixture90sMeme, }, } @@ -691,7 +691,7 @@ func TestAlterFuncMap_require(t *testing.T) { t.Fatal(err) } - expectStr := "All your base are belong to us" + expectStr := helmFixtureBaseAreUs if gotStr := out["conan/templates/quote"]; gotStr != expectStr { t.Errorf("Expected %q, got %q (%v)", expectStr, gotStr, out) } @@ -703,12 +703,12 @@ func TestAlterFuncMap_require(t *testing.T) { // test required without passing in needed values with lint mode on // verifies lint replaces required with an empty string (should not fail) lintValues := chartutil.Values{ - "Values": chartutil.Values{ + helmKeyValues: chartutil.Values{ "who": "us", }, - "Chart": c.Metadata, - "Release": chartutil.Values{ - "Name": "That 90s meme", + helmKeyChart: c.Metadata, + helmKeyRelease: chartutil.Values{ + helmKeyName: helmFixture90sMeme, }, } var e Engine @@ -718,7 +718,7 @@ func TestAlterFuncMap_require(t *testing.T) { t.Fatal(err) } - expectStr = "All your base are belong to us" + expectStr = helmFixtureBaseAreUs if gotStr := out["conan/templates/quote"]; gotStr != expectStr { t.Errorf("Expected %q, got %q (%v)", expectStr, gotStr, out) } @@ -730,19 +730,19 @@ func TestAlterFuncMap_require(t *testing.T) { func TestAlterFuncMap_tpl(t *testing.T) { c := &chart.Chart{ - Metadata: &chart.Metadata{Name: "TplFunction"}, + Metadata: &chart.Metadata{Name: helmFixtureTplFunction}, Templates: []*chart.File{ {Name: "templates/base", Data: []byte(`Evaluate tpl {{tpl "Value: {{ .Values.value}}" .}}`)}, }, } v := chartutil.Values{ - "Values": chartutil.Values{ + helmKeyValues: chartutil.Values{ "value": "myvalue", }, - "Chart": c.Metadata, - "Release": chartutil.Values{ - "Name": "TestRelease", + helmKeyChart: c.Metadata, + helmKeyRelease: chartutil.Values{ + helmKeyName: helmFixtureTestRelease, }, } @@ -759,19 +759,19 @@ func TestAlterFuncMap_tpl(t *testing.T) { func TestAlterFuncMap_tplfunc(t *testing.T) { c := &chart.Chart{ - Metadata: &chart.Metadata{Name: "TplFunction"}, + Metadata: &chart.Metadata{Name: helmFixtureTplFunction}, Templates: []*chart.File{ {Name: "templates/base", Data: []byte(`Evaluate tpl {{tpl "Value: {{ .Values.value | quote}}" .}}`)}, }, } v := chartutil.Values{ - "Values": chartutil.Values{ + helmKeyValues: chartutil.Values{ "value": "myvalue", }, - "Chart": c.Metadata, - "Release": chartutil.Values{ - "Name": "TestRelease", + helmKeyChart: c.Metadata, + helmKeyRelease: chartutil.Values{ + helmKeyName: helmFixtureTestRelease, }, } @@ -788,19 +788,19 @@ func TestAlterFuncMap_tplfunc(t *testing.T) { func TestAlterFuncMap_tplinclude(t *testing.T) { c := &chart.Chart{ - Metadata: &chart.Metadata{Name: "TplFunction"}, + Metadata: &chart.Metadata{Name: helmFixtureTplFunction}, Templates: []*chart.File{ {Name: "templates/base", Data: []byte(`{{ tpl "{{include ` + "`" + `TplFunction/templates/_partial` + "`" + ` . | quote }}" .}}`)}, {Name: "templates/_partial", Data: []byte(`{{.Template.Name}}`)}, }, } v := chartutil.Values{ - "Values": chartutil.Values{ + helmKeyValues: chartutil.Values{ "value": "myvalue", }, - "Chart": c.Metadata, - "Release": chartutil.Values{ - "Name": "TestRelease", + helmKeyChart: c.Metadata, + helmKeyRelease: chartutil.Values{ + helmKeyName: helmFixtureTestRelease, }, } @@ -813,7 +813,6 @@ func TestAlterFuncMap_tplinclude(t *testing.T) { if got := out["TplFunction/templates/base"]; got != expect { t.Errorf("Expected %q, got %q (%v)", expect, got, out) } - } func TestRenderRecursionLimit(t *testing.T) { @@ -826,10 +825,10 @@ func TestRenderRecursionLimit(t *testing.T) { }, } v := chartutil.Values{ - "Values": "", - "Chart": c.Metadata, - "Release": chartutil.Values{ - "Name": "TestRelease", + helmKeyValues: "", + helmKeyChart: c.Metadata, + helmKeyRelease: chartutil.Values{ + helmKeyName: helmFixtureTestRelease, }, } expectErr := "rendering template has a nested reference name: recursion: unable to execute template" @@ -862,7 +861,6 @@ func TestRenderRecursionLimit(t *testing.T) { if got := out["overlook/templates/quote"]; got != expect { t.Errorf("Expected %q, got %q (%v)", expect, got, out) } - } func TestRenderLoadTemplateForTplFromFile(t *testing.T) { @@ -879,13 +877,13 @@ func TestRenderLoadTemplateForTplFromFile(t *testing.T) { } v := chartutil.Values{ - "Values": chartutil.Values{ + helmKeyValues: chartutil.Values{ "filename": "test", "filename2": "test2", }, - "Chart": c.Metadata, - "Release": chartutil.Values{ - "Name": "TestRelease", + helmKeyChart: c.Metadata, + helmKeyRelease: chartutil.Values{ + helmKeyName: helmFixtureTestRelease, }, } @@ -910,9 +908,9 @@ func TestRenderTplEmpty(t *testing.T) { }, } v := chartutil.Values{ - "Chart": c.Metadata, - "Release": chartutil.Values{ - "Name": "TestRelease", + helmKeyChart: c.Metadata, + helmKeyRelease: chartutil.Values{ + helmKeyName: helmFixtureTestRelease, }, } @@ -946,18 +944,18 @@ func TestRenderTplTemplateNames(t *testing.T) { }, } v := chartutil.Values{ - "Values": chartutil.Values{ + helmKeyValues: chartutil.Values{ "dot": chartutil.Values{ "Template": chartutil.Values{ - "BasePath": "path/to/template", - "Name": "name-of-template", - "Field": "extra-field", + helmKeyBasePath: "path/to/template", + helmKeyName: "name-of-template", + "Field": helmFixtureExtraField, }, }, }, - "Chart": c.Metadata, - "Release": chartutil.Values{ - "Name": "TestRelease", + helmKeyChart: c.Metadata, + helmKeyRelease: chartutil.Values{ + helmKeyName: helmFixtureTestRelease, }, } @@ -971,7 +969,7 @@ func TestRenderTplTemplateNames(t *testing.T) { "TplTemplateNames/templates/default-name": "TplTemplateNames/templates/default-name", "TplTemplateNames/templates/modified-basepath": "path/to/template", "TplTemplateNames/templates/modified-name": "name-of-template", - "TplTemplateNames/templates/modified-field": "extra-field", + "TplTemplateNames/templates/modified-field": helmFixtureExtraField, } for file, expect := range expects { if out[file] != expect { @@ -1007,7 +1005,7 @@ func TestRenderTplRedefines(t *testing.T) { }, } v := chartutil.Values{ - "Values": chartutil.Values{ + helmKeyValues: chartutil.Values{ "partialText": `{{define "partial"}}redefined-in-tpl{{end}}tpl: {{include "partial" .}}`, "manifestText": `{{define "manifest"}}redefined-in-tpl{{end}}tpl: {{include "manifest" .}}`, "manifestOnlyText": `tpl: {{include "manifest-only" .}}`, @@ -1018,9 +1016,9 @@ func TestRenderTplRedefines(t *testing.T) { `after-inner-tpl: {{include "nested" .}} {{include "nested-outer" . }}`, "innerText": `{{define "nested"}}redefined-in-inner-tpl{{end}}inner-tpl: {{include "nested" .}} {{include "nested-outer" . }}`, }, - "Chart": c.Metadata, - "Release": chartutil.Values{ - "Name": "TestRelease", + helmKeyChart: c.Metadata, + helmKeyRelease: chartutil.Values{ + helmKeyName: helmFixtureTestRelease, }, } @@ -1057,10 +1055,10 @@ func TestRenderTplMissingKey(t *testing.T) { }, } v := chartutil.Values{ - "Values": chartutil.Values{}, - "Chart": c.Metadata, - "Release": chartutil.Values{ - "Name": "TestRelease", + helmKeyValues: chartutil.Values{}, + helmKeyChart: c.Metadata, + helmKeyRelease: chartutil.Values{ + helmKeyName: helmFixtureTestRelease, }, } @@ -1090,10 +1088,10 @@ func TestRenderTplMissingKeyString(t *testing.T) { }, } v := chartutil.Values{ - "Values": chartutil.Values{}, - "Chart": c.Metadata, - "Release": chartutil.Values{ - "Name": "TestRelease", + helmKeyValues: chartutil.Values{}, + helmKeyChart: c.Metadata, + helmKeyRelease: chartutil.Values{ + helmKeyName: helmFixtureTestRelease, }, } @@ -1105,16 +1103,15 @@ func TestRenderTplMissingKeyString(t *testing.T) { t.Errorf("Expected error, got %v", out) return } - switch err.(type) { - case (template.ExecError): - errTxt := fmt.Sprint(err) - if !strings.Contains(errTxt, "noSuchKey") { - t.Errorf("Expected error to contain 'noSuchKey', got %s", errTxt) - } - default: + var execErr template.ExecError + if !errors.As(err, &execErr) { // Some unexpected error. t.Fatal(err) } + errTxt := fmt.Sprint(err) + if !strings.Contains(errTxt, "noSuchKey") { + t.Errorf("Expected error to contain 'noSuchKey', got %s", errTxt) + } } func TestTalosVersionInTemplateContext(t *testing.T) { @@ -1131,7 +1128,7 @@ func TestTalosVersionInTemplateContext(t *testing.T) { } vals := chartutil.Values{ - "Values": chartutil.Values{}, + helmKeyValues: chartutil.Values{}, "TalosVersion": "v1.12", } @@ -1161,7 +1158,7 @@ func TestTalosVersionEmpty(t *testing.T) { } vals := chartutil.Values{ - "Values": chartutil.Values{}, + helmKeyValues: chartutil.Values{}, } out, err := Render(c, vals) @@ -1190,7 +1187,7 @@ func TestTalosVersionConcurrentRender(t *testing.T) { }, } vals := chartutil.Values{ - "Values": chartutil.Values{}, + helmKeyValues: chartutil.Values{}, "TalosVersion": version, } out, err := Render(c, vals) @@ -1231,7 +1228,7 @@ func TestCidrNetworkTemplateFunc(t *testing.T) { Values: map[string]any{}, } var eng Engine - out, err := eng.Render(chrt, chartutil.Values{"Values": map[string]any{}}) + out, err := eng.Render(chrt, chartutil.Values{helmKeyValues: map[string]any{}}) if err != nil { return "", err } diff --git a/pkg/engine/helm/files.go b/pkg/engine/helm/files.go index f2cfdb3f..84825f89 100644 --- a/pkg/engine/helm/files.go +++ b/pkg/engine/helm/files.go @@ -36,6 +36,7 @@ func newFiles(from []*chart.File) files { for _, f := range from { files[f.Name] = f.Data } + return files } @@ -50,6 +51,7 @@ func (f files) GetBytes(name string) []byte { if v, ok := f[name]; ok { return v } + return []byte{} } @@ -70,21 +72,22 @@ func (f files) Get(name string) string { // // {{ range $name, $content := .Files.Glob("foo/**") }} // {{ $name }}: | -// {{ .Files.Get($name) | indent 4 }}{{ end }} +// {{ .Files.Get($name) | indent 4 }}{{ end }}. func (f files) Glob(pattern string) files { - g, err := glob.Compile(pattern, '/') + matcher, err := glob.Compile(pattern, '/') if err != nil { - g, _ = glob.Compile("**") + matcher, _ = glob.Compile("**") } - nf := newFiles(nil) + matched := newFiles(nil) + for name, contents := range f { - if g.Match(name) { - nf[name] = contents + if matcher.Match(name) { + matched[name] = contents } } - return nf + return matched } // AsConfig turns a Files group and flattens it to a YAML map suitable for @@ -101,7 +104,7 @@ func (f files) Glob(pattern string) files { // // data: // -// {{ .Files.Glob("config/**").AsConfig() | indent 4 }} +// {{ .Files.Glob("config/**").AsConfig() | indent 4 }}. func (f files) AsConfig() string { if f == nil { return "" @@ -131,7 +134,7 @@ func (f files) AsConfig() string { // // data: // -// {{ .Files.Glob("secrets/*").AsSecrets() | indent 4 }} +// {{ .Files.Glob("secrets/*").AsSecrets() | indent 4 }}. func (f files) AsSecrets() string { if f == nil { return "" @@ -152,14 +155,20 @@ func (f files) AsSecrets() string { // This is designed to be called from a template. // // {{ range .Files.Lines "foo/bar.html" }} -// {{ . }}{{ end }} -func (f files) Lines(path string) []string { - if f == nil || f[path] == nil { +// {{ . }}{{ end }}. +func (f files) Lines(name string) []string { + if f == nil || f[name] == nil { + return []string{} + } + + content := string(f[name]) + if content == "" { return []string{} } - s := string(f[path]) - if s[len(s)-1] == '\n' { - s = s[:len(s)-1] + + if content[len(content)-1] == '\n' { + content = content[:len(content)-1] } - return strings.Split(s, "\n") + + return strings.Split(content, "\n") } diff --git a/pkg/engine/helm/files_test.go b/pkg/engine/helm/files_test.go index e53263c7..4d32cd58 100644 --- a/pkg/engine/helm/files_test.go +++ b/pkg/engine/helm/files_test.go @@ -21,26 +21,32 @@ import ( "github.com/stretchr/testify/assert" ) -var cases = []struct { +type filesTestCase struct { path, data string -}{ - {"ship/captain.txt", "The Captain"}, - {"ship/stowaway.txt", "Legatt"}, - {"story/name.txt", "The Secret Sharer"}, - {"story/author.txt", "Joseph Conrad"}, - {"multiline/test.txt", "bar\nfoo\n"}, - {"multiline/test_with_blank_lines.txt", "bar\nfoo\n\n\n"}, +} + +func filesTestCases() []filesTestCase { + return []filesTestCase{ + {"ship/captain.txt", "The Captain"}, + {"ship/stowaway.txt", "Legatt"}, + {"story/name.txt", "The Secret Sharer"}, + {"story/author.txt", "Joseph Conrad"}, + {"multiline/test.txt", "bar\nfoo\n"}, + {"multiline/test_with_blank_lines.txt", "bar\nfoo\n\n\n"}, + } } func getTestFiles() files { - a := make(files, len(cases)) + cases := filesTestCases() + out := make(files, len(cases)) for _, c := range cases { - a[c.path] = []byte(c.data) + out[c.path] = []byte(c.data) } - return a + return out } func TestNewFiles(t *testing.T) { + cases := filesTestCases() files := getTestFiles() if len(files) != len(cases) { t.Errorf("Expected len() = %d, got %d", len(cases), len(files)) @@ -109,3 +115,28 @@ func TestBlankLines(t *testing.T) { as.Equal("bar", out[0]) as.Equal("", out[3]) } + +// TestLines_EmptyContent pins the guard against an `index out of +// range` panic when a key in the files map exists but maps to an +// empty []byte value. The trailing-newline stripper at the end of +// Lines reads `content[len(content)-1]` unconditionally, which would +// panic on a zero-length string. The empty-content branch returns an +// empty slice (matching the `f[name] == nil` shape) so a `{{ range +// .Files.Lines "empty.txt" }}` template iterates zero times instead +// of taking the engine down. +func TestLines_EmptyContent(t *testing.T) { + as := assert.New(t) + + f := files{ + "empty.txt": []byte{}, + "explicitly_nil": nil, + "only_newline.txt": []byte("\n"), + "single_line.txt": []byte("only"), + } + + as.Equal([]string{}, f.Lines("empty.txt"), "empty []byte must produce an empty slice without panicking") + as.Equal([]string{}, f.Lines("explicitly_nil"), "nil entry must short-circuit before the trailing-newline strip") + as.Equal([]string{}, f.Lines("missing.txt"), "absent key must short-circuit before the trailing-newline strip") + as.Equal([]string{""}, f.Lines("only_newline.txt"), "single trailing newline strips to one empty line") + as.Equal([]string{"only"}, f.Lines("single_line.txt"), "no-trailing-newline content is preserved as a single line") +} diff --git a/pkg/engine/helm/funcs.go b/pkg/engine/helm/funcs.go index 34d5795f..5c68c0da 100644 --- a/pkg/engine/helm/funcs.go +++ b/pkg/engine/helm/funcs.go @@ -28,6 +28,11 @@ import ( "sigs.k8s.io/yaml" ) +// notImplementedSentinel is the placeholder string returned by the +// include/tpl/required late-bound function stubs before the engine +// rebinds them per render. +const notImplementedSentinel = "not implemented" + // funcMap returns a mapping of all of the functions that Engine has. // // Because some functions are late-bound (e.g. contain context-sensitive @@ -42,36 +47,36 @@ import ( // These are late-bound in Engine.Render(). The // version included in the FuncMap is a placeholder. func funcMap() template.FuncMap { - f := sprig.TxtFuncMap() - delete(f, "env") - delete(f, "expandenv") + funcs := sprig.TxtFuncMap() + delete(funcs, "env") + delete(funcs, "expandenv") // Add some extra functionality extra := template.FuncMap{ - "toToml": toTOML, - "toYaml": toYAML, - "fromYaml": fromYAML, - "fromYamlArray": fromYAMLArray, - "toJson": toJSON, - "fromJson": fromJSON, - "fromJsonArray": fromJSONArray, + helmFuncToToml: toTOML, + helmFuncToYAML: toYAML, + helmFuncFromYAML: fromYAML, + "fromYamlArray": fromYAMLArray, + helmFuncToJSON: toJSON, + "fromJson": fromJSON, + "fromJsonArray": fromJSONArray, // This is a placeholder for the "include" function, which is // late-bound to a template. By declaring it here, we preserve the // integrity of the linter. - "include": func(string, any) string { return "not implemented" }, - "tpl": func(string, any) any { return "not implemented" }, - "required": func(string, any) (any, error) { return "not implemented", nil }, + helmFuncInclude: func(string, any) string { return notImplementedSentinel }, + helmFuncTpl: func(string, any) any { return notImplementedSentinel }, + helmFuncRequired: func(string, any) (any, error) { return notImplementedSentinel, nil }, // Provide a placeholder for the "lookup" function, which requires a kubernetes // connection. - "lookup": func(string, string, string, string) (map[string]any, error) { + helmFuncLookup: func(string, string, string, string) (map[string]any, error) { return map[string]any{}, nil }, } - maps.Copy(f, extra) + maps.Copy(funcs, extra) - return f + return funcs } // toYAML takes an interface, marshals it to yaml, and returns a string. It will @@ -84,6 +89,7 @@ func toYAML(v any) string { // Swallow errors inside of a template. return "" } + return strings.TrimSuffix(string(data), "\n") } @@ -96,9 +102,11 @@ func toYAML(v any) string { func fromYAML(str string) map[string]any { m := map[string]any{} - if err := yaml.Unmarshal([]byte(str), &m); err != nil { + err := yaml.Unmarshal([]byte(str), &m) + if err != nil { m["Error"] = err.Error() } + return m } @@ -109,12 +117,14 @@ func fromYAML(str string) map[string]any { // it tolerates errors. It will insert the returned error message string as // the first and only item in the returned array. func fromYAMLArray(str string) []any { - a := []any{} + out := []any{} - if err := yaml.Unmarshal([]byte(str), &a); err != nil { - a = []any{err.Error()} + err := yaml.Unmarshal([]byte(str), &out) + if err != nil { + out = []any{err.Error()} } - return a + + return out } // toTOML takes an interface, marshals it to toml, and returns a string. It will @@ -124,10 +134,12 @@ func fromYAMLArray(str string) []any { func toTOML(v any) string { b := bytes.NewBuffer(nil) e := toml.NewEncoder(b) + err := e.Encode(v) if err != nil { return err.Error() } + return b.String() } @@ -141,6 +153,7 @@ func toJSON(v any) string { // Swallow errors inside of a template. return "" } + return string(data) } @@ -153,9 +166,11 @@ func toJSON(v any) string { func fromJSON(str string) map[string]any { m := make(map[string]any) - if err := json.Unmarshal([]byte(str), &m); err != nil { + err := json.Unmarshal([]byte(str), &m) + if err != nil { m["Error"] = err.Error() } + return m } @@ -166,10 +181,12 @@ func fromJSON(str string) map[string]any { // it tolerates errors. It will insert the returned error message string as // the first and only item in the returned array. func fromJSONArray(str string) []any { - a := []any{} + out := []any{} - if err := json.Unmarshal([]byte(str), &a); err != nil { - a = []any{err.Error()} + err := json.Unmarshal([]byte(str), &out) + if err != nil { + out = []any{err.Error()} } - return a + + return out } diff --git a/pkg/engine/helm/funcs_test.go b/pkg/engine/helm/funcs_test.go index dc08e58a..17e511e5 100644 --- a/pkg/engine/helm/funcs_test.go +++ b/pkg/engine/helm/funcs_test.go @@ -25,7 +25,8 @@ import ( ) func TestFuncs(t *testing.T) { - //TODO write tests for failure cases + //nolint:godox // upstream Helm carries this TODO; preserved verbatim. + // TODO write tests for failure cases tests := []struct { tpl, expect string vars any diff --git a/pkg/engine/render_test.go b/pkg/engine/render_test.go index 1f870ef7..e799d6df 100644 --- a/pkg/engine/render_test.go +++ b/pkg/engine/render_test.go @@ -125,6 +125,7 @@ func renderChartTemplate(t *testing.T, chartPath string, templateFile string, ta key := path.Join(chrt.Name(), templateFile) result, ok := out[key] if !ok { + //nolint:prealloc // capacity-zero map iteration; len(out) is the upper bound but we don't know if all keys land in keys. var keys []string for k := range out { keys = append(keys, k) @@ -184,6 +185,7 @@ func renderChartTemplateWithLookup(t *testing.T, chartPath string, templateFile key := path.Join(chrt.Name(), templateFile) result, ok := out[key] if !ok { + //nolint:prealloc // capacity-zero map iteration; len(out) is upper bound, not all keys necessarily land in keys. var keys []string for k := range out { keys = append(keys, k) @@ -647,7 +649,7 @@ machine: defer func() { helmEngine.LookupFunc = origLookup }() // Simulate online mode: return route data with a real interface name. - helmEngine.LookupFunc = func(resource, namespace, name string) (map[string]any, error) { + helmEngine.LookupFunc = func(resource, _, name string) (map[string]any, error) { if resource == "routes" && name == "" { return map[string]any{ "apiVersion": "v1", @@ -794,7 +796,7 @@ func bondTopologyLookup() func(string, string, string) (map[string]any, error) { "dnsServers": []any{"8.8.8.8", "1.1.1.1"}, }, } - return func(resource, namespace, id string) (map[string]any, error) { + return func(resource, _, id string) (map[string]any, error) { switch resource { case "routes": return routesList, nil @@ -909,7 +911,7 @@ func vlanOnBondTopologyLookup() func(string, string, string) (map[string]any, er "dnsServers": []any{"8.8.8.8"}, }, } - return func(resource, namespace, id string) (map[string]any, error) { + return func(resource, _, id string) (map[string]any, error) { switch resource { case "routes": return routesList, nil @@ -1543,6 +1545,8 @@ link_selector={{ include "talm.discovered.default_link_selector_by_gateway" . }} // gateway/address level. The two-NIC subtest below catches the same // bug at the link-identification level — both must hold for the chart // to produce a working config on real dual-stack hardware. +// +//nolint:godox // tracked engine bug; comment intentional pending the link-identification refactor. func TestCozystackChartRendersIPv4GatewayOnDualStack(t *testing.T) { t.Run("single NIC dual-stack", func(t *testing.T) { output := renderChartTemplateWithLookup(t, "../../charts/cozystack", "templates/controlplane.yaml", dualStackNicLookup(), "v1.12") @@ -3471,7 +3475,7 @@ machine: t.Fatalf("MergeFileAsPatch: %v", err) } - renderedCount := strings.Count(string(rendered), "127.0.0.1") + renderedCount := strings.Count(rendered, "127.0.0.1") mergedCount := strings.Count(string(merged), "127.0.0.1") if renderedCount == 0 { t.Fatalf("test fixture broken: rendered output has no 127.0.0.1 to count duplicates against") @@ -4088,7 +4092,7 @@ func simpleNicLookup() func(string, string, string) (map[string]any, error) { "dnsServers": []any{"8.8.8.8"}, }, } - return func(resource, namespace, id string) (map[string]any, error) { + return func(resource, _, id string) (map[string]any, error) { switch resource { case "routes": return routesList, nil @@ -4189,7 +4193,7 @@ func dualStackNicLookup() func(string, string, string) (map[string]any, error) { "dnsServers": []any{"8.8.8.8"}, }, } - return func(resource, namespace, id string) (map[string]any, error) { + return func(resource, _, id string) (map[string]any, error) { switch resource { case "routes": return routesList, nil @@ -4294,7 +4298,7 @@ func multiNicLookup() func(string, string, string) (map[string]any, error) { "dnsServers": []any{"8.8.8.8"}, }, } - return func(resource, namespace, id string) (map[string]any, error) { + return func(resource, _, id string) (map[string]any, error) { switch resource { case "routes": return routesList, nil @@ -4390,7 +4394,7 @@ func multiNicWithVLANLookup() func(string, string, string) (map[string]any, erro "dnsServers": []any{"8.8.8.8"}, }, } - return func(resource, namespace, id string) (map[string]any, error) { + return func(resource, _, id string) (map[string]any, error) { switch resource { case "routes": return routesList, nil @@ -4492,7 +4496,7 @@ func legacyInterfacesInRunningConfigLookup() func(string, string, string) (map[s "dnsServers": []any{"8.8.8.8"}, }, } - return func(resource, namespace, id string) (map[string]any, error) { + return func(resource, _, id string) (map[string]any, error) { switch resource { case "routes": return routesList, nil @@ -4608,7 +4612,7 @@ func bondWithSlavesLookup() func(string, string, string) (map[string]any, error) "dnsServers": []any{"8.8.8.8"}, }, } - return func(resource, namespace, id string) (map[string]any, error) { + return func(resource, _, id string) (map[string]any, error) { switch resource { case "routes": return routesList, nil @@ -4703,7 +4707,7 @@ func bondWithoutBondMasterLookup() func(string, string, string) (map[string]any, "dnsServers": []any{"8.8.8.8"}, }, } - return func(resource, namespace, id string) (map[string]any, error) { + return func(resource, _, id string) (map[string]any, error) { switch resource { case "routes": return routesList, nil @@ -4791,7 +4795,7 @@ func bridgeLookup() func(string, string, string) (map[string]any, error) { "dnsServers": []any{"8.8.8.8"}, }, } - return func(resource, namespace, id string) (map[string]any, error) { + return func(resource, _, id string) (map[string]any, error) { switch resource { case "routes": return routesList, nil @@ -4874,7 +4878,7 @@ func vipActiveOnLinkLookup() func(string, string, string) (map[string]any, error "dnsServers": []any{"8.8.8.8"}, }, } - return func(resource, namespace, id string) (map[string]any, error) { + return func(resource, _, id string) (map[string]any, error) { switch resource { case "routes": return routesList, nil @@ -4952,7 +4956,7 @@ func bridgeWithGatewayLookup() func(string, string, string) (map[string]any, err "dnsServers": []any{"8.8.8.8"}, }, } - return func(resource, namespace, id string) (map[string]any, error) { + return func(resource, _, id string) (map[string]any, error) { switch resource { case "routes": return routesList, nil @@ -5038,7 +5042,7 @@ func vlanWithoutVlanIDLookup() func(string, string, string) (map[string]any, err "dnsServers": []any{"8.8.8.8"}, }, } - return func(resource, namespace, id string) (map[string]any, error) { + return func(resource, _, id string) (map[string]any, error) { switch resource { case "routes": return routesList, nil @@ -5130,7 +5134,7 @@ func vlanWithoutParentLookup() func(string, string, string) (map[string]any, err "dnsServers": []any{"8.8.8.8"}, }, } - return func(resource, namespace, id string) (map[string]any, error) { + return func(resource, _, id string) (map[string]any, error) { switch resource { case "routes": return routesList, nil @@ -5238,7 +5242,7 @@ func dualStackTwoNicsLookup() func(string, string, string) (map[string]any, erro "dnsServers": []any{"8.8.8.8"}, }, } - return func(resource, namespace, id string) (map[string]any, error) { + return func(resource, _, id string) (map[string]any, error) { switch resource { case "routes": return routesList, nil @@ -5279,7 +5283,7 @@ func freshNicLookup() func(string, string, string) (map[string]any, error) { "kind": "List", "items": []any{}, } - return func(resource, namespace, id string) (map[string]any, error) { + return func(resource, _, _ string) (map[string]any, error) { switch resource { case "routes", "links", "addresses": return emptyList, nil @@ -5453,7 +5457,7 @@ func TestMultiDocCozystack_EndpointRequired(t *testing.T) { } // TestMultiDocCozystack_InvalidClusterNameOverride ensures invalid -// clusterName overrides are rejected +// clusterName overrides are rejected. func TestMultiDocCozystack_InvalidClusterNameOverride(t *testing.T) { origLookup := helmEngine.LookupFunc t.Cleanup(func() { helmEngine.LookupFunc = origLookup }) @@ -5910,7 +5914,7 @@ func TestMultiDocCozystack_DedupesDuplicateSubnetsFromMultipleAddresses(t *testi }}, }, } - return func(resource, namespace, id string) (map[string]any, error) { + return func(resource, _, id string) (map[string]any, error) { switch resource { case "routes": return routesList, nil diff --git a/pkg/modeline/consts_test.go b/pkg/modeline/consts_test.go new file mode 100644 index 00000000..dcf2469b --- /dev/null +++ b/pkg/modeline/consts_test.go @@ -0,0 +1,10 @@ +package modeline + +// Shared test fixture literals. Hoisted into package-level consts to keep +// goconst quiet across modeline_test.go and contract_test.go. +const ( + testNodeIP1 = "1.2.3.4" + testNodeIP2 = "192.168.100.2" + testLoopback = "127.0.0.1" + testTemplateControlPln = "templates/controlplane.yaml" +) diff --git a/pkg/modeline/contract_test.go b/pkg/modeline/contract_test.go index 7767a361..cfe5a9a4 100644 --- a/pkg/modeline/contract_test.go +++ b/pkg/modeline/contract_test.go @@ -44,10 +44,10 @@ func TestContract_ParseModeline_RejectsWithoutPrefix(t *testing.T) { cases := []string{ "", "# nothing", - "# vim: set ft=yaml", // editor modeline (Vim) - "# talm noprefix", // missing colon - `#talm: nodes=["x"]`, // missing space between # and talm - `# talm:nodes=["x"]`, // missing space after colon + "# vim: set ft=yaml", // editor modeline (Vim) + "# talm noprefix", // missing colon + `#talm: nodes=["x"]`, // missing space between # and talm + `# talm:nodes=["x"]`, // missing space after colon } for _, line := range cases { t.Run(line, func(t *testing.T) { @@ -68,7 +68,7 @@ func TestContract_ParseModeline_TrimsLine(t *testing.T) { if err != nil { t.Fatalf("expected indented modeline to parse, got: %v", err) } - if !reflect.DeepEqual(got.Nodes, []string{"1.2.3.4"}) { + if !reflect.DeepEqual(got.Nodes, []string{testNodeIP1}) { t.Errorf("expected Nodes=[1.2.3.4], got %v", got.Nodes) } } @@ -145,14 +145,15 @@ func TestContract_ReadAndParseModeline_FirstLineOnly(t *testing.T) { machine: type: worker ` - if err := os.WriteFile(file, []byte(content), 0o644); err != nil { + err := os.WriteFile(file, []byte(content), 0o644) + if err != nil { t.Fatal(err) } got, err := ReadAndParseModeline(file) if err != nil { t.Fatalf("unexpected error: %v", err) } - if !reflect.DeepEqual(got.Nodes, []string{"1.2.3.4"}) { + if !reflect.DeepEqual(got.Nodes, []string{testNodeIP1}) { t.Errorf("expected first line only, got %+v", got) } } @@ -171,10 +172,11 @@ func TestContract_ReadAndParseModeline_MissingFile(t *testing.T) { func TestContract_ReadAndParseModeline_EmptyFile(t *testing.T) { dir := t.TempDir() file := filepath.Join(dir, "empty.yaml") - if err := os.WriteFile(file, []byte(""), 0o644); err != nil { + err := os.WriteFile(file, []byte(""), 0o644) + if err != nil { t.Fatal(err) } - _, err := ReadAndParseModeline(file) + _, err = ReadAndParseModeline(file) if err == nil { t.Fatal("expected error for empty file") } @@ -188,10 +190,11 @@ func TestContract_ReadAndParseModeline_EmptyFile(t *testing.T) { func TestContract_ReadAndParseModeline_NonModelineFirstLine(t *testing.T) { dir := t.TempDir() file := filepath.Join(dir, "no-modeline.yaml") - if err := os.WriteFile(file, []byte("machine:\n"), 0o644); err != nil { + err := os.WriteFile(file, []byte("machine:\n"), 0o644) + if err != nil { t.Fatal(err) } - _, err := ReadAndParseModeline(file) + _, err = ReadAndParseModeline(file) if err == nil { t.Fatal("expected error for first line without modeline") } @@ -210,18 +213,21 @@ func TestContract_GenerateModeline_RoundTrip(t *testing.T) { endpoints []string templates []string }{ - {"all populated", - []string{"1.2.3.4", "5.6.7.8"}, - []string{"1.2.3.4"}, - []string{"templates/controlplane.yaml"}, + { + "all populated", + []string{testNodeIP1, "5.6.7.8"}, + []string{testNodeIP1}, + []string{testTemplateControlPln}, }, {"empty all", nil, nil, nil}, - {"only nodes", - []string{"1.2.3.4"}, + { + "only nodes", + []string{testNodeIP1}, nil, nil, }, - {"special characters in path", + { + "special characters in path", []string{"node.example.com"}, []string{"https://api.example.com:6443"}, []string{"path/with spaces/template.yaml"}, diff --git a/pkg/modeline/modeline.go b/pkg/modeline/modeline.go index 4d1b7844..88621abb 100644 --- a/pkg/modeline/modeline.go +++ b/pkg/modeline/modeline.go @@ -6,33 +6,49 @@ import ( "fmt" "os" "strings" + + "github.com/cockroachdb/errors" ) -// Config structure for storing settings from modeline +// Config structure for storing settings from modeline. type Config struct { Nodes []string Endpoints []string Templates []string } -// ParseModeline parses a modeline string and populates the Config structure +// ParseModeline parses a modeline string and populates the Config structure. func ParseModeline(line string) (*Config, error) { config := &Config{} trimLine := strings.TrimSpace(line) + prefix := "# talm: " if after, ok := strings.CutPrefix(trimLine, prefix); ok { content := after + parts := strings.SplitSeq(content, ", ") for part := range parts { keyVal := strings.SplitN(strings.TrimSpace(part), "=", 2) if len(keyVal) != 2 { - return nil, fmt.Errorf("invalid format of modeline part: %s", part) + //nolint:wrapcheck // cockroachdb/errors.WithHintf is the project's wrapping/hinting idiom + return nil, errors.WithHintf( + errors.Newf("invalid format of modeline part: %s", part), + "expected key=value form (value is a JSON array); see modeline contract", + ) } + key := keyVal[0] val := keyVal[1] + var arr []string - if err := json.Unmarshal([]byte(val), &arr); err != nil { - return nil, fmt.Errorf("error parsing JSON array for key %s, value %s, error: %v", key, val, err) + + err := json.Unmarshal([]byte(val), &arr) + if err != nil { + //nolint:wrapcheck // cockroachdb/errors.WithHintf is the project's wrapping/hinting idiom + return nil, errors.WithHintf( + errors.Wrapf(err, "error parsing JSON array for key %s, value %s", key, val), + "value must be a JSON array, e.g. nodes=[\"1.2.3.4\"]", + ) } // Assign values to Config fields based on known keys switch key { @@ -45,53 +61,74 @@ func ParseModeline(line string) (*Config, error) { // Ignore unknown keys } } + return config, nil } - return nil, fmt.Errorf("modeline prefix not found") + + //nolint:wrapcheck // cockroachdb/errors.WithHint is the project's wrapping/hinting idiom + return nil, errors.WithHint( + errors.New("modeline prefix not found"), + "first line must begin with '# talm: '", + ) } // ReadAndParseModeline reads the first line from a file and parses the modeline. func ReadAndParseModeline(filePath string) (*Config, error) { file, err := os.Open(filePath) if err != nil { - return nil, fmt.Errorf("error opening config file: %v", err) + //nolint:wrapcheck // cockroachdb/errors.WithHintf is the project's wrapping/hinting idiom + return nil, errors.WithHintf( + errors.Wrap(err, "error opening config file"), + "check that %s exists and is readable", filePath, + ) } defer func() { _ = file.Close() }() scanner := bufio.NewScanner(file) if scanner.Scan() { firstLine := scanner.Text() + return ParseModeline(firstLine) } - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("error reading first line of config file: %v", err) + err = scanner.Err() + if err != nil { + //nolint:wrapcheck // cockroachdb/errors.WithHint is the project's wrapping/hinting idiom + return nil, errors.WithHint( + errors.Wrap(err, "error reading first line of config file"), + "file may be truncated or unreadable", + ) } - return nil, fmt.Errorf("config file is empty") + //nolint:wrapcheck // cockroachdb/errors.WithHint is the project's wrapping/hinting idiom + return nil, errors.WithHint( + errors.New("config file is empty"), + "per-node values file must start with a modeline like '# talm: nodes=[...]'", + ) } -// GenerateModeline creates a modeline string using JSON formatting for values -func GenerateModeline(nodes []string, endpoints []string, templates []string) (string, error) { +// GenerateModeline creates a modeline string using JSON formatting for values. +func GenerateModeline(nodes, endpoints, templates []string) (string, error) { // Convert Nodes to JSON nodesJSON, err := json.Marshal(nodes) if err != nil { - return "", fmt.Errorf("failed to marshal nodes: %v", err) + return "", errors.Wrap(err, "failed to marshal nodes") } // Convert Endpoints to JSON endpointsJSON, err := json.Marshal(endpoints) if err != nil { - return "", fmt.Errorf("failed to marshal endpoints: %v", err) + return "", errors.Wrap(err, "failed to marshal endpoints") } // Convert Templates to JSON templatesJSON, err := json.Marshal(templates) if err != nil { - return "", fmt.Errorf("failed to marshal templates: %v", err) + return "", errors.Wrap(err, "failed to marshal templates") } // Form the final modeline string modeline := fmt.Sprintf(`# talm: nodes=%s, endpoints=%s, templates=%s`, string(nodesJSON), string(endpointsJSON), string(templatesJSON)) + return modeline, nil } diff --git a/pkg/modeline/modeline_test.go b/pkg/modeline/modeline_test.go index 9db1537c..5d75d60f 100644 --- a/pkg/modeline/modeline_test.go +++ b/pkg/modeline/modeline_test.go @@ -16,9 +16,9 @@ func TestParseModeline(t *testing.T) { name: "valid modeline with all known keys", line: `# talm: nodes=["192.168.100.2"], endpoints=["1.2.3.4","127.0.0.1","192.168.100.2"], templates=["templates/controlplane.yaml","templates/worker.yaml"]`, want: &Config{ - Nodes: []string{"192.168.100.2"}, - Endpoints: []string{"1.2.3.4", "127.0.0.1", "192.168.100.2"}, - Templates: []string{"templates/controlplane.yaml", "templates/worker.yaml"}, + Nodes: []string{testNodeIP2}, + Endpoints: []string{testNodeIP1, testLoopback, testNodeIP2}, + Templates: []string{testTemplateControlPln, "templates/worker.yaml"}, }, wantErr: false, }, @@ -26,8 +26,8 @@ func TestParseModeline(t *testing.T) { name: "modeline with unknown key", line: `# talm: nodes=["192.168.100.2"], endpoints=["1.2.3.4","127.0.0.1","192.168.100.2"], unknown=["value"]`, want: &Config{ - Nodes: []string{"192.168.100.2"}, - Endpoints: []string{"1.2.3.4", "127.0.0.1", "192.168.100.2"}, + Nodes: []string{testNodeIP2}, + Endpoints: []string{testNodeIP1, testLoopback, testNodeIP2}, }, wantErr: false, }, diff --git a/pkg/secureperm/contract_unix_test.go b/pkg/secureperm/contract_unix_test.go index 19226b54..f7ad894e 100644 --- a/pkg/secureperm/contract_unix_test.go +++ b/pkg/secureperm/contract_unix_test.go @@ -27,6 +27,7 @@ package secureperm_test import ( "bytes" + "errors" "os" "path/filepath" "strings" @@ -45,7 +46,8 @@ func TestContract_WriteFile_BytesIntegrity(t *testing.T) { // material like age private keys or certificate DER. want := []byte("AGE-SECRET-KEY-1\x00\x80\xffend\nline2\n") - if err := secureperm.WriteFile(path, want); err != nil { + err := secureperm.WriteFile(path, want) + if err != nil { t.Fatalf("WriteFile: %v", err) } @@ -64,7 +66,8 @@ func TestContract_WriteFile_BytesIntegrity(t *testing.T) { func TestContract_WriteFile_EmptyPayload(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "empty") - if err := secureperm.WriteFile(path, nil); err != nil { + err := secureperm.WriteFile(path, nil) + if err != nil { t.Fatalf("WriteFile: %v", err) } info, err := os.Stat(path) @@ -87,7 +90,8 @@ func TestContract_WriteFile_EmptyPayload(t *testing.T) { func TestContract_WriteFile_NoTmpLeftoverOnSuccess(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "out") - if err := secureperm.WriteFile(path, []byte("data")); err != nil { + err := secureperm.WriteFile(path, []byte("data")) + if err != nil { t.Fatalf("WriteFile: %v", err) } entries, err := os.ReadDir(dir) @@ -145,7 +149,8 @@ func TestContract_WriteFile_Idempotent(t *testing.T) { payload := []byte("payload-v1") for i := range 3 { - if err := secureperm.WriteFile(path, payload); err != nil { + err := secureperm.WriteFile(path, payload) + if err != nil { t.Fatalf("WriteFile iteration %d: %v", i, err) } } @@ -173,18 +178,18 @@ func TestContract_WriteFile_Idempotent(t *testing.T) { } } -// Contract: LockDown on a non-existent file surfaces an os.PathError -// — callers wrap with their own context. No silent success is -// allowed: silently skipping the chmod would let secret material -// remain world-readable if the path was never created. +// Contract: LockDown on a non-existent file surfaces a +// not-exist-class error detectable via errors.Is(err, os.ErrNotExist). +// No silent success is allowed: silently skipping the chmod would let +// secret material remain world-readable if the path was never created. func TestContract_LockDown_MissingFileErrors(t *testing.T) { missing := filepath.Join(t.TempDir(), "absent") err := secureperm.LockDown(missing) if err == nil { t.Fatal("expected error for missing file") } - if !os.IsNotExist(err) { - t.Errorf("expected os.IsNotExist, got: %v", err) + if !errors.Is(err, os.ErrNotExist) { + t.Errorf("expected errors.Is(err, os.ErrNotExist), got: %v", err) } } @@ -194,10 +199,12 @@ func TestContract_LockDown_MissingFileErrors(t *testing.T) { func TestContract_LockDown_AlreadyTightIsNoOp(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "already") - if err := os.WriteFile(path, []byte("ok"), 0o600); err != nil { + err := os.WriteFile(path, []byte("ok"), 0o600) + if err != nil { t.Fatal(err) } - if err := secureperm.LockDown(path); err != nil { + err = secureperm.LockDown(path) + if err != nil { t.Fatalf("LockDown on tight file: %v", err) } info, err := os.Stat(path) diff --git a/pkg/secureperm/secureperm_test.go b/pkg/secureperm/secureperm_test.go index 92a5ab98..dd43c797 100644 --- a/pkg/secureperm/secureperm_test.go +++ b/pkg/secureperm/secureperm_test.go @@ -28,7 +28,8 @@ func TestWriteFile_PersistsContent(t *testing.T) { path := filepath.Join(dir, "secret.txt") payload := []byte("topsecret") - if err := secureperm.WriteFile(path, payload); err != nil { + err := secureperm.WriteFile(path, payload) + if err != nil { t.Fatalf("WriteFile: %v", err) } @@ -45,17 +46,20 @@ func TestLockDown_OnExistingFile(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "existing.txt") - if err := os.WriteFile(path, []byte("data"), 0o644); err != nil { + err := os.WriteFile(path, []byte("data"), 0o644) + if err != nil { t.Fatalf("seed file: %v", err) } - if err := secureperm.LockDown(path); err != nil { + err = secureperm.LockDown(path) + if err != nil { t.Fatalf("LockDown: %v", err) } // Re-read to confirm the file is still accessible to the owner after // tightening. On Unix this is trivial; on Windows this verifies the // DACL did not accidentally exclude the current user. - if _, err := os.ReadFile(path); err != nil { + _, err = os.ReadFile(path) + if err != nil { t.Fatalf("owner cannot read locked file: %v", err) } } diff --git a/pkg/secureperm/secureperm_unix.go b/pkg/secureperm/secureperm_unix.go index 57ee312c..f22f0664 100644 --- a/pkg/secureperm/secureperm_unix.go +++ b/pkg/secureperm/secureperm_unix.go @@ -17,11 +17,16 @@ package secureperm import ( - "fmt" "os" "path/filepath" + + "github.com/cockroachdb/errors" ) +// secretsFileMode is the permission bitmask for owner-only read/write, +// used for files that hold cluster secrets (talosconfig, secrets.yaml). +const secretsFileMode os.FileMode = 0o600 + // WriteFile writes data to path atomically with mode 0o600. // // Atomic in the sense that either the write fully succeeds and path @@ -46,26 +51,32 @@ import ( // invoke talm under a consistent identity. func WriteFile(path string, data []byte) error { dir := filepath.Dir(path) - f, err := os.CreateTemp(dir, ".secureperm-*") + + tmpFile, err := os.CreateTemp(dir, ".secureperm-*") if err != nil { - return fmt.Errorf("create tmp in %s: %w", dir, err) + return errors.Wrapf(err, "create tmp in %s", dir) } - tmpPath := f.Name() + + tmpPath := tmpFile.Name() + committed := false defer func() { if !committed { - _ = f.Close() + _ = tmpFile.Close() _ = os.Remove(tmpPath) } }() // os.CreateTemp already uses 0o600 but enforce explicitly so the // contract survives any future stdlib change. - if err := f.Chmod(0o600); err != nil { - return fmt.Errorf("chmod tmp: %w", err) + err = tmpFile.Chmod(secretsFileMode) + if err != nil { + return errors.Wrap(err, "chmod tmp") } - if _, err := f.Write(data); err != nil { - return fmt.Errorf("write tmp: %w", err) + + _, err = tmpFile.Write(data) + if err != nil { + return errors.Wrap(err, "write tmp") } // fsync the tmp file before rename so its contents are on stable // storage; otherwise a crash/power-loss between rename and the @@ -73,27 +84,40 @@ func WriteFile(path string, data []byte) error { // zero-length or stale data on reboot — the canonical failure mode // the atomic-rename pattern is meant to avoid. Secrets files are // not reconstructible, so the full fsync is warranted. - if err := f.Sync(); err != nil { - return fmt.Errorf("sync tmp: %w", err) + err = tmpFile.Sync() + if err != nil { + return errors.Wrap(err, "sync tmp") } - if err := f.Close(); err != nil { - return fmt.Errorf("close tmp: %w", err) + + err = tmpFile.Close() + if err != nil { + return errors.Wrap(err, "close tmp") } - if err := os.Rename(tmpPath, path); err != nil { - return fmt.Errorf("rename tmp -> %s: %w", path, err) + + err = os.Rename(tmpPath, path) + if err != nil { + return errors.Wrapf(err, "rename tmp -> %s", path) } + committed = true // Best-effort fsync of the parent dir so the rename entry itself is // durable. Ignored errors: dir fsync is unsupported on a few // filesystems; the tmp fsync above already protects the payload. - if d, openErr := os.Open(dir); openErr == nil { - _ = d.Sync() - _ = d.Close() + dirFile, openErr := os.Open(dir) + if openErr == nil { + _ = dirFile.Sync() + _ = dirFile.Close() } + return nil } // LockDown narrows an existing file's permissions to 0o600. func LockDown(path string) error { - return os.Chmod(path, 0o600) + err := os.Chmod(path, secretsFileMode) + if err != nil { + return errors.Wrapf(err, "chmod %s", path) + } + + return nil } diff --git a/pkg/secureperm/secureperm_unix_test.go b/pkg/secureperm/secureperm_unix_test.go index 83a7380b..1e5a66b4 100644 --- a/pkg/secureperm/secureperm_unix_test.go +++ b/pkg/secureperm/secureperm_unix_test.go @@ -28,7 +28,8 @@ func TestWriteFile_Mode0600_Unix(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "secret.txt") - if err := secureperm.WriteFile(path, []byte("x")); err != nil { + err := secureperm.WriteFile(path, []byte("x")) + if err != nil { t.Fatalf("WriteFile: %v", err) } @@ -45,17 +46,20 @@ func TestLockDown_Mode0600_Unix(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "existing.txt") - if err := os.WriteFile(path, []byte("data"), 0o644); err != nil { + err := os.WriteFile(path, []byte("data"), 0o644) + if err != nil { t.Fatalf("seed: %v", err) } // os.WriteFile is subject to the process umask; force 0o644 so the // lax-mode precondition is deterministic regardless of the host // umask (a restrictive 0o077 would otherwise make the seed 0o600 // and the test pass without proving LockDown tightened anything). - if err := os.Chmod(path, 0o644); err != nil { + err = os.Chmod(path, 0o644) + if err != nil { t.Fatalf("chmod seed: %v", err) } - if err := secureperm.LockDown(path); err != nil { + err = secureperm.LockDown(path) + if err != nil { t.Fatalf("LockDown: %v", err) } @@ -76,16 +80,19 @@ func TestWriteFile_OverwriteDowngrades_Unix(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "lax.txt") - if err := os.WriteFile(path, []byte("old"), 0o644); err != nil { + err := os.WriteFile(path, []byte("old"), 0o644) + if err != nil { t.Fatalf("seed: %v", err) } // Force the seed mode so the lax precondition survives a // restrictive umask; otherwise the test can pass without actually // verifying that WriteFile downgraded 0o644 to 0o600. - if err := os.Chmod(path, 0o644); err != nil { + err = os.Chmod(path, 0o644) + if err != nil { t.Fatalf("chmod seed: %v", err) } - if err := secureperm.WriteFile(path, []byte("new")); err != nil { + err = secureperm.WriteFile(path, []byte("new")) + if err != nil { t.Fatalf("WriteFile: %v", err) } @@ -114,17 +121,19 @@ func TestWriteFile_PreservesOriginalOnFailure_Unix(t *testing.T) { path := filepath.Join(dir, "secrets.yaml") original := []byte("critical-content-DO-NOT-LOSE") - if err := os.WriteFile(path, original, 0o600); err != nil { + err := os.WriteFile(path, original, 0o600) + if err != nil { t.Fatalf("seed: %v", err) } // Make the directory non-writable so os.CreateTemp fails. - if err := os.Chmod(dir, 0o500); err != nil { + err = os.Chmod(dir, 0o500) + if err != nil { t.Fatalf("chmod dir: %v", err) } t.Cleanup(func() { _ = os.Chmod(dir, 0o700) }) - err := secureperm.WriteFile(path, []byte("replacement")) + err = secureperm.WriteFile(path, []byte("replacement")) if err == nil { t.Fatal("expected WriteFile to fail on read-only directory") } diff --git a/pkg/secureperm/secureperm_windows.go b/pkg/secureperm/secureperm_windows.go index bc0bcc33..c7ee7b77 100644 --- a/pkg/secureperm/secureperm_windows.go +++ b/pkg/secureperm/secureperm_windows.go @@ -17,13 +17,13 @@ package secureperm import ( - "errors" "fmt" "math/rand/v2" "os" "path/filepath" "unsafe" + "github.com/cockroachdb/errors" "golang.org/x/sys/windows" ) @@ -33,13 +33,13 @@ import ( func ownerOnlyDACL() (*windows.ACL, error) { var token windows.Token if err := windows.OpenProcessToken(windows.CurrentProcess(), windows.TOKEN_QUERY, &token); err != nil { - return nil, fmt.Errorf("open process token: %w", err) + return nil, errors.Wrap(err, "open process token") } defer func() { _ = token.Close() }() tokenUser, err := token.GetTokenUser() if err != nil { - return nil, fmt.Errorf("get token user: %w", err) + return nil, errors.Wrap(err, "get token user") } entries := []windows.EXPLICIT_ACCESS{ @@ -54,10 +54,12 @@ func ownerOnlyDACL() (*windows.ACL, error) { }, }, } + acl, err := windows.ACLFromEntries(entries, nil) if err != nil { - return nil, fmt.Errorf("build DACL: %w", err) + return nil, errors.Wrap(err, "build DACL") } + return acl, nil } @@ -68,18 +70,21 @@ func ownerOnlyDescriptor() (*windows.SECURITY_DESCRIPTOR, error) { if err != nil { return nil, err } - sd, err := windows.NewSecurityDescriptor() + + sd, err := windows.NewSecurityDescriptor() //nolint:varnamelen // Windows API conventional short name if err != nil { - return nil, fmt.Errorf("create security descriptor: %w", err) + return nil, errors.Wrap(err, "create security descriptor") } + if err := sd.SetDACL(dacl, true, false); err != nil { - return nil, fmt.Errorf("attach DACL: %w", err) + return nil, errors.Wrap(err, "attach DACL") } // Mark the DACL protected so inherited ACEs are stripped rather // than merged. if err := sd.SetControl(windows.SE_DACL_PROTECTED, windows.SE_DACL_PROTECTED); err != nil { - return nil, fmt.Errorf("protect DACL: %w", err) + return nil, errors.Wrap(err, "protect DACL") } + return sd, nil } @@ -89,24 +94,36 @@ func ownerOnlyDescriptor() (*windows.SECURITY_DESCRIPTOR, error) { // member when opening an existing file, so using the NEW variant on a // filename we already verified didn't exist is what makes the DACL // actually apply at creation time. -func createSecureTmp(dir string) (tmpPath string, handle windows.Handle, err error) { - sd, err := ownerOnlyDescriptor() +// +//nolint:nonamedreturns // documents the (path, OS handle, error) tuple semantics; naked returns aren't used. +func createSecureTmp(dir string) (tmpPath string, h windows.Handle, err error) { + sd, err := ownerOnlyDescriptor() //nolint:varnamelen // Windows API conventional short name if err != nil { return "", 0, err } - sa := windows.SecurityAttributes{ + + // SecurityAttributes.Length is uint32 by ABI; unsafe.Sizeof + // returns uintptr. windows.SecurityAttributes is 1 uint32 + 2 + // pointers, so its size is 24 bytes on win64 and 12 on win32 — + // both fit uint32 trivially. The conversion is the canonical + // Windows ABI idiom (mirrored across golang.org/x/sys/windows + // examples) and not a real overflow risk. + sa := windows.SecurityAttributes{ //nolint:varnamelen // Windows API conventional short name Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), SecurityDescriptor: sd, InheritHandle: 0, } for range 100 { + //nolint:gosec // G404: tmpfile name suffix; non-cryptographic randomness is sufficient. candidate := filepath.Join(dir, fmt.Sprintf(".secureperm-%d-%d", os.Getpid(), rand.Uint32())) + utf16, err := windows.UTF16PtrFromString(candidate) if err != nil { - return "", 0, fmt.Errorf("encode tmp path: %w", err) + return "", 0, errors.Wrap(err, "encode tmp path") } - h, err := windows.CreateFile( + + h, err := windows.CreateFile( //nolint:varnamelen // Windows API conventional short name utf16, windows.GENERIC_WRITE, 0, // exclusive — no sharing during write @@ -118,11 +135,13 @@ func createSecureTmp(dir string) (tmpPath string, handle windows.Handle, err err if err == nil { return candidate, h, nil } + if !errors.Is(err, windows.ERROR_FILE_EXISTS) && !errors.Is(err, windows.ERROR_ALREADY_EXISTS) { - return "", 0, fmt.Errorf("create tmp %s: %w", candidate, err) + return "", 0, errors.Wrapf(err, "create tmp %s", candidate) } // Name already in use — pick another. } + return "", 0, errors.New("could not find unused tmp filename after 100 attempts") } @@ -151,7 +170,8 @@ func WriteFile(path string, data []byte) error { return err } - f := os.NewFile(uintptr(handle), tmpPath) + f := os.NewFile(uintptr(handle), tmpPath) //nolint:varnamelen // Windows API conventional short name + committed := false defer func() { if !committed { @@ -161,7 +181,7 @@ func WriteFile(path string, data []byte) error { }() if _, err := f.Write(data); err != nil { - return fmt.Errorf("write tmp %s: %w", tmpPath, err) + return errors.Wrapf(err, "write tmp %s", tmpPath) } // FlushFileBuffers (the Windows backend for *os.File.Sync) before // rename so the tmp's contents are on stable storage. os.Rename on @@ -172,15 +192,19 @@ func WriteFile(path string, data []byte) error { // rename pattern is meant to avoid. Secrets files are not // reconstructible, so the flush is warranted. if err := f.Sync(); err != nil { - return fmt.Errorf("sync tmp %s: %w", tmpPath, err) + return errors.Wrapf(err, "sync tmp %s", tmpPath) } + if err := f.Close(); err != nil { - return fmt.Errorf("close tmp %s: %w", tmpPath, err) + return errors.Wrapf(err, "close tmp %s", tmpPath) } + if err := os.Rename(tmpPath, path); err != nil { - return fmt.Errorf("rename tmp %s -> %s: %w", tmpPath, path, err) + return errors.Wrapf(err, "rename tmp %s -> %s", tmpPath, path) } + committed = true + return nil } @@ -192,13 +216,15 @@ func LockDown(path string) error { if err != nil { return err } + if err := windows.SetNamedSecurityInfo( path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, nil, nil, dacl, nil, ); err != nil { - return fmt.Errorf("set file DACL: %w", err) + return errors.Wrap(err, "set file DACL") } + return nil } diff --git a/pkg/yamltools/yamltools.go b/pkg/yamltools/yamltools.go index 08331286..bde2c930 100644 --- a/pkg/yamltools/yamltools.go +++ b/pkg/yamltools/yamltools.go @@ -7,20 +7,29 @@ import ( "strconv" "strings" + "github.com/cockroachdb/errors" "gopkg.in/yaml.v3" ) +// Patch directive sentinels emitted by DiffYAMLs to mark deletions in the +// resulting YAML so downstream JSON-merge-patch consumers can apply them. +const ( + patchKey = "$patch" + patchValDelete = "delete" +) + // CopyComments updates the comments in dstNode considering the structure of whitespace. func CopyComments(srcNode, dstNode *yaml.Node, path string, dstPaths map[string]*yaml.Node) { if srcNode.HeadComment != "" || srcNode.LineComment != "" || srcNode.FootComment != "" { dstPaths[path] = srcNode } - for i := 0; i < len(srcNode.Content); i++ { + for i := range len(srcNode.Content) { newPath := path + "/" + srcNode.Content[i].Value if srcNode.Kind == yaml.SequenceNode { newPath = path + "/" + strconv.Itoa(i) } + CopyComments(srcNode.Content[i], dstNode, newPath, dstPaths) } } @@ -33,11 +42,12 @@ func ApplyComments(dstNode *yaml.Node, path string, dstPaths map[string]*yaml.No dstNode.FootComment = mergeComments(dstNode.FootComment, srcNode.FootComment) } - for i := 0; i < len(dstNode.Content); i++ { + for i := range len(dstNode.Content) { newPath := path + "/" + dstNode.Content[i].Value if dstNode.Kind == yaml.SequenceNode { newPath = path + "/" + strconv.Itoa(i) } + ApplyComments(dstNode.Content[i], newPath, dstPaths) } } @@ -47,20 +57,26 @@ func mergeComments(oldComment, newComment string) string { if oldComment == "" { return newComment } + if newComment == "" { return oldComment } + return strings.TrimSpace(oldComment) + "\n\n" + strings.TrimSpace(newComment) } // DiffYAMLs compares two YAML documents and outputs the differences. func DiffYAMLs(original, modified []byte) ([]byte, error) { var origNode, modNode yaml.Node - if err := yaml.Unmarshal(original, &origNode); err != nil { - return nil, err + + err := yaml.Unmarshal(original, &origNode) + if err != nil { + return nil, errors.Wrap(err, "unmarshal original YAML") } - if err := yaml.Unmarshal(modified, &modNode); err != nil { - return nil, err + + err = yaml.Unmarshal(modified, &modNode) + if err != nil { + return nil, errors.Wrap(err, "unmarshal modified YAML") } clearComments(&origNode) @@ -74,9 +90,12 @@ func DiffYAMLs(original, modified []byte) ([]byte, error) { buffer := &bytes.Buffer{} encoder := yaml.NewEncoder(buffer) encoder.SetIndent(2) - if err := encoder.Encode(diff); err != nil { - return nil, err + + err = encoder.Encode(diff) + if err != nil { + return nil, errors.Wrap(err, "encode YAML diff") } + _ = encoder.Close() return buffer.Bytes(), nil @@ -86,6 +105,7 @@ func DiffYAMLs(original, modified []byte) ([]byte, error) { func clearComments(node *yaml.Node) { node.HeadComment = "" node.LineComment = "" + node.FootComment = "" for _, n := range node.Content { clearComments(n) @@ -107,7 +127,12 @@ func compareNodes(orig, mod *yaml.Node) *yaml.Node { if orig.Value != mod.Value { return mod } + case yaml.DocumentNode, yaml.AliasNode: + // Document and alias nodes are not produced at this level — DiffYAMLs + // strips the document wrapper before recursing, and aliases are not + // supported by the diff format. Fall through to the no-diff result. } + return nil } @@ -115,8 +140,8 @@ func createDeleteNode() *yaml.Node { return &yaml.Node{ Kind: yaml.MappingNode, Content: []*yaml.Node{ - {Kind: yaml.ScalarNode, Value: "$patch"}, - {Kind: yaml.ScalarNode, Value: "delete"}, + {Kind: yaml.ScalarNode, Value: patchKey}, + {Kind: yaml.ScalarNode, Value: patchValDelete}, }, } } @@ -137,6 +162,7 @@ func compareMappingNodes(orig, mod *yaml.Node) *yaml.Node { if origExists { processedKeys[key] = true + changedNode := compareNodes(origVal, modVal) if changedNode != nil { addNodeToDiff(diff, key, changedNode) @@ -153,10 +179,12 @@ func compareMappingNodes(orig, mod *yaml.Node) *yaml.Node { origVal := origMap[key] if origVal.Kind == yaml.MappingNode { nestedDelete := &yaml.Node{Kind: yaml.MappingNode} + for j := 0; j < len(origVal.Content); j += 2 { nestedKey := origVal.Content[j].Value addNodeToDiff(nestedDelete, nestedKey, createDeleteNode()) } + addNodeToDiff(diff, key, nestedDelete) } else { addNodeToDiff(diff, key, createDeleteNode()) @@ -167,12 +195,14 @@ func compareMappingNodes(orig, mod *yaml.Node) *yaml.Node { if len(diff.Content) == 0 { return nil } + return diff } // compareSequenceNodes compares two sequence nodes and returns differences. func compareSequenceNodes(orig, mod *yaml.Node) *yaml.Node { diff := &yaml.Node{Kind: yaml.SequenceNode} + origSet := nodeSet(orig) for _, modItem := range mod.Content { if !origSet[modItem.Value] { @@ -183,6 +213,7 @@ func compareSequenceNodes(orig, mod *yaml.Node) *yaml.Node { if len(diff.Content) == 0 { return nil } + return diff } @@ -192,24 +223,26 @@ func nodeSet(node *yaml.Node) map[string]bool { for _, item := range node.Content { result[item.Value] = true } + return result } // addNodeToDiff adds a node to the diff result. func addNodeToDiff(diff *yaml.Node, key string, node *yaml.Node) { keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key} - diff.Content = append(diff.Content, keyNode) - diff.Content = append(diff.Content, node) + diff.Content = append(diff.Content, keyNode, node) } // nodeMap creates a map from a YAML mapping node for easy lookup. func nodeMap(node *yaml.Node) map[string]*yaml.Node { result := make(map[string]*yaml.Node) + for i := 0; i+1 < len(node.Content); i += 2 { keyNode := node.Content[i] if keyNode.Kind == yaml.ScalarNode { result[keyNode.Value] = node.Content[i+1] } } + return result } diff --git a/pkg/yamltools/yamltools_test.go b/pkg/yamltools/yamltools_test.go index dc0e551d..a09a6908 100644 --- a/pkg/yamltools/yamltools_test.go +++ b/pkg/yamltools/yamltools_test.go @@ -8,6 +8,15 @@ import ( "gopkg.in/yaml.v3" ) +// Shared YAML/comment fixture literals. Hoisted to keep goconst quiet. +const ( + yamlKeyValue = `key: value` + yamlKeyValue2 = `key: value2` + tokenNew = "new" + commentNewText = "new comment" + commentOldText = "old comment" +) + func TestCopyComments(t *testing.T) { tests := []struct { name string @@ -69,19 +78,19 @@ func TestApplyComments(t *testing.T) { name: "copies and applies head comment", src: `# Source comment key: value1`, - dst: `key: value2`, + dst: yamlKeyValue2, expectComments: true, }, { name: "copies and applies line comment", src: `key: value1 # inline`, - dst: `key: value2`, + dst: yamlKeyValue2, expectComments: true, }, { name: "no comments to copy", src: `key: value1`, - dst: `key: value2`, + dst: yamlKeyValue2, expectComments: false, }, } @@ -115,8 +124,8 @@ func TestDiffYAMLs(t *testing.T) { }{ { name: "no diff for identical documents", - original: `key: value`, - modified: `key: value`, + original: yamlKeyValue, + modified: yamlKeyValue, wantDiff: false, }, { @@ -124,7 +133,7 @@ func TestDiffYAMLs(t *testing.T) { original: `key: old`, modified: `key: new`, wantDiff: true, - contains: []string{"key:", "new"}, + contains: []string{"key:", tokenNew}, }, { name: "detects added key", @@ -190,20 +199,20 @@ func TestMergeComments(t *testing.T) { { name: "returns new when old is empty", old: "", - new: "new comment", - expected: "new comment", + new: commentNewText, + expected: commentNewText, }, { name: "returns old when new is empty", - old: "old comment", + old: commentOldText, new: "", - expected: "old comment", + expected: commentOldText, }, { name: "merges both comments", - old: "old comment", - new: "new comment", - expected: "old comment\n\nnew comment", + old: commentOldText, + new: commentNewText, + expected: commentOldText + "\n\n" + commentNewText, }, } @@ -214,4 +223,3 @@ func TestMergeComments(t *testing.T) { }) } } -