diff --git a/go.mod b/go.mod index bc0a720e6..c6a83d10a 100644 --- a/go.mod +++ b/go.mod @@ -42,6 +42,7 @@ require ( k8s.io/api v0.34.11 k8s.io/apiextensions-apiserver v0.34.11 k8s.io/apimachinery v0.34.11 + k8s.io/client-go v0.34.11 k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 sigs.k8s.io/yaml v1.6.0 ) @@ -267,7 +268,6 @@ require ( gopkg.in/yaml.v2 v2.4.0 // indirect k8s.io/apiserver v0.34.11 // indirect k8s.io/cli-runtime v0.34.11 // indirect - k8s.io/client-go v0.34.11 // indirect k8s.io/component-base v0.34.11 // indirect k8s.io/klog v1.0.0 // indirect k8s.io/klog/v2 v2.130.1 // indirect diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 21705c7fa..26d026b85 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -47,8 +47,12 @@ var ( ShowIgnored bool ShowDocumentation bool Fix bool + Matrix bool + MatrixLimit int ) +const defaultMatrixLimit = 100 + var ( BootstrapRepositoryType string BootstrapRepositoryURL string @@ -77,6 +81,12 @@ func InitLintFlagSet() *pflag.FlagSet { // automatically fix findings that support autofix lint.BoolVarP(&Fix, "fix", "", false, "automatically fix findings that support autofix") + // render every combination of template variants (across openapi examples, + // enums and booleans) and lint them all, to reach conditionally-rendered + // resources a single default render never produces. + lint.BoolVarP(&Matrix, "matrix", "", false, "render and lint all template variants (all openapi value combinations)") + lint.IntVarP(&MatrixLimit, "matrix-limit", "", defaultMatrixLimit, "maximum number of value combinations to render per module in --matrix mode") + // hide warnings in output lint.BoolVarP(&HideWarnings, "hide-warnings", "", false, "hide warnings") diff --git a/internal/fsutils/getfiles.go b/internal/fsutils/getfiles.go index b4554c65e..05d5f5f35 100644 --- a/internal/fsutils/getfiles.go +++ b/internal/fsutils/getfiles.go @@ -30,7 +30,15 @@ func GetFiles(rootPath string, skipSymlink bool, filters ...filterFn) []string { return result } - _ = filepath.Walk(rootPath, func(path string, info os.FileInfo, _ error) error { + _ = filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error { + // Walk reports a path it could not stat with a nil info: a file removed + // between the directory listing and the stat, a directory it may not read. + // Every branch below dereferences info, so the entry has to be skipped here + // rather than crash the whole run over one unreadable path. + if err != nil || info == nil { + return nil + } + if skipSymlink && info.Mode()&os.ModeSymlink != 0 { // Correct symlink handling: skip symlink directory, just skip symlink file if info.IsDir() { diff --git a/internal/fsutils/getfiles_test.go b/internal/fsutils/getfiles_test.go index 72e015345..c829bd4d8 100644 --- a/internal/fsutils/getfiles_test.go +++ b/internal/fsutils/getfiles_test.go @@ -19,6 +19,7 @@ package fsutils import ( "os" "path/filepath" + "slices" "testing" ) @@ -84,3 +85,47 @@ func assertEqualFiles(t *testing.T, actual, expected []string) { t.Errorf("expected %d files, but got %d", len(expected), len(actual)) } } + +// TestGetFilesSurvivesUnstatablePath is the regression guard for a crash that took +// down whole lint runs: filepath.Walk hands the callback a nil FileInfo for a path +// it could not stat, and the callback used to dereference it. dmt itself creates +// such paths — a render injects a helper template into the module's templates/ and +// removes it again — so a linter walking that directory could hit an entry that had +// just vanished and panic the process. +// +// A directory that is readable but not traversable reproduces it deterministically: +// its children are listed, and the lstat of each one then fails. +func TestGetFilesSurvivesUnstatablePath(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root ignores the permission bits this test relies on") + } + + root := t.TempDir() + + readable := filepath.Join(root, "readable.yaml") + if err := os.WriteFile(readable, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + blocked := filepath.Join(root, "blocked") + if err := os.Mkdir(blocked, 0o755); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(blocked, "hidden.yaml"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + // Readable (r) but not traversable (no x): the entry is listed, its lstat fails. + if err := os.Chmod(blocked, 0o600); err != nil { + t.Fatal(err) + } + + t.Cleanup(func() { _ = os.Chmod(blocked, 0o755) }) + + files := GetFiles(root, false) + + if !slices.Contains(files, readable) { + t.Errorf("GetFiles dropped the readable file: %v", files) + } +} diff --git a/internal/manager/manager.go b/internal/manager/manager.go index e365f4753..eadcf15b6 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -47,6 +47,11 @@ import ( const ( baseRepoURL = "https://github.com/deckhouse/dmt/tree/main" + + // logProgressEvery throttles the per-target "Run linters" line when a source + // pushes many renders of one module, as --matrix does: the first render and + // every Nth one afterwards are logged. + logProgressEvery = 50 ) func generateDocumentationURL(linterID, ruleID string) string { @@ -71,6 +76,11 @@ type Target struct { // ObjectID tags this target's findings. The remote source sets the scope name so // bundle and release findings stay apart in the output; empty means no tag. ObjectID string + // Variant marks this target as one of several renders of the same module, which + // is what a --matrix run produces. It turns on deduplication for the whole run: + // a finding that does not depend on the value combination being rendered is + // produced once per combination, and only one of those is worth printing. + Variant bool } // Source supplies the modules a run lints. It is the only thing that differs between @@ -86,11 +96,23 @@ type Source interface { // anything, so a run that finds no modules still reports the sections it would // have linted with. Scopes() []scopes.Scope - // Targets loads the modules to lint. Findings made while loading go into - // errorList; a returned error is one the run could not fold into a finding, e.g. - // a registry failure, and is reported after the findings are printed rather than - // instead of them. - Targets(ctx context.Context, cfg *config.RootConfig, errorList *errors.LintRuleErrorsList) ([]Target, error) + // Targets loads the modules to lint, handing each to yield as soon as it is + // built. Targets are pushed rather than returned as a slice because a --matrix + // run expands one module into many renders: yield blocks while the worker pool + // is full, so the source builds the next render only once an earlier one has + // been linted and freed, and a module that expands to thousands of renders never + // has more than the worker count of them resident. A false from yield means the + // run is over and the source must stop. + // + // Findings made while loading go into errorList; a returned error is one the run + // could not fold into a finding, e.g. a registry failure, and is reported after + // the findings are printed rather than instead of them. + Targets( + ctx context.Context, + cfg *config.RootConfig, + errorList *errors.LintRuleErrorsList, + yield func(Target) bool, + ) error // Close releases what the source allocated, e.g. image extraction directories. // It runs after the findings are printed, so a finding must never name a path // that only exists until Close. @@ -98,10 +120,16 @@ type Source interface { } type Manager struct { - cfg *config.RootConfig - source Source - targets []Target - errors *errors.LintRuleErrorsList + cfg *config.RootConfig + source Source + errors *errors.LintRuleErrorsList + // moduleIDs holds the ModuleID of every target the source pushed. Targets are + // streamed and not kept — a --matrix run pushes far more of them than a summary + // would want to hold — so the module count is accumulated as they arrive. + moduleIDs set.Set + // dedupe is set by the first variant target, and collapses findings that repeat + // across the renders of one module. See Target.Variant. + dedupe bool // startedAt marks the beginning of the run; PrintStatistics reports the // wall-clock time elapsed since it, matching the mirror summary's Elapsed line. // It is taken before the source loads anything, so for a remote run the pulls @@ -116,50 +144,137 @@ func New(cfg *config.RootConfig, src Source) *Manager { cfg: cfg, source: src, errors: errors.NewLintRuleErrorsList().WithMaxLevel(&managerLevel), + moduleIDs: set.New(), startedAt: time.Now(), } } -// Run loads the source's targets and lints them. The returned error is the source's: -// the findings collected before it are still on the Manager, so the caller prints -// them first and reports the error afterwards. +// Run lints the targets the source pushes. The returned error is the source's: the +// findings collected before it are still on the Manager, so the caller prints them +// first and reports the error afterwards. func (m *Manager) Run(ctx context.Context) error { - targets, sourceErr := m.source.Targets(ctx, m.cfg, m.errors.WithLinterID("manager")) - m.targets = targets - - log.Info("Found modules", slog.Int("count", m.moduleCount())) - wg := new(sync.WaitGroup) // The send below happens before the goroutine that drains it, so an unbuffered // channel deadlocks. --parallel is a user-supplied number and every caller is not // a cobra command, so the floor lives here rather than at each entry point. + // + // The channel doubles as the bound on how many rendered modules are alive at + // once: a slot is taken before the source is let go to build the next target and + // released only once this one has been linted and freed. That is what keeps a + // --matrix run, which renders a module under thousands of value combinations, + // from holding more than the worker count of them. processingCh := make(chan struct{}, max(flags.LintersLimit, 1)) - for _, target := range targets { + var prog progress + + lint := func(target Target) { + defer func() { + // Hand the rendered objects back to the pool as soon as this target is + // linted, then free the slot: the source is waiting on it to render the + // next one. + target.Module.Release() + <-processingCh + wg.Done() + }() + + lintModule(ctx, target.Scope, target.Module, m.errorsFor(target)) + } + + sourceErr := m.source.Targets(ctx, m.cfg, m.errors.WithLinterID("manager"), func(target Target) bool { processingCh <- struct{}{} + m.moduleIDs.Add(target.ModuleID) + m.dedupe = m.dedupe || target.Variant + + prog.start(target) + wg.Add(1) - go func() { - defer func() { - <-processingCh - wg.Done() - }() + // A variant target shares a directory with the renders still to come, and + // rendering writes into it: a helper template is placed in the module's + // templates/ for the duration of each render and taken out again. A linter + // walking that directory while the next variant renders would see a file + // appear and vanish under it — findings against a path that no longer + // exists, or a walk that fails outright. So a variant is linted to + // completion before the source is let go to render another one. + // + // Only --matrix produces variant targets. An ordinary run renders each + // module once and never waits here, so its linters stay fully parallel. + if target.Variant { + lint(target) + + return true + } - log.Info("Run linters for module", - slog.String("module", target.Module.GetName()), - slog.String("scope", string(target.Scope)), - ) + go lint(target) - lintModule(ctx, target.Scope, target.Module, m.errorsFor(target)) - }() - } + return true + }) + + prog.done() wg.Wait() return sourceErr } +// progress throttles the per-target "Run linters" line. A --matrix run renders one +// module under many value combinations, each of which would otherwise log the same +// line; the renders of a module arrive consecutively, so counting the current run of +// them is enough to log the first, every logProgressEvery-th, and the last. Only the +// goroutine the source pushes targets from touches it, so it needs no locking. +type progress struct { + target Target + count int + // logged is the count the last line reported, so done can tell whether the final + // render of a module has already been announced. + logged int +} + +// start announces that target is about to be linted, unless the throttle swallows +// the line. +func (p *progress) start(t Target) { + // A module linted in two scopes — the two images of a remote run — is two + // streams, not a repeated render, and each is announced. + if t.ModuleID != p.target.ModuleID || t.Scope != p.target.Scope { + p.done() + + p.count, p.logged = 0, 0 + } + + p.target = t + p.count++ + + if p.count == 1 || p.count%logProgressEvery == 0 { + p.log() + } +} + +// done reports where the stream the throttle was counting finished, unless its last +// render was already logged. Call it once the source has pushed everything. +func (p *progress) done() { + if p.count > 1 && p.logged != p.count { + p.log() + } +} + +func (p *progress) log() { + p.logged = p.count + + attrs := []any{ + slog.String("module", p.target.Module.GetName()), + slog.String("scope", string(p.target.Scope)), + } + + // Only a stream of several renders needs a counter; a plain run has one per + // module and the field would be noise. + if p.count > 1 { + attrs = append(attrs, slog.Int("render", p.count)) + } + + log.Info("Run linters for module", attrs...) +} + // Close releases the source's resources. It must be called after the findings are // printed: a source may be holding the directory the run linted. func (m *Manager) Close() { @@ -176,14 +291,10 @@ func (m *Manager) errorsFor(t Target) *errors.LintRuleErrorsList { } // moduleCount counts modules, not targets: a remote run reads one module from two -// images, and the summary must call that one module. +// images and a --matrix run renders one module many times, and the summary must call +// either of those one module. func (m *Manager) moduleCount() int { - ids := set.New() - for _, t := range m.targets { - ids.Add(t.ModuleID) - } - - return ids.Size() + return m.moduleIDs.Size() } // MetricsSections returns the config sections this run linted with, in the form @@ -218,13 +329,11 @@ func lintModule(ctx context.Context, sc scopes.Scope, m *modules.Module, errorLi } func (m *Manager) PrintResult() { - printResult(m.errors) + printResult(m.GetErrors()) } -// printResult renders a finished error list. -func printResult(errorList *errors.LintRuleErrorsList) { - errs := errorList.GetErrors() - +// printResult renders a finished set of findings. +func printResult(errs []pkg.LinterError) { if len(errs) == 0 { return } @@ -345,7 +454,40 @@ func (m *Manager) ApplyFixes() { // It is primarily intended for tests (e.g. the e2e framework) that need to // assert on the structured findings produced by the linters. func (m *Manager) GetErrors() []pkg.LinterError { - return m.errors.GetErrors() + errs := m.errors.GetErrors() + + if m.dedupe { + return dedupeErrors(errs) + } + + return errs +} + +// dedupeErrors removes findings that are identical in every user-visible field. It +// runs only for a run that rendered some module more than once (see Target.Variant): +// the same resource is linted under many value combinations, so a finding that is +// not specific to one of them would otherwise be reported once per combination. +func dedupeErrors(errs []pkg.LinterError) []pkg.LinterError { + seen := make(map[string]struct{}, len(errs)) + out := make([]pkg.LinterError, 0, len(errs)) + + for i := range errs { + e := errs[i] + key := strings.Join([]string{ + e.LinterID, e.RuleID, e.ModuleID, e.ObjectID, + e.Level.String(), e.FilePath, e.Text, + }, "\x00") + + if _, dup := seen[key]; dup { + continue + } + + seen[key] = struct{}{} + + out = append(out, e) + } + + return out } // prepareString handle ussual string and prepare it for tablewriter diff --git a/internal/manager/manager_test.go b/internal/manager/manager_test.go index c1d485140..8f7f37765 100644 --- a/internal/manager/manager_test.go +++ b/internal/manager/manager_test.go @@ -18,6 +18,7 @@ package manager import ( "context" + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -45,8 +46,19 @@ func (s *fakeSource) ConfigDir() string { return "." } func (s *fakeSource) Scopes() []scopes.Scope { return s.scopes } func (s *fakeSource) Close() { s.closed = true } -func (s *fakeSource) Targets(_ context.Context, _ *config.RootConfig, _ *errors.LintRuleErrorsList) ([]Target, error) { - return s.targets, s.err +func (s *fakeSource) Targets( + _ context.Context, + _ *config.RootConfig, + _ *errors.LintRuleErrorsList, + yield func(Target) bool, +) error { + for _, t := range s.targets { + if !yield(t) { + break + } + } + + return s.err } // TestModuleCountCountsModulesNotTargets pins the number the summary reports. A @@ -182,3 +194,101 @@ func TestLintModuleHonoursTheLinterFilter(t *testing.T) { assert.Equal(t, []string{"module"}, lint(t, "module")) }) } + +// variantSource pushes variant targets and records, for each one, how many findings +// the run had collected by the time yield handed control back. +type variantSource struct { + targets []Target + // atReturn[i] is the finding count observed right after yield returned for + // target i. + atReturn []int +} + +func (s *variantSource) ConfigDir() string { return "." } +func (s *variantSource) Scopes() []scopes.Scope { return []scopes.Scope{scopes.Bundle} } +func (s *variantSource) Close() {} + +func (s *variantSource) Targets( + _ context.Context, + _ *config.RootConfig, + errorList *errors.LintRuleErrorsList, + yield func(Target) bool, +) error { + for _, t := range s.targets { + if !yield(t) { + break + } + + s.atReturn = append(s.atReturn, len(errorList.GetErrors())) + } + + return nil +} + +// TestVariantTargetsAreLintedBeforeTheSourceContinues pins the invariant a --matrix +// run depends on: every render of a module writes a helper template into that +// module's templates/ and removes it again, so the source must never render the next +// variant while a linter is still walking the directory. The manager enforces it by +// linting a variant target to completion before returning from yield — without which +// a matrix run crashes outright on a path that vanishes mid-walk. +// +// The finding count is the observation: the bundle scope reports on every one of +// these modules, so a target that has been linted has necessarily added findings by +// the time the source is let go. +func TestVariantTargetsAreLintedBeforeTheSourceContinues(t *testing.T) { + cfg, err := config.NewDefaultRootConfig(t.TempDir()) + require.NoError(t, err) + + targets := make([]Target, 0, 3) + for i := range 3 { + targets = append(targets, Target{ + Module: modules.NewRemoteModule(t.TempDir(), "mod", scopes.Bundle.Settings(cfg)), + Scope: scopes.Bundle, + ModuleID: "mod", + ObjectID: fmt.Sprintf("variant-%d", i), + Variant: true, + }) + } + + src := &variantSource{targets: targets} + + m := New(cfg, src) + require.NoError(t, m.Run(t.Context())) + + require.Len(t, src.atReturn, len(targets)) + + for i, count := range src.atReturn { + assert.Positive(t, count, + "variant %d was still being linted when the source was let go to render the next one", i) + + if i > 0 { + assert.Greater(t, count, src.atReturn[i-1], + "variant %d added no findings before the source continued", i) + } + } +} + +// TestPlainTargetsDoNotBlockTheSource is the other half: a run that renders each +// module once has no directory to protect, so its targets must keep linting in the +// background instead of paying the variant barrier's serialization. +func TestPlainTargetsDoNotBlockTheSource(t *testing.T) { + cfg, err := config.NewDefaultRootConfig(t.TempDir()) + require.NoError(t, err) + + // One slot only, so a target that is linted synchronously would be finished by + // the time yield returns, exactly as the variant case asserts above. + flags.LintersLimit = 1 + + t.Cleanup(func() { flags.LintersLimit = 0 }) + + src := &variantSource{targets: []Target{{ + Module: modules.NewRemoteModule(t.TempDir(), "mod", scopes.Bundle.Settings(cfg)), + Scope: scopes.Bundle, + ModuleID: "mod", + }}} + + m := New(cfg, src) + require.NoError(t, m.Run(t.Context())) + + assert.NotEmpty(t, m.GetErrors(), "the target still has to be linted by the time Run returns") +} diff --git a/internal/manager/statistics.go b/internal/manager/statistics.go index 5fe05a693..b9b815816 100644 --- a/internal/manager/statistics.go +++ b/internal/manager/statistics.go @@ -27,7 +27,6 @@ import ( "github.com/fatih/color" "github.com/deckhouse/dmt/pkg" - "github.com/deckhouse/dmt/pkg/errors" ) // The statistics summary is rendered as a single framed block that intentionally @@ -113,10 +112,11 @@ type statistics struct { elapsed time.Duration } -// collectStatistics tallies every collected finding by severity and by linter. -// Counts are taken over all findings regardless of the --hide-warnings / -// --show-ignored display flags: the summary is meant to give the full picture. -func collectStatistics(errorList *errors.LintRuleErrorsList, modules int, elapsed time.Duration) statistics { +// collectStatistics tallies the run's findings by severity and by linter. Counts are +// taken over all of them regardless of the --hide-warnings / --show-ignored display +// flags: the summary is meant to give the full picture. It is handed the same set +// PrintResult listed, so the totals cannot disagree with what the user just read. +func collectStatistics(errs []pkg.LinterError, modules int, elapsed time.Duration) statistics { s := statistics{ modules: modules, elapsed: elapsed, @@ -124,7 +124,6 @@ func collectStatistics(errorList *errors.LintRuleErrorsList, modules int, elapse perLinter := make(map[string]int) - errs := errorList.GetErrors() for idx := range errs { s.total++ @@ -160,7 +159,7 @@ func collectStatistics(errorList *errors.LintRuleErrorsList, modules int, elapse // styled identically to the deckhouse-cli mirror summaries. It is meant to be // called after PrintResult, once all findings have been listed. func (m *Manager) PrintStatistics() { - fmt.Println(renderStatistics(collectStatistics(m.errors, m.moduleCount(), time.Since(m.startedAt)))) + fmt.Println(renderStatistics(collectStatistics(m.GetErrors(), m.moduleCount(), time.Since(m.startedAt)))) } // renderStatistics formats the statistics as a single multi-line, framed block. diff --git a/internal/matrix/combinations.go b/internal/matrix/combinations.go new file mode 100644 index 000000000..82e28a020 --- /dev/null +++ b/internal/matrix/combinations.go @@ -0,0 +1,179 @@ +/* +Copyright 2026 Flant JSC + +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 matrix + +import ( + "fmt" + "iter" + "slices" + "strings" +) + +// combinationsSeq returns a lazy sequence of index-tuples (one value index per +// axis) to render, plus the total number it will yield. When the full cartesian +// product fits within limit it is streamed in full — generated one tuple at a +// time via a mixed-radix counter, so a schema that expands to millions of +// combinations never materializes them all at once. Otherwise an all-pairs set +// is built (bounded, and capped at limit) so every pair of axis values still +// co-occurs in at least one tuple. +func combinationsSeq(axes []Axis, limit int) (iter.Seq[[]int], int) { + if len(axes) == 0 || limit <= 0 { + return sliceSeq[[]int](nil), 0 + } + + if size, ok := cartesianSize(axes, limit); ok { + return cartesianSeq(axes), size + } + + combos := pairwise(axes) + if len(combos) > limit { + combos = combos[:limit] + } + + return sliceSeq(combos), len(combos) +} + +// combinations collects combinationsSeq into a slice. It is a convenience for +// tests; production code consumes the lazy sequence directly. +func combinations(axes []Axis, limit int) [][]int { + seq, _ := combinationsSeq(axes, limit) + return slices.Collect(seq) +} + +// sliceSeq adapts a slice into an iter.Seq without copying its elements. +func sliceSeq[T any](s []T) iter.Seq[T] { + return func(yield func(T) bool) { + for i := range s { + if !yield(s[i]) { + return + } + } + } +} + +// cartesianSize returns the product of axis lengths, and false if it exceeds +// limit (short-circuiting to avoid overflow on pathological schemas). +func cartesianSize(axes []Axis, limit int) (int, bool) { + size := 1 + + for i := range axes { + size *= len(axes[i].Values) + if size > limit { + return 0, false + } + } + + return size, true +} + +// cartesianSeq lazily yields every index-tuple of the axes' cartesian product +// via a mixed-radix counter, allocating only the single tuple it is about to +// yield. This is what lets --matrix mode enumerate an arbitrarily large product +// (e.g. a module expanding to >1M combinations) without holding it in memory. +func cartesianSeq(axes []Axis) iter.Seq[[]int] { + return func(yield func([]int) bool) { + idx := make([]int, len(axes)) + + for { + combo := make([]int, len(axes)) + copy(combo, idx) + + if !yield(combo) { + return + } + + // increment the mixed-radix counter + pos := len(axes) - 1 + for pos >= 0 { + idx[pos]++ + if idx[pos] < len(axes[pos].Values) { + break + } + + idx[pos] = 0 + pos-- + } + + if pos < 0 { + return + } + } + } +} + +// pairwise returns a set of tuples covering every pair of (axis, value) across +// all axis pairs. It is a simple, deterministic all-pairs construction: for each +// pair of axes it emits a tuple pinning those two axes to each value +// combination while leaving the rest at their first value. Duplicate tuples are +// removed. This guarantees 2-way coverage, which is enough to reach resources +// gated by two simultaneous conditions. +func pairwise(axes []Axis) [][]int { + seen := map[string]struct{}{} + + var combos [][]int + + add := func(combo []int) { + key := comboKey(combo) + if _, dup := seen[key]; dup { + return + } + + seen[key] = struct{}{} + + combos = append(combos, combo) + } + + base := make([]int, len(axes)) // all firsts + + // Single-axis sweeps first: every value of every axis appears at least once. + for i := range axes { + for v := 1; v < len(axes[i].Values); v++ { + combo := append([]int(nil), base...) + combo[i] = v + add(combo) + } + } + + // Then every pair of axes at every value combination. + for i := range axes { + for j := i + 1; j < len(axes); j++ { + for vi := range axes[i].Values { + for vj := range axes[j].Values { + combo := append([]int(nil), base...) + combo[i] = vi + combo[j] = vj + add(combo) + } + } + } + } + + if len(combos) == 0 { + add(append([]int(nil), base...)) + } + + return combos +} + +func comboKey(combo []int) string { + parts := make([]string, len(combo)) + for i, v := range combo { + parts[i] = fmt.Sprintf("%d", v) + } + + return strings.Join(parts, ",") +} diff --git a/internal/matrix/combinations_test.go b/internal/matrix/combinations_test.go new file mode 100644 index 000000000..b9ad172e0 --- /dev/null +++ b/internal/matrix/combinations_test.go @@ -0,0 +1,99 @@ +/* +Copyright 2026 Flant JSC + +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 matrix + +import ( + "fmt" + "testing" +) + +func axesOfSize(sizes ...int) []Axis { + axes := make([]Axis, len(sizes)) + + for i, n := range sizes { + vals := make([]any, n) + for v := range vals { + vals[v] = v + } + + axes[i] = Axis{Path: []string{fmt.Sprintf("a%d", i)}, Values: vals} + } + + return axes +} + +func TestCombinations_FullCartesian(t *testing.T) { + axes := axesOfSize(2, 3, 2) // product = 12 + + combos := combinations(axes, 100) + if len(combos) != 12 { + t.Fatalf("expected 12 cartesian combos, got %d", len(combos)) + } + + // All must be unique. + seen := map[string]struct{}{} + + for _, c := range combos { + k := comboKey(c) + if _, dup := seen[k]; dup { + t.Fatalf("duplicate combo %v", c) + } + + seen[k] = struct{}{} + } +} + +func TestCombinations_PairwiseFallbackCoversAllPairs(t *testing.T) { + // 5 booleans => cartesian 32; a limit below that forces the pairwise + // fallback, while still leaving room for the full all-pairs set (~16). + axes := axesOfSize(2, 2, 2, 2, 2) + + const limit = 25 + + combos := combinations(axes, limit) + + if len(combos) >= 32 { + t.Fatalf("expected pairwise fallback (fewer than cartesian 32), got %d", len(combos)) + } + + if len(combos) > limit { + t.Fatalf("pairwise result exceeded limit: %d", len(combos)) + } + + // Every pair (i,j) and every (vi,vj) must appear in some combo. + for i := range axes { + for j := i + 1; j < len(axes); j++ { + for vi := range axes[i].Values { + for vj := range axes[j].Values { + if !pairCovered(combos, i, j, vi, vj) { + t.Fatalf("pair (a%d=%d, a%d=%d) not covered", i, vi, j, vj) + } + } + } + } + } +} + +func pairCovered(combos [][]int, i, j, vi, vj int) bool { + for _, c := range combos { + if c[i] == vi && c[j] == vj { + return true + } + } + + return false +} diff --git a/internal/matrix/matrix.go b/internal/matrix/matrix.go new file mode 100644 index 000000000..74f970f97 --- /dev/null +++ b/internal/matrix/matrix.go @@ -0,0 +1,362 @@ +/* +Copyright 2026 Flant JSC + +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 matrix expands a module's openapi value schema into many concrete +// value combinations ("variants"). A single default render only exercises one +// branch of a chart's templates; matrix mode renders every combination so that +// conditionally-rendered resources (guarded by feature flags, modes, enums, +// etc.) are produced and linted too. +// +// Axes of variation are discovered from the module's own openapi schema: +// +// - a node carrying x-examples contributes one variant per example; +// - an enum contributes one variant per allowed value; +// - a boolean contributes true and false. +// +// Combinations are the cartesian product of all axes, capped by a limit. When +// the product exceeds the limit, an all-pairs (pairwise) set is produced +// instead so that every pair of axis values still co-occurs in some variant — +// enough to reach bugs that need two conditions at once (e.g. "feature enabled" +// AND "mode = Static"). +package matrix + +import ( + "fmt" + "iter" + "sort" + "strings" + + "github.com/go-openapi/spec" + "helm.sh/helm/v3/pkg/chartutil" + + "github.com/deckhouse/dmt/internal/modules" + "github.com/deckhouse/dmt/internal/modules/values" + "github.com/deckhouse/dmt/internal/modules/values/schema/defaults" +) + +// xExamples is the openapi extension holding example values for a node. +const xExamples = "x-examples" + +// defaultLimit caps the number of combinations when the caller passes a +// non-positive limit (e.g. tests that don't parse CLI flags). +const defaultLimit = 100 + +// Axis is one dimension of variation: the value at Path may take any of Values. +type Axis struct { + Path []string + Values []any +} + +// Variant is a set of value overrides to render, structured as the chart's +// .Values tree (i.e. keyed by the camel-cased module name). A nil/empty Variant +// means "render with the default generated values". +type Variant struct { + // Overrides is the .Values override tree ({camelName: {...}}); nil for the + // default variant. + Overrides chartutil.Values + // Label is a short human-readable description of what this variant sets. + Label string +} + +// Generate returns a lazy sequence of value variants to render for the module +// at modulePath, using valuesFile (e.g. "values.yaml") as the openapi values +// schema, along with the total number of variants the sequence will yield. The +// first variant is always the default (no overrides), so matrix output is a +// superset of a normal lint. At most limit combinations are produced. +// +// The sequence is lazy on purpose: a module whose schema expands to millions of +// combinations would exhaust memory if every override tree were materialized up +// front, so each variant's overrides are built only as it is consumed. The +// returned count is computed without materializing anything (a cheap product +// for the cartesian case, the pairwise set size otherwise), so callers can log +// it before iterating. +func Generate(modulePath, valuesFile string, limit int) (iter.Seq[Variant], int, error) { + if limit <= 0 { + limit = defaultLimit + } + + camelName, err := moduleCamelName(modulePath) + if err != nil { + return nil, 0, err + } + + schema, err := values.GetModuleValuesForValuesFile(modulePath, valuesFile) + if err != nil { + return nil, 0, fmt.Errorf("load module values schema: %w", err) + } + + axes := discoverAxes(schema) + + // Always start with the default (no-override) render. + defaultVariant := Variant{Overrides: nil, Label: "default"} + + if len(axes) == 0 { + return sliceSeq([]Variant{defaultVariant}), 1, nil + } + + comboSeq, comboCount := combinationsSeq(axes, max(1, limit-1)) + + seq := func(yield func(Variant) bool) { + if !yield(defaultVariant) { + return + } + + for combo := range comboSeq { + if !yield(Variant{ + Overrides: buildOverride(camelName, axes, combo), + Label: comboLabel(axes, combo), + }) { + return + } + } + } + + return seq, comboCount + 1, nil +} + +// discoverAxes walks a module values schema and collects one Axis per node that +// offers a finite, meaningful set of alternative values. +func discoverAxes(schema *spec.Schema) []Axis { + var axes []Axis + + walkSchema(schema, nil, &axes) + + return axes +} + +func walkSchema(s *spec.Schema, path []string, axes *[]Axis) { + if s == nil { + return + } + + // A node's own alternatives come from its x-examples and its oneOf branches; + // each becomes a candidate value for the whole subtree. We still recurse into + // the node's properties afterwards so nested enums/booleans are varied too — + // every combination is exercised even when the author provided examples. + if nodeValues := collectNodeValues(s); len(nodeValues) > 1 { + addAxis(axes, path, nodeValues) + } else if len(s.Enum) > 1 { + addAxis(axes, path, append([]any{}, s.Enum...)) + return // scalar leaf: nothing to recurse into + } else if s.Type.Contains("boolean") { + addAxis(axes, path, []any{true, false}) + return // scalar leaf + } + + for _, key := range sortedKeys(s.Properties) { + child := s.Properties[key] + walkSchema(&child, append(path, key), axes) + } +} + +// collectNodeValues gathers whole-subtree candidate values for a node: its +// x-examples plus one generated value per oneOf branch. Expanding oneOf lets the +// matrix reach each alternative shape a field can take (e.g. mode: VPA vs mode: +// Static), not just the one the default generator happens to pick. +func collectNodeValues(s *spec.Schema) []any { + var vals []any + + vals = append(vals, schemaExamples(s)...) + + for i := range s.OneOf { + if v, ok := branchValue(s, &s.OneOf[i]); ok { + vals = append(vals, v) + } + } + + return vals +} + +// branchValue generates a representative value for a single oneOf branch by +// overlaying the branch's properties on the parent's and running the shared +// openapi value generator. Returns false when nothing could be generated. +func branchValue(parent, branch *spec.Schema) (any, bool) { + merged := spec.Schema{} + merged.Properties = make(map[string]spec.Schema, len(parent.Properties)+len(branch.Properties)) + + for k := range parent.Properties { + merged.Properties[k] = parent.Properties[k] + } + + for k := range branch.Properties { + merged.Properties[k] = branch.Properties[k] + } + + if len(merged.Properties) == 0 { + return nil, false + } + + generated, err := defaults.Generate(&merged) + if err != nil || len(generated) == 0 { + return nil, false + } + + return generated, true +} + +// addAxis appends an axis at path. If an axis already exists there (branches and +// properties can reference the same location), the values are merged rather than +// dropped, so no variant is lost. +func addAxis(axes *[]Axis, path []string, valuesList []any) { + target := pathString(path) + + for i := range *axes { + if pathString((*axes)[i].Path) == target { + (*axes)[i].Values = append((*axes)[i].Values, valuesList...) + return + } + } + + *axes = append(*axes, Axis{Path: clonePath(path), Values: valuesList}) +} + +// schemaExamples extracts the x-examples of a node, normalized to []any. +func schemaExamples(s *spec.Schema) []any { + raw, ok := s.Extensions[xExamples] + if !ok { + return nil + } + + switch v := raw.(type) { + case []any: + return v + case []map[string]any: + out := make([]any, 0, len(v)) + for i := range v { + out = append(out, v[i]) + } + + return out + default: + return nil + } +} + +// buildOverride turns one combination (an index per axis) into a .Values +// override tree keyed by the module's camel name. +func buildOverride(camelName string, axes []Axis, combo []int) chartutil.Values { + moduleValues := map[string]any{} + + // Apply shallower paths first so a whole-subtree value (e.g. a oneOf branch + // or an x-example at resourcesRequests) is written before a nested override + // (e.g. resourcesRequests.mode) that must land inside it. + order := make([]int, len(combo)) + for i := range order { + order[i] = i + } + + sort.SliceStable(order, func(a, b int) bool { + return len(axes[order[a]].Path) < len(axes[order[b]].Path) + }) + + for _, i := range order { + setPath(moduleValues, axes[i].Path, deepCopyValue(axes[i].Values[combo[i]])) + } + + return chartutil.Values{camelName: moduleValues} +} + +func comboLabel(axes []Axis, combo []int) string { + parts := make([]string, len(combo)) + for i, valueIdx := range combo { + parts[i] = fmt.Sprintf("%s=%v", pathString(axes[i].Path), axes[i].Values[valueIdx]) + } + + return strings.Join(parts, ", ") +} + +// setPath assigns value at the nested key path within root, creating +// intermediate maps as needed. +func setPath(root map[string]any, path []string, value any) { + node := root + + for i, key := range path { + if i == len(path)-1 { + node[key] = value + return + } + + next, ok := node[key].(map[string]any) + if !ok { + next = map[string]any{} + node[key] = next + } + + node = next + } +} + +func moduleCamelName(modulePath string) (string, error) { + moduleYaml, err := modules.ParseModuleConfigFile(modulePath) + if err != nil { + return "", fmt.Errorf("parse module.yaml: %w", err) + } + + chartYaml, err := modules.ParseChartFile(modulePath) + if err != nil { + return "", fmt.Errorf("parse Chart.yaml: %w", err) + } + + name := modules.GetModuleName(moduleYaml, chartYaml) + if name == "" { + return "", fmt.Errorf("module at %q has no name", modulePath) + } + + return modules.ToLowerCamel(name), nil +} + +func deepCopyValue(v any) any { + switch t := v.(type) { + case map[string]any: + out := make(map[string]any, len(t)) + for k, val := range t { + out[k] = deepCopyValue(val) + } + + return out + case []any: + out := make([]any, len(t)) + for i := range t { + out[i] = deepCopyValue(t[i]) + } + + return out + default: + return v + } +} + +func clonePath(path []string) []string { + out := make([]string, len(path)) + copy(out, path) + + return out +} + +func pathString(path []string) string { + return strings.Join(path, ".") +} + +func sortedKeys(m map[string]spec.Schema) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + + sort.Strings(keys) + + return keys +} diff --git a/internal/matrix/matrix_test.go b/internal/matrix/matrix_test.go new file mode 100644 index 000000000..7510ea72a --- /dev/null +++ b/internal/matrix/matrix_test.go @@ -0,0 +1,222 @@ +/* +Copyright 2026 Flant JSC + +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 matrix + +import ( + "os" + "path/filepath" + "slices" + "testing" +) + +const configValues = ` +type: object +properties: + debug: + type: boolean + internal: + type: object + properties: + activated: + type: boolean + x-examples: [false, true] + resourcesRequests: + type: object + x-examples: + - mode: VPA + vpa: + mode: Auto + - mode: Static + static: + cpu: "55m" + memory: "256Ki" +` + +const valuesSchema = ` +x-extend: + schema: config-values.yaml +type: object +properties: {} +` + +func writeTestModule(t *testing.T) string { + t.Helper() + + dir := t.TempDir() + openapi := filepath.Join(dir, "openapi") + + if err := os.MkdirAll(openapi, 0o755); err != nil { + t.Fatal(err) + } + + write := func(name, content string) { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + + write("module.yaml", "name: test-module\nnamespace: test-module\n") + write(filepath.Join("openapi", "config-values.yaml"), configValues) + write(filepath.Join("openapi", "values.yaml"), valuesSchema) + + return dir +} + +func TestGenerate_IncludesDefaultAndCombos(t *testing.T) { + dir := writeTestModule(t) + + seq, count, err := Generate(dir, "values.yaml", 100) + if err != nil { + t.Fatalf("Generate: %v", err) + } + + variants := slices.Collect(seq) + + if len(variants) != count { + t.Fatalf("reported count %d but sequence yielded %d variants", count, len(variants)) + } + + if len(variants) < 2 { + t.Fatalf("expected default + combinations, got %d variants", len(variants)) + } + + if variants[0].Overrides != nil { + t.Errorf("first variant must be the default (nil overrides), got %v", variants[0].Overrides) + } + + // There must be a variant that simultaneously activates the module and + // selects the Static resources example — the combination that reaches the + // conditionally-rendered resource. + found := false + + for _, v := range variants { + mv, ok := v.Overrides["testModule"].(map[string]any) + if !ok { + continue + } + + internal, _ := mv["internal"].(map[string]any) + activated, _ := internal["activated"].(bool) + + rr, _ := mv["resourcesRequests"].(map[string]any) + mode, _ := rr["mode"].(string) + + if activated && mode == "Static" { + found = true + break + } + } + + if !found { + t.Fatalf("no variant combined internal.activated=true with resourcesRequests.mode=Static; variants:\n%s", labels(variants)) + } +} + +func labels(variants []Variant) string { + out := "" + for _, v := range variants { + out += " - " + v.Label + "\n" + } + + return out +} + +const oneOfConfigValues = ` +type: object +properties: + resourcesMode: + type: object + default: {} + oneOf: + - properties: + mode: + enum: ["Balanced"] + - properties: + mode: + enum: ["Static"] + properties: + mode: + type: string + enum: ["Balanced", "Static"] + default: "Balanced" +` + +func TestGenerate_ExpandsOneOf(t *testing.T) { + dir := t.TempDir() + openapi := filepath.Join(dir, "openapi") + + if err := os.MkdirAll(openapi, 0o755); err != nil { + t.Fatal(err) + } + + writeFile := func(name, content string) { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + + writeFile("module.yaml", "name: one-of\nnamespace: one-of\n") + writeFile(filepath.Join("openapi", "config-values.yaml"), oneOfConfigValues) + writeFile(filepath.Join("openapi", "values.yaml"), valuesSchema) + + seq, _, err := Generate(dir, "values.yaml", 100) + if err != nil { + t.Fatalf("Generate: %v", err) + } + + variants := slices.Collect(seq) + + // The non-default (generated Balanced) "Static" branch must be reachable in + // at least one variant, either via the oneOf branch value or the mode enum. + found := false + + for _, v := range variants { + mv, ok := v.Overrides["oneOf"].(map[string]any) + if !ok { + continue + } + + rm, ok := mv["resourcesMode"].(map[string]any) + if !ok { + continue + } + + if mode, _ := rm["mode"].(string); mode == "Static" { + found = true + break + } + } + + if !found { + t.Fatalf("no variant selected the Static oneOf/enum branch; variants:\n%s", labels(variants)) + } +} + +func TestSetPath_Nested(t *testing.T) { + root := map[string]any{} + setPath(root, []string{"internal", "activated"}, true) + setPath(root, []string{"internal", "other"}, "x") + + internal, ok := root["internal"].(map[string]any) + if !ok { + t.Fatal("internal not created as map") + } + + if internal["activated"] != true || internal["other"] != "x" { + t.Fatalf("nested values not set correctly: %v", internal) + } +} diff --git a/internal/modules/module.go b/internal/modules/module.go index 5dbbd0ba3..e1a0d1a00 100644 --- a/internal/modules/module.go +++ b/internal/modules/module.go @@ -22,6 +22,7 @@ import ( "os" "path/filepath" "strings" + "sync" "github.com/go-openapi/spec" "helm.sh/helm/v3/pkg/chart" @@ -45,6 +46,16 @@ const ( // Compile-time check to ensure Module implements pkg.Module interface var _ pkg.Module = (*Module)(nil) +// objectStorePool recycles UnstructuredObjectStores across module renders. In +// --matrix mode a module set expands to thousands of variants rendered one +// after another; reusing a store — and the map buckets it has already grown — +// instead of allocating a fresh one per render cuts allocation churn and peak +// memory. A store is borrowed in NewModule (rendered strictly into it) and +// handed back by Module.Release once the module has been linted. +var objectStorePool = sync.Pool{ + New: func() any { return storage.NewUnstructuredObjectStore() }, +} + type Module struct { name string namespace string @@ -129,6 +140,20 @@ func (m *Module) GetStorage() map[storage.ResourceIndex]storage.StoreObject { return m.objectStore.Storage } +// Release returns the module's rendered object store to the shared pool and +// detaches it from the module. Call it once the module has been fully linted; +// the module (and any object obtained from its store) must not be used +// afterwards. Safe to call more than once and on a nil module. +func (m *Module) Release() { + if m == nil || m.objectStore == nil { + return + } + + m.objectStore.Reset() + objectStorePool.Put(m.objectStore) + m.objectStore = nil +} + func (m *Module) GetWerfFile() string { if m == nil { return "" @@ -392,6 +417,7 @@ func mapTemplatesRules(linterSettings *pkg.LintersSettings, configSettings *conf rules.WebhookConfigurationRule.SetLevel(globalRules.WebhookConfigurationRule.Impact, fallbackImpact) rules.HelmRenderRule.SetLevel(globalRules.HelmRenderRule.Impact, fallbackImpact) rules.OpenAPIValuesQuoteRule.SetLevel(globalRules.OpenAPIValuesQuoteRule.Impact, fallbackImpact) + rules.SchemaValidationRule.SetLevel(globalRules.SchemaValidationRule.Impact, fallbackImpact) } // mapOpenAPIRules configures OpenAPI linter rules @@ -525,6 +551,7 @@ func mapTemplatesExclusionsAndSettings(linterSettings *pkg.LintersSettings, conf excludes.WebhookConfiguration = configExcludes.WebhookConfiguration.Get() excludes.MountPoints = pkg.StringRuleExcludeList(configExcludes.MountPoints) excludes.OpenAPIValuesQuote = pkg.StringRuleExcludeList(configExcludes.OpenAPIValuesQuote) + excludes.SchemaValidation = configExcludes.SchemaValidation.Get() // Additional settings linterSettings.Templates.PrometheusRuleSettings.Disable = configSettings.Templates.PrometheusRules.Disable @@ -577,10 +604,16 @@ func NewModule(path string, vals *chartutil.Values, globalSchema *spec.Schema, r return nil, fmt.Errorf("failed to override values from file: %w", err) } - objectStore := storage.NewUnstructuredObjectStore() + // Render strictly into a pooled store rather than a freshly allocated one. + objectStore := objectStorePool.Get().(*storage.UnstructuredObjectStore) err = RunRender(module, schemas, objectStore, errorList) if err != nil { + // Matrix variants fail to render often; return the store to the pool so + // the next render reuses it instead of leaking it. + objectStore.Reset() + objectStorePool.Put(objectStore) + return nil, err } diff --git a/internal/modules/values/schema/defaults/generator.go b/internal/modules/values/schema/defaults/generator.go index 2929110de..3059d3426 100644 --- a/internal/modules/values/schema/defaults/generator.go +++ b/internal/modules/values/schema/defaults/generator.go @@ -120,7 +120,14 @@ func synthesizeProperty(key string, prop *spec.Schema, result map[string]any) er func synthesizeString(key, pattern string, result map[string]any) error { if pattern == "" { - pattern = `^[a-zA-Z0-9]{8}$` + // No pattern in the module's own values schema, so we invent a placeholder. + // Generate a lowercase kebab-case string (e.g. "abcd-efgh") rather than an + // arbitrary mixed-case one: such values routinely flow into resource name / + // namespace / label fields, which downstream CRD schemas constrain to the + // DNS-1123 label pattern `^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`. A mixed-case + // placeholder fails that pattern and produces spurious schema-validation + // findings; a lowercase kebab value satisfies it. + pattern = `^[a-z]{4}-[a-z]{4}$` } const limit = 8 @@ -290,13 +297,29 @@ func synthesizeComposite(key string, prop *spec.Schema, branches []spec.Schema, downwardSchema := deepcopy.Copy(prop).(*spec.Schema) mergedSchema := mergeSchemas(downwardSchema, branches...) - t, err := synthesizeProperties(mergedSchema) - if err != nil { - return err + if len(mergedSchema.Properties) > 0 { + t, err := synthesizeProperties(mergedSchema) + if err != nil { + return err + } + + if t != nil { + result[key] = t + } + + return nil } - if t != nil { - result[key] = t + // No object shape to build: pick the first branch with a concrete scalar + // type (or enum) and generate that, so int-or-string style unions yield a + // valid scalar rather than an empty object. + for i := range branches { + branch := branches[i] + if len(branch.Enum) > 0 || branch.Type.Contains("string") || + branch.Type.Contains("integer") || branch.Type.Contains("number") || + branch.Type.Contains("boolean") { + return synthesizeProperty(key, &branch, result) + } } return nil diff --git a/internal/modules/values/schema/defaults/generator_test.go b/internal/modules/values/schema/defaults/generator_test.go index 0fd9caa74..f3694bc24 100644 --- a/internal/modules/values/schema/defaults/generator_test.go +++ b/internal/modules/values/schema/defaults/generator_test.go @@ -527,13 +527,38 @@ func Test_synthesizeProperties(t *testing.T) { } } +// Test_synthesizeComposite_ScalarOneOf guards the int-or-string quantity shape used +// throughout deckhouse (resources.requests/limits.cpu/memory, VPA min/max): +// `oneOf: [{type: string}, {type: number}]`. It must generate a scalar, not an +// empty object — a `{}` there renders into resource fields and trips schema +// validation with "got object, want null or number". +func Test_synthesizeComposite_ScalarOneOf(t *testing.T) { + prop := &spec.Schema{ + SchemaProps: spec.SchemaProps{ + OneOf: []spec.Schema{ + {SchemaProps: spec.SchemaProps{Type: spec.StringOrArray{"string"}, Pattern: `^[0-9]+m?$`}}, + {SchemaProps: spec.SchemaProps{Type: spec.StringOrArray{"number"}}}, + }, + }, + } + + result := map[string]any{} + require.NoError(t, synthesizeProperty("cpu", prop, result)) + + if _, isObject := result["cpu"].(map[string]any); isObject { + t.Fatalf("scalar oneOf generated an object, want a scalar: %v", result["cpu"]) + } + + require.Contains(t, result, "cpu") + require.IsType(t, "", result["cpu"], "expected a string quantity from the first (string) branch") +} + func Test_pickExample(t *testing.T) { disabled := map[string]any{"mode": "Disabled"} certManager := map[string]any{ "mode": "CertManager", "certManager": map[string]any{"clusterIssuerName": "letsencrypt"}, } - tests := []struct { name string in any diff --git a/internal/modules/values/values.go b/internal/modules/values/values.go index 5113c4f2b..7064225e5 100644 --- a/internal/modules/values/values.go +++ b/internal/modules/values/values.go @@ -187,14 +187,16 @@ func GetModuleValuesForValuesFile(modulePath, valuesFile string) (*spec.Schema, return schemas[ValuesSchema], nil } +// OverrideValues deep-merges vals into the module's generated .Values tree, with +// vals winning on conflicts. values is the flat .Values tree fed straight to the +// renderer (see render.Options.Values), so vals must be merged at that same level +// — e.g. a --values-file or a matrix variant's overrides carry `{: {...}, +// global: {...}}` and set `.Values.....`. Wrapping vals under a "Values" +// key here would misplace it at `.Values.Values....`, where no template reads it. func OverrideValues(values, vals *chartutil.Values) error { if vals == nil { return nil } - v := &chartutil.Values{ - "Values": *vals, - } - - return mergo.Merge(values, v, mergo.WithOverride) + return mergo.Merge(values, vals, mergo.WithOverride) } diff --git a/internal/modules/values/values_test.go b/internal/modules/values/values_test.go index 846e0981e..0867c5d29 100644 --- a/internal/modules/values/values_test.go +++ b/internal/modules/values/values_test.go @@ -3,7 +3,6 @@ package values import ( "os" "path/filepath" - "reflect" "testing" "helm.sh/helm/v3/pkg/chartutil" @@ -22,7 +21,9 @@ func TestOverrideValues(t *testing.T) { t.Errorf("expected values to be unchanged when vals is nil") } - // Test override + // Test override: vals is merged directly into the .Values tree (no wrapper), + // so the original keys are preserved and the override keys are added at the + // same level the renderer reads them from. values = &chartutil.Values{"foo": "bar"} vals := &chartutil.Values{"baz": "qux"} @@ -31,17 +32,16 @@ func TestOverrideValues(t *testing.T) { t.Fatalf("expected no error, got: %v", err) } - if v, ok := (*values)["Values"]; !ok { - t.Errorf("expected 'Values' key to be present after override") - } else { - valsMap, ok := v.(chartutil.Values) - if !ok { - t.Errorf("expected 'Values' to be of type chartutil.Values") - } + if _, ok := (*values)["Values"]; ok { + t.Errorf("did not expect a wrapping 'Values' key; vals must merge into the flat .Values tree") + } + + if (*values)["foo"] != "bar" { + t.Errorf("expected original key 'foo' to be preserved, got: %v", (*values)["foo"]) + } - if !reflect.DeepEqual(valsMap, *vals) { - t.Errorf("expected 'Values' to equal vals, got: %v", valsMap) - } + if (*values)["baz"] != "qux" { + t.Errorf("expected override key 'baz' to be merged in, got: %v", (*values)["baz"]) } } diff --git a/internal/sources/remote/lint.go b/internal/sources/remote/lint.go index 70cd349bf..7487c8198 100644 --- a/internal/sources/remote/lint.go +++ b/internal/sources/remote/lint.go @@ -23,12 +23,14 @@ import ( "context" stderrors "errors" "fmt" + "log/slog" "os" "path" "strings" "github.com/google/go-containerregistry/pkg/name" + "github.com/deckhouse/deckhouse/pkg/log" "github.com/deckhouse/deckhouse/pkg/registry" "github.com/deckhouse/dmt/internal/manager" @@ -103,19 +105,25 @@ func (s *Source) Targets( ctx context.Context, cfg *config.RootConfig, _ *errors.LintRuleErrorsList, -) ([]manager.Target, error) { + yield func(manager.Target) bool, +) error { bundle, bundleErr := s.target(ctx, s.client, scopes.Bundle, cfg) release, releaseErr := s.target(ctx, s.client.WithSegment(releaseSegment), scopes.Release, cfg) - targets := make([]manager.Target, 0, 2) + // One module read twice, so the count the summary reports is one. + log.Info("Found modules", slog.Int("count", 1)) for _, t := range []*manager.Target{bundle, release} { - if t != nil { - targets = append(targets, *t) + if t == nil { + continue + } + + if !yield(*t) { + break } } - return targets, stderrors.Join(bundleErr, releaseErr) + return stderrors.Join(bundleErr, releaseErr) } // target pulls one image and unpacks it into a module. Which linters the scope runs diff --git a/internal/sources/static/lint.go b/internal/sources/static/lint.go index 45865e400..693594f63 100644 --- a/internal/sources/static/lint.go +++ b/internal/sources/static/lint.go @@ -23,6 +23,8 @@ import ( "log/slog" "path/filepath" + "dario.cat/mergo" + "github.com/go-openapi/spec" "helm.sh/helm/v3/pkg/chartutil" "github.com/deckhouse/deckhouse/pkg/log" @@ -30,6 +32,7 @@ import ( "github.com/deckhouse/dmt/internal/flags" "github.com/deckhouse/dmt/internal/fsutils" "github.com/deckhouse/dmt/internal/manager" + "github.com/deckhouse/dmt/internal/matrix" "github.com/deckhouse/dmt/internal/moduleloader" "github.com/deckhouse/dmt/internal/modules" "github.com/deckhouse/dmt/internal/modules/values" @@ -41,13 +44,50 @@ import ( // Source reads modules from a directory on disk. type Source struct { dir string + + // matrix renders each module under every value combination its openapi schema + // describes (see internal/matrix) instead of only the default one; matrixLimit + // caps the combinations per module. + matrix bool + matrixLimit int } var _ manager.Source = (*Source)(nil) +// Option customizes a Source at construction time. +type Option func(*Source) + +// WithMatrix enables matrix mode with the given per-module combination limit. A +// non-positive limit falls back to the matrix package default. It is the +// programmatic equivalent of the --matrix / --matrix-limit flags, and lets callers +// (e.g. tests running cases in parallel) opt in without touching process-global +// flags. +func WithMatrix(enabled bool, limit int) Option { + return func(s *Source) { + s.matrix = enabled + + if limit > 0 { + s.matrixLimit = limit + } + } +} + // NewSource lints every module found under dir. -func NewSource(dir string) *Source { - return &Source{dir: dir} +func NewSource(dir string, opts ...Option) *Source { + s := &Source{ + dir: dir, + + // Default to the process-global flags so the CLI keeps working; the options + // override them for programmatic callers. + matrix: flags.Matrix, + matrixLimit: flags.MatrixLimit, + } + + for _, opt := range opts { + opt(s) + } + + return s } // ConfigDir is the linted directory itself: .dmtlint.yaml is looked up from the tree @@ -66,16 +106,21 @@ func (s *Source) Close() {} // Targets walks the tree for modules and builds each one. A module that cannot be // read is reported as a finding and skipped, not returned as an error: one broken // module must not cost the caller the findings of every other. +// +// Modules are handed to yield as they are built rather than collected: under +// --matrix one module becomes many renders, and yield blocks while the linters are +// busy, so only a bounded number of them is ever resident. func (s *Source) Targets( _ context.Context, cfg *config.RootConfig, errorList *errors.LintRuleErrorsList, -) ([]manager.Target, error) { + yield func(manager.Target) bool, +) error { paths, err := moduleloader.GetModulePaths(s.dir) if err != nil { log.Error("Error getting module paths", log.Err(err)) - return nil, nil + return nil } vals, err := decodeValuesFile(flags.ValuesFile) @@ -87,40 +132,207 @@ func (s *Source) Targets( if err != nil { log.Error("Failed to get global values", log.Err(err)) - return nil, nil + return nil } - targets := make([]manager.Target, 0, len(paths)) + // Validate every module before rendering any, so the count below is the number + // of modules that will actually be linted and is reported up front rather than + // after the last (slow) render. + valid := make([]string, 0, len(paths)) for i := range paths { - moduleName := filepath.Base(paths[i]) - log.Debug("Found module", slog.String("module", moduleName)) + log.Debug("Found module", slog.String("module", filepath.Base(paths[i]))) if err := validateModule(paths[i], errorList); err != nil { // linting errors are already logged continue } - mdl, err := modules.NewModule(paths[i], &vals, globalValues, cfg, errorList) - if err != nil { - errorList. - WithFilePath(paths[i]).WithModule(moduleName). - WithValue(err.Error()). - Errorf("cannot create module `%s`", moduleName) + valid = append(valid, paths[i]) + } - continue + log.Info("Found modules", slog.Int("count", len(valid))) + + for _, path := range valid { + if !s.push(path, vals, globalValues, cfg, errorList, yield) { + return nil } + } - targets = append(targets, manager.Target{ + return nil +} + +// push renders every variant of one module and hands each to yield, returning false +// once yield has asked the run to stop. +func (s *Source) push( + path string, + vals chartutil.Values, + globalValues *spec.Schema, + cfg *config.RootConfig, + errorList *errors.LintRuleErrorsList, + yield func(manager.Target) bool, +) bool { + moduleName := filepath.Base(path) + running := true + + s.forEachVariant(path, moduleName, errorList, func(v variant) bool { + mdl := s.render(path, moduleName, v, vals, globalValues, cfg, errorList) + if mdl == nil { + return true + } + + running = yield(manager.Target{ Module: mdl, Scope: scopes.Static, // The directory, not the name: two directories may declare the same // module name, and the summary must still count them separately. - ModuleID: paths[i], + ModuleID: path, + Variant: s.matrix, }) + + return running + }) + + return running +} + +// variant is one render of a module: the value overrides that select which template +// branches are produced. Nil overrides are the default render — the one whose +// failure is a genuine module defect rather than an invalid value combination. +type variant struct { + label string + overrides chartutil.Values +} + +// forEachVariant invokes fn for each render of a module, generated lazily. Without +// --matrix that is the single default render (the generated values plus +// --values-file). With it, it is every value combination the module's openapi schema +// describes, the default among them, streamed one at a time so a schema that expands +// to millions of combinations is never materialized as a slice. fn returning false +// stops the iteration. +func (s *Source) forEachVariant( + path, moduleName string, + errorList *errors.LintRuleErrorsList, + fn func(variant) bool, +) { + if !s.matrix { + fn(variant{}) + + return + } + + seq, count, err := matrix.Generate(path, "values.yaml", s.matrixLimit) + if err != nil { + errorList.WithFilePath(path).WithModule(moduleName). + WithValue(err.Error()). + Errorf("cannot expand matrix variants for module `%s`", moduleName) + + // Fall back to the default render so the module is still linted. + fn(variant{}) + + return } - return targets, nil + log.Info("Matrix variants for module", + slog.String("module", moduleName), slog.Int("count", count)) + + for v := range seq { + if !fn(variant{label: v.Label, overrides: v.Overrides}) { + return + } + } +} + +// render builds the module for one variant. It returns nil when the variant cannot +// be rendered: a matrix variant (non-nil overrides) that fails is almost always an +// invalid value combination the chart rejects via `fail` — two mutually-exclusive +// parameters, say — so it is skipped quietly. Only the default render's failure is +// reported as a genuine "module doesn't build" finding. +func (s *Source) render( + path, moduleName string, + v variant, + vals chartutil.Values, + globalValues *spec.Schema, + cfg *config.RootConfig, + errorList *errors.LintRuleErrorsList, +) *modules.Module { + merged := mergeValues(vals, v.overrides) + + mdl, err := modules.NewModule(path, &merged, globalValues, cfg, errorList) + if err == nil { + return mdl + } + + if v.overrides != nil { + log.Debug("skipping matrix variant that failed to render", + slog.String("module", moduleName), + slog.String("variant", v.label), + slog.String("error", err.Error()), + ) + + return nil + } + + errorList. + WithFilePath(path).WithModule(moduleName). + WithValue(err.Error()). + Errorf("cannot create module `%s`", moduleName) + + return nil +} + +// mergeValues returns a fresh value tree with override applied on top of base. base +// is read-only: one tree is shared by every render of every module, so a variant that +// wrote into it would hand its overrides to every variant rendered after it. +// +// The copy has to be deep. mergo assigns a nested map by reference when the +// destination does not have the key yet, so merging base into an empty tree and the +// override on top of that would reach through the shared sub-maps and write the +// override into base itself. +func mergeValues(base, override chartutil.Values) chartutil.Values { + if len(override) == 0 { + return base + } + + out := make(chartutil.Values, len(base)) + for k, v := range base { + out[k] = deepCopyValue(v) + } + + // override is built fresh for this variant and used once, so the sub-trees mergo + // takes from it by reference are not shared with anything. + _ = mergo.Merge(&out, override, mergo.WithOverride) + + return out +} + +// deepCopyValue copies the maps and slices of a value tree, leaving scalars alone. +func deepCopyValue(v any) any { + switch t := v.(type) { + case chartutil.Values: + out := make(chartutil.Values, len(t)) + for k, val := range t { + out[k] = deepCopyValue(val) + } + + return out + case map[string]any: + out := make(map[string]any, len(t)) + for k, val := range t { + out[k] = deepCopyValue(val) + } + + return out + case []any: + out := make([]any, len(t)) + for i := range t { + out[i] = deepCopyValue(t[i]) + } + + return out + default: + return v + } } func decodeValuesFile(path string) (chartutil.Values, error) { diff --git a/internal/sources/static/lint_test.go b/internal/sources/static/lint_test.go new file mode 100644 index 000000000..5e476f45e --- /dev/null +++ b/internal/sources/static/lint_test.go @@ -0,0 +1,45 @@ +package static + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "helm.sh/helm/v3/pkg/chartutil" +) + +// TestMergeValuesLeavesBaseAlone is the guard for the invariant every matrix render +// depends on: the --values-file tree is shared by all of them, so a variant that +// wrote into it would hand its own overrides to every variant rendered afterwards +// and each one would be linted under the union of the combinations before it. +func TestMergeValuesLeavesBaseAlone(t *testing.T) { + base := chartutil.Values{ + "mod": map[string]any{ + "enabled": false, + "nested": map[string]any{"mode": "Direct"}, + "list": []any{"a"}, + }, + } + + first := mergeValues(base, chartutil.Values{"mod": map[string]any{"enabled": true}}) + assert.Equal(t, true, first["mod"].(map[string]any)["enabled"]) + + second := mergeValues(base, chartutil.Values{ + "mod": map[string]any{"nested": map[string]any{"mode": "Proxy"}}, + }) + + assert.Equal(t, false, base["mod"].(map[string]any)["enabled"], + "the first variant's override must not have reached the shared base") + assert.Equal(t, "Direct", base["mod"].(map[string]any)["nested"].(map[string]any)["mode"], + "the second variant's override must not have reached the shared base") + assert.Equal(t, false, second["mod"].(map[string]any)["enabled"], + "a variant must render with its own overrides only, not those of earlier ones") +} + +// TestMergeValuesWithoutOverridesSharesTheBase pins the plain (non-matrix) path: the +// single render of a module is handed the values tree as it is, with no copying. +func TestMergeValuesWithoutOverridesSharesTheBase(t *testing.T) { + base := chartutil.Values{"mod": map[string]any{"enabled": true}} + + assert.Equal(t, base, mergeValues(base, nil)) + assert.Nil(t, mergeValues(nil, nil)) +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index ba003f761..1b8988a27 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -426,6 +426,13 @@ func (s *UnstructuredObjectStore) Close() { s.Storage = make(map[ResourceIndex]StoreObject) } +// Reset empties the store in place, keeping the map's already-allocated buckets +// so a pooled store retains its capacity for the next render. Unlike Close it +// does not drop the backing map, which is what makes reuse cheap. +func (s *UnstructuredObjectStore) Reset() { + clear(s.Storage) +} + func NewSHA256(data []byte) string { h := sha256.New() h.Write(data) diff --git a/pkg/config.go b/pkg/config.go index 114a4bc41..a98025a7f 100644 --- a/pkg/config.go +++ b/pkg/config.go @@ -153,6 +153,7 @@ type TemplatesLinterRules struct { MountPointsRule RuleConfig HelmRenderRule RuleConfig OpenAPIValuesQuoteRule RuleConfig + SchemaValidationRule RuleConfig } type PrometheusRuleSettings struct { @@ -173,6 +174,7 @@ type TemplatesExcludeRules struct { WebhookConfiguration KindRuleExcludeList MountPoints StringRuleExcludeList OpenAPIValuesQuote StringRuleExcludeList + SchemaValidation KindRuleExcludeList } type EnabledModulesExcludeRule struct { diff --git a/pkg/config/global/global.go b/pkg/config/global/global.go index 738369443..a5b2fca78 100644 --- a/pkg/config/global/global.go +++ b/pkg/config/global/global.go @@ -156,6 +156,7 @@ type TemplatesLinterRules struct { MountPointsRule RuleConfig `mapstructure:"mount-points"` HelmRenderRule RuleConfig `mapstructure:"helm-render"` OpenAPIValuesQuoteRule RuleConfig `mapstructure:"openapi-values-quote"` + SchemaValidationRule RuleConfig `mapstructure:"schema-validation"` } func (c LinterConfig) IsWarn() bool { diff --git a/pkg/config/linters_settings.go b/pkg/config/linters_settings.go index 76702a608..db57c4804 100644 --- a/pkg/config/linters_settings.go +++ b/pkg/config/linters_settings.go @@ -250,6 +250,7 @@ type TemplatesLinterRules struct { MountPointsRule RuleConfig `mapstructure:"mount-points"` HelmRenderRule RuleConfig `mapstructure:"helm-render"` OpenAPIValuesQuoteRule RuleConfig `mapstructure:"openapi-values-quote"` + SchemaValidationRule RuleConfig `mapstructure:"schema-validation"` } type TemplatesExcludeRules struct { @@ -263,6 +264,7 @@ type TemplatesExcludeRules struct { WebhookConfiguration KindRuleExcludeList `mapstructure:"webhook-configuration-annotations"` MountPoints StringRuleExcludeList `mapstructure:"mount-points"` OpenAPIValuesQuote StringRuleExcludeList `mapstructure:"openapi-values-quote"` + SchemaValidation KindRuleExcludeList `mapstructure:"schema-validation"` } type EnabledModulesExcludeRule struct { diff --git a/pkg/linters/templates/README.md b/pkg/linters/templates/README.md index 2b92d784e..e5f530497 100644 --- a/pkg/linters/templates/README.md +++ b/pkg/linters/templates/README.md @@ -26,6 +26,7 @@ Proper template validation prevents runtime issues, ensures applications are pro | [webhook-configuration-annotations](#webhook-configuration-annotations) | Checks webhook configurations have werf.io/weight or deploy-dependency annotations | ✅ | enabled | | [mount-points](#mount-points) | Validates that mount-points.yaml directories are used as volumeMounts in pod controllers | ✅ | enabled | | [openapi-values-quote](#openapi-values-quote) | Requires templates to quote OpenAPI string values that have no `pattern`/`enum`/`format` | ✅ | enabled | +| [schema-validation](#schema-validation) | Strictly decodes every rendered standard Kubernetes resource against its API type | ✅ | enabled | "Configurable" means that this rule can be configured using the `.dmtlint.yaml` file, including customizing the rule's parameters and/or disabling the rule. @@ -2863,3 +2864,75 @@ The linter now includes comprehensive validation for Grafana dashboards based on - **Required variable**: Ensures dashboards contain the required `ds_prometheus` variable of type `datasource` - **Query variables**: Validates that query variables use recommended datasource UIDs + +### schema-validation + +**Purpose:** Checks every rendered **standard Kubernetes** resource against the +API it targets, catching fields of the wrong type and fields the API does not +declare at lint time — before the resource is ever applied to a cluster. + +**Description:** + +After the module's templates are rendered, each resulting object is decoded into +the Go type that serves its `apiVersion`/`kind` (e.g. `Deployment`, `Service`), +strictly. Two things are reported: + +- **a field of the wrong type** — `replicas: "3"` where an integer is expected; +- **a field the API does not declare** — a typo, a renamed key, or a value put at + the wrong level, such as `resources.memory` instead of + `resources.limits.memory`. This is the same error server-side apply reports as + "field not declared in schema", and each offending field is reported separately, + by its full path. + +The types come from the `k8s.io/api` version dmt is built against, so the check +follows whatever Kubernetes release that is. Nothing is downloaded and no schema +snapshot is embedded, so there is no catalog to regenerate and no way for the +check to drift from the API types the rest of dmt already uses. + +**Custom resources are not checked.** Anything served by a +CustomResourceDefinition — the module's own CRDs included — has no registered Go +type and is skipped, as is any other unregistered kind. The rule reports +violations, never the absence of a type, so a skipped resource is simply silent. + +**What it does not catch:** constraints that live in the OpenAPI schema rather +than in the Go type — a missing required field, a value outside an enum, a +`minimum`/`maxLength` bound. Those still surface at apply time. + +Nor is the **content** of a binary field judged (`Secret.data`, `ConfigMap.binaryData`, +a webhook's `caBundle`). Such a field travels as base64, and dmt renders with values +generated from the module's openapi schema rather than the ones a cluster supplies: +a chart that passes a value straight through — expecting it to arrive already +encoded — would otherwise be reported for a payload dmt itself invented. The shape +around it is still checked, so a `data` that is not a map of strings still fails. + +**Why it matters:** + +Rendered templates frequently drift from the API they target: a value of the +wrong type, a renamed field, a key indented one level off. Such issues otherwise +surface only at `helm install` / apply time. Decoding against the real API types +during lint moves that feedback left. + +Custom resources stay out of scope on purpose: the definition a cluster actually +serves is not knowable from a source tree, so the only available answers would +come from a third-party catalog that lags upstream or from a CRD that may not be +the one installed. A false finding against a stale schema costs more than the +check is worth. + +**Configuration:** + +```yaml +linters-settings: + templates: + rules: + schema-validation: + impact: error # or warn / ignore + exclude-rules: + schema-validation: + - kind: MyResource + name: my-resource +``` + +**Which Kubernetes version is checked against:** + +Whichever `k8s.io/api` is in `go.mod`. Bumping that dependency is the whole of +updating this rule — there is nothing else to regenerate. diff --git a/pkg/linters/templates/rules/schema_validation.go b/pkg/linters/templates/rules/schema_validation.go new file mode 100644 index 000000000..b976c7360 --- /dev/null +++ b/pkg/linters/templates/rules/schema_validation.go @@ -0,0 +1,232 @@ +/* +Copyright 2026 Flant JSC + +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 rules + +import ( + "context" + "reflect" + "strings" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/scheme" + + "github.com/deckhouse/dmt/pkg" + "github.com/deckhouse/dmt/pkg/errors" +) + +const SchemaValidationRuleName = "schema-validation" + +func NewSchemaValidationRule(excludeRules []pkg.KindRuleExclude, m pkg.Module, errorList *errors.LintRuleErrorsList) *SchemaValidationRule { + return &SchemaValidationRule{ + RuleMeta: pkg.RuleMeta{ + Name: SchemaValidationRuleName, + }, + KindRule: pkg.KindRule{ + ExcludeRules: excludeRules, + }, + module: m, + errorList: errorList.WithRule(SchemaValidationRuleName), + } +} + +// SchemaValidationRule decodes every rendered manifest into the Go type that +// serves it, strictly: a field of the wrong type and a field the API does not +// declare are both errors. The types come from the k8s.io/api version dmt is +// built against, so what the rule checks against is whatever Kubernetes release +// that is — there is no schema snapshot to keep in sync. +// +// Only standard Kubernetes resources are checked. A custom resource has no Go +// type registered and is skipped, as is any other unregistered kind: the rule +// reports violations, never the absence of a type. +type SchemaValidationRule struct { + pkg.RuleMeta + pkg.KindRule + + module pkg.Module + errorList *errors.LintRuleErrorsList +} + +var _ pkg.Rule = (*SchemaValidationRule)(nil) + +func (r *SchemaValidationRule) Check(_ context.Context) { + for _, object := range r.module.GetStorage() { + kind := object.Unstructured.GetKind() + + if !r.Enabled(kind, object.Unstructured.GetName()) { + continue + } + + for _, violation := range check(object.Unstructured) { + r.errorList.WithObjectID(object.Identity()). + WithFilePath(object.GetPath()). + Errorf("resource does not match the %s API: %s", kind, violation) + } + } +} + +// check reads one object into its API type and returns what did not fit. +func check(u unstructured.Unstructured) []string { + gvk := u.GroupVersionKind() + + // New reports an error for a kind the scheme does not know, which is how a + // custom resource — anything served by a CRD rather than by the API server + // itself — is left alone. + typed, err := scheme.Scheme.New(gvk) + if err != nil { + return nil + } + + if err := decode(u.UnstructuredContent(), typed); err == nil { + return nil + } + + // The failure may be about the content of a binary field rather than the shape + // of the object, and that content is dmt's own invention (see blankBinaryLeaves). + // Read it again with that content out of the picture, and report only what + // survives. The second pass needs a fresh object, since the first left this one + // half-filled, and a copy of the content, since the store is shared with every + // other rule — both of which are why this is not simply how the first pass works. + typed, err = scheme.Scheme.New(gvk) + if err != nil { + return nil + } + + clean := runtime.DeepCopyJSON(u.UnstructuredContent()) + blankBinaryLeaves(clean, reflect.TypeOf(typed).Elem()) + + if err := decode(clean, typed); err != nil { + return violations(err) + } + + return nil +} + +// decode strictly reads an object into its API type: a field of the wrong type and +// a field the type does not declare are both errors. +func decode(content map[string]any, typed any) error { + return runtime.DefaultUnstructuredConverter.FromUnstructuredWithValidation(content, typed, true) +} + +// blankBinaryLeaves walks content alongside the shape of t, emptying every string +// that would be read into a []byte field. +// +// Such a field travels as base64, and dmt renders with values generated from the +// module's openapi schema rather than the ones a cluster supplies. A chart that +// passes a value straight into Secret.data — the value being expected to arrive +// already encoded — therefore produces a payload that is not valid base64, and the +// decode fails on dmt's own invention rather than on anything the module got wrong. +// +// The structure is still judged: only strings are emptied, so a data field that is +// not a map, or a map holding a number where the API wants a string, still fails. +func blankBinaryLeaves(value any, t reflect.Type) any { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + + switch { + case t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8: + if _, ok := value.(string); ok { + return "" + } + + return value + + case t.Kind() == reflect.Struct: + obj, ok := value.(map[string]any) + if !ok { + return value + } + + for i := range t.NumField() { + field := t.Field(i) + + name, inline := jsonFieldName(field) + if inline { + // An embedded struct's own fields sit at this level, so it is walked + // against the same map rather than a member of it. + blankBinaryLeaves(obj, field.Type) + + continue + } + + if v, ok := obj[name]; ok { + obj[name] = blankBinaryLeaves(v, field.Type) + } + } + + return obj + + case t.Kind() == reflect.Map: + obj, ok := value.(map[string]any) + if !ok { + return value + } + + for k, v := range obj { + obj[k] = blankBinaryLeaves(v, t.Elem()) + } + + return obj + + case t.Kind() == reflect.Slice || t.Kind() == reflect.Array: + items, ok := value.([]any) + if !ok { + return value + } + + for i := range items { + items[i] = blankBinaryLeaves(items[i], t.Elem()) + } + + return items + + default: + return value + } +} + +// jsonFieldName is the key a struct field is carried under, and whether it is +// embedded at the parent's level instead of under a key of its own. +func jsonFieldName(field reflect.StructField) (string, bool) { + name, _, _ := strings.Cut(field.Tag.Get("json"), ",") + if name == "" { + return field.Name, field.Anonymous + } + + return name, false +} + +// violations flattens a decode failure into one message per problem. Unknown +// fields arrive as a strict-decoding error carrying one entry per field, already +// ordered; anything else is a type mismatch, which aborts the decode and so is +// always a single message. +func violations(err error) []string { + strict, ok := runtime.AsStrictDecodingError(err) + if !ok { + return []string{err.Error()} + } + + unknown := strict.Errors() + out := make([]string, 0, len(unknown)) + + for _, e := range unknown { + out = append(out, e.Error()) + } + + return out +} diff --git a/pkg/linters/templates/rules/schema_validation_test.go b/pkg/linters/templates/rules/schema_validation_test.go new file mode 100644 index 000000000..6a9fd3d6a --- /dev/null +++ b/pkg/linters/templates/rules/schema_validation_test.go @@ -0,0 +1,218 @@ +/* +Copyright 2026 Flant JSC + +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 rules + +import ( + "strings" + "testing" + + "github.com/gojuno/minimock/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/deckhouse/dmt/internal/mocks" + "github.com/deckhouse/dmt/internal/storage" + "github.com/deckhouse/dmt/pkg" + "github.com/deckhouse/dmt/pkg/errors" +) + +func schemaStorage(objects ...map[string]any) map[storage.ResourceIndex]storage.StoreObject { + out := make(map[storage.ResourceIndex]storage.StoreObject, len(objects)) + + for i := range objects { + u := unstructured.Unstructured{Object: objects[i]} + + idx := storage.ResourceIndex{ + Kind: u.GetKind(), + Name: u.GetName(), + Namespace: u.GetNamespace(), + } + + out[idx] = storage.StoreObject{Unstructured: u, AbsPath: "/test/" + u.GetName() + ".yaml"} + } + + return out +} + +func runSchemaRule(t *testing.T, exclude []pkg.KindRuleExclude, objects ...map[string]any) *errors.LintRuleErrorsList { + t.Helper() + + mc := minimock.NewController(t) + + mod := mocks.NewModuleMock(mc) + mod.GetStorageMock.Return(schemaStorage(objects...)) + + errorList := errors.NewLintRuleErrorsList() + NewSchemaValidationRule(exclude, mod, errorList).Check(t.Context()) + + return errorList +} + +func TestSchemaValidationRule_ValidService(t *testing.T) { + errorList := runSchemaRule(t, nil, map[string]any{ + "apiVersion": "v1", + "kind": "Service", + "metadata": map[string]any{"name": "svc"}, + "spec": map[string]any{ + "ports": []any{map[string]any{"port": int64(80)}}, + }, + }) + + assert.False(t, errorList.ContainsErrors(), "valid Service should not produce errors") +} + +// TestSchemaValidationRule_UnknownFields covers the strict half of the decode: +// fields the API does not declare. Two of them in one object must come back as two +// findings, not one lump, so each names the field a reader has to go fix. +func TestSchemaValidationRule_UnknownFields(t *testing.T) { + errorList := runSchemaRule(t, nil, map[string]any{ + "apiVersion": "v1", + "kind": "Service", + "metadata": map[string]any{"name": "svc"}, + "spec": map[string]any{ + "ports": []any{map[string]any{"port": int64(80)}}, + "bogusField": true, + "anotherBogus": "x", + }, + }) + + errs := errorList.GetErrors() + assert.Len(t, errs, 2, "each undeclared field must be reported on its own") + + texts := make([]string, 0, len(errs)) + for _, e := range errs { + texts = append(texts, e.Text) + } + + assert.Contains(t, strings.Join(texts, "\n"), `unknown field "spec.bogusField"`) + assert.Contains(t, strings.Join(texts, "\n"), `unknown field "spec.anotherBogus"`) +} + +func TestSchemaValidationRule_InvalidService(t *testing.T) { + errorList := runSchemaRule(t, nil, map[string]any{ + "apiVersion": "v1", + "kind": "Service", + "metadata": map[string]any{"name": "svc"}, + "spec": map[string]any{ + "ports": []any{map[string]any{"port": "not-a-number"}}, + }, + }) + + assert.True(t, errorList.ContainsErrors(), "Service with string port should produce errors") +} + +// TestSchemaValidationRule_CustomResourceSkipped covers the rule's boundary: only +// standard Kubernetes resources are validated, so a custom resource passes through +// untouched however malformed it is. +func TestSchemaValidationRule_CustomResourceSkipped(t *testing.T) { + errorList := runSchemaRule(t, nil, map[string]any{ + "apiVersion": "cert-manager.io/v1", + "kind": "Certificate", + "metadata": map[string]any{"name": "c"}, + "spec": map[string]any{"secretName": "s", "dnsNames": int64(12345)}, + }, map[string]any{ + "apiVersion": "totally.unknown.io/v1", + "kind": "Nonexistent", + "metadata": map[string]any{"name": "x"}, + "spec": map[string]any{"whatever": true}, + }) + + assert.False(t, errorList.ContainsErrors(), "resources without a bundled schema must be skipped") +} + +func TestSchemaValidationRule_Excluded(t *testing.T) { + exclude := []pkg.KindRuleExclude{{Kind: "Service", Name: "svc"}} + + errorList := runSchemaRule(t, exclude, map[string]any{ + "apiVersion": "v1", + "kind": "Service", + "metadata": map[string]any{"name": "svc"}, + "spec": map[string]any{ + "ports": []any{map[string]any{"port": "not-a-number"}}, + }, + }) + + assert.False(t, errorList.ContainsErrors(), "excluded resource must not be validated") +} + +// TestSchemaValidationRule_BinaryContentNotJudged is the guard for a false positive +// that fired on real modules: a chart passing a value straight into Secret.data — +// the value being expected to arrive already base64-encoded — renders, under the +// values dmt generates from the openapi schema, a payload that is not valid base64. +// The complaint was about dmt's own invention, not about the module, and reads +// especially badly because the decoder cannot even say which field it meant +// ("illegal base64 data at input byte 4"). +func TestSchemaValidationRule_BinaryContentNotJudged(t *testing.T) { + errorList := runSchemaRule(t, nil, map[string]any{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]any{"name": "passthrough"}, + "type": "Opaque", + "data": map[string]any{"ca.crt": "not-base64-at-all!"}, + }) + + assert.Empty(t, errorList.GetErrors(), + "the content of a binary field is generated by dmt and must not be reported") +} + +// TestSchemaValidationRule_BinaryFieldStructureStillJudged is the other half: only +// the content is beyond dmt's reach. The shape around it is still the module's own, +// so a data map that is not a map, a value that is not a string, and a field the API +// does not declare all have to survive the pass that lets the base64 through. +func TestSchemaValidationRule_BinaryFieldStructureStillJudged(t *testing.T) { + for name, tc := range map[string]struct { + object map[string]any + want string + }{ + "data is not a map": { + object: map[string]any{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]any{"name": "s"}, + "data": int64(5), + }, + want: "cannot restore map", + }, + "data value is not a string": { + object: map[string]any{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]any{"name": "s"}, + "data": map[string]any{"key": int64(12345)}, + }, + want: "cannot restore slice", + }, + "undeclared field alongside a binary one": { + object: map[string]any{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]any{"name": "s"}, + "data": map[string]any{"key": "still-not-base64"}, + "immutableTypo": false, + }, + want: `unknown field "immutableTypo"`, + }, + } { + t.Run(name, func(t *testing.T) { + errs := runSchemaRule(t, nil, tc.object).GetErrors() + + require.Len(t, errs, 1) + assert.Contains(t, errs[0].Text, tc.want) + }) + } +} diff --git a/pkg/linters/templates/templates.go b/pkg/linters/templates/templates.go index dc5e0f660..285e30057 100644 --- a/pkg/linters/templates/templates.go +++ b/pkg/linters/templates/templates.go @@ -110,6 +110,7 @@ func (l *Templates) rules() []pkg.Rule { rules.NewMountPointsRule(cfg.ExcludeRules.MountPoints.Get(), m, level(cfg.Rules.MountPointsRule)), rules.NewHelmRenderRule(m, level(cfg.Rules.HelmRenderRule)), rules.NewOpenAPIValuesQuoteRule(cfg.ExcludeRules.OpenAPIValuesQuote.Get(), m, level(cfg.Rules.OpenAPIValuesQuoteRule)), + rules.NewSchemaValidationRule(cfg.ExcludeRules.SchemaValidation.Get(), m, level(cfg.Rules.SchemaValidationRule)), ) } diff --git a/pkg/scopes/static.go b/pkg/scopes/static.go index eab1af650..670aeb63b 100644 --- a/pkg/scopes/static.go +++ b/pkg/scopes/static.go @@ -144,6 +144,7 @@ var staticRules = map[string]set.Set{ // They share this ID, so a scope can only ask for both or neither. templatesrules.PrometheusRuleName, templatesrules.RegistryRuleName, + templatesrules.SchemaValidationRuleName, templatesrules.ServicePortRuleName, templatesrules.VPARuleName, templatesrules.WebhookConfigurationRuleName, diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index e036f4f7d..47521d7ae 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -90,7 +90,7 @@ func runCase(t *testing.T, caseDir string) { moduleDir := filepath.Join(caseDir, spec.Module) require.DirExists(t, moduleDir, "module dir %q must exist", moduleDir) - findings, err := Run(spec.Kind, moduleDir) + findings, err := Run(spec.Kind, moduleDir, spec.Matrix) require.NoError(t, err, "run case") result := Match(spec, findings) diff --git a/test/e2e/framework.go b/test/e2e/framework.go index 7dbd67676..dc580e77b 100644 --- a/test/e2e/framework.go +++ b/test/e2e/framework.go @@ -121,6 +121,10 @@ type CaseSpec struct { // Exhaustive, when true, asserts that there are no findings beyond those // listed in Expect (every produced finding must be matched by some Finding). Exhaustive bool `yaml:"exhaustive"` + // Matrix, when true, lints the module in matrix mode (renders every value + // combination), so conditionally-rendered resources are reached. Only + // applies to lint cases. + Matrix bool `yaml:"matrix"` } // LoadCaseSpec reads and parses the expected.yaml file from a case directory. @@ -148,14 +152,14 @@ func LoadCaseSpec(caseDir string) (*CaseSpec, error) { // Run executes a case (lint or conversions) against a module directory and // returns the produced findings. -func Run(kind, moduleDir string) ([]pkg.LinterError, error) { +func Run(kind, moduleDir string, matrix bool) ([]pkg.LinterError, error) { switch kind { case KindConversions: return RunConversions(moduleDir) case KindFix: return RunFix(moduleDir) case KindLint, "": - return Lint(moduleDir) + return Lint(moduleDir, matrix) default: return nil, fmt.Errorf("unknown case kind %q", kind) } @@ -165,7 +169,7 @@ func Run(kind, moduleDir string) ([]pkg.LinterError, error) { // findings. The module is copied into an isolated temp directory first so the // run is hermetic (no config inherited from parent dirs, no artifacts written // back into testdata). -func Lint(moduleDir string) ([]pkg.LinterError, error) { +func Lint(moduleDir string, matrix bool) ([]pkg.LinterError, error) { tmpRoot, err := os.MkdirTemp("", "dmt-e2e-*") if err != nil { return nil, fmt.Errorf("create temp dir: %w", err) @@ -191,7 +195,14 @@ func Lint(moduleDir string) ([]pkg.LinterError, error) { // Initialize the metrics client so linters that emit metrics don't panic. metrics.GetClient(target) - mng := manager.New(cfg, static.NewSource(target)) + // Matrix mode is passed per-case as a source option instead of via the + // process-global flag, so parallel cases with different settings don't race. + var opts []static.Option + if matrix { + opts = append(opts, static.WithMatrix(true, 0)) + } + + mng := manager.New(cfg, static.NewSource(target, opts...)) defer mng.Close() _ = mng.Run(context.Background()) diff --git a/test/e2e/testdata/templates/matrix-flag-off/expected.yaml b/test/e2e/testdata/templates/matrix-flag-off/expected.yaml new file mode 100644 index 000000000..3e7e97016 --- /dev/null +++ b/test/e2e/testdata/templates/matrix-flag-off/expected.yaml @@ -0,0 +1,9 @@ +description: > + The same module as matrix-flag-on, but linted WITHOUT --matrix. The guarded + Service is never rendered (enabled defaults to false), so the schema + violation is not reached. This documents why matrix mode is needed. +module: module +matrix: false +expectAbsent: + - linter: templates + rule: schema-validation diff --git a/test/e2e/testdata/templates/matrix-flag-off/module/module.yaml b/test/e2e/testdata/templates/matrix-flag-off/module/module.yaml new file mode 100644 index 000000000..f8198b335 --- /dev/null +++ b/test/e2e/testdata/templates/matrix-flag-off/module/module.yaml @@ -0,0 +1,2 @@ +name: matrix-flag +namespace: matrix-flag diff --git a/test/e2e/testdata/templates/matrix-flag-off/module/openapi/config-values.yaml b/test/e2e/testdata/templates/matrix-flag-off/module/openapi/config-values.yaml new file mode 100644 index 000000000..fc94be460 --- /dev/null +++ b/test/e2e/testdata/templates/matrix-flag-off/module/openapi/config-values.yaml @@ -0,0 +1,7 @@ +type: object +properties: + enabled: + type: boolean + # The default render picks the first example (false), so the guarded + # resource below is never produced without --matrix. + x-examples: [false, true] diff --git a/test/e2e/testdata/templates/matrix-flag-off/module/openapi/values.yaml b/test/e2e/testdata/templates/matrix-flag-off/module/openapi/values.yaml new file mode 100644 index 000000000..47180da56 --- /dev/null +++ b/test/e2e/testdata/templates/matrix-flag-off/module/openapi/values.yaml @@ -0,0 +1,4 @@ +x-extend: + schema: config-values.yaml +type: object +properties: {} diff --git a/test/e2e/testdata/templates/matrix-flag-off/module/templates/service.yaml b/test/e2e/testdata/templates/matrix-flag-off/module/templates/service.yaml new file mode 100644 index 000000000..9e42d5429 --- /dev/null +++ b/test/e2e/testdata/templates/matrix-flag-off/module/templates/service.yaml @@ -0,0 +1,17 @@ +{{- if .Values.matrixFlag.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: matrix-flag-svc + namespace: matrix-flag +spec: + selector: + app: matrix-flag + ports: + - name: http + port: 80 + targetPort: http + # Invalid: `bogusField` is not part of the Service schema. Only reachable + # when enabled=true, i.e. only under --matrix. + bogusField: true +{{- end }} diff --git a/test/e2e/testdata/templates/matrix-flag-on/expected.yaml b/test/e2e/testdata/templates/matrix-flag-on/expected.yaml new file mode 100644 index 000000000..7a21f79c9 --- /dev/null +++ b/test/e2e/testdata/templates/matrix-flag-on/expected.yaml @@ -0,0 +1,11 @@ +description: > + A Service guarded by a boolean flag (default false) carries a schema + violation. With --matrix, the enabled=true variant is rendered and the + templates schema-validation rule flags the undeclared field. +module: module +matrix: true +expect: + - linter: templates + rule: schema-validation + level: error + textContains: 'unknown field "spec.bogusField"' diff --git a/test/e2e/testdata/templates/matrix-flag-on/module/module.yaml b/test/e2e/testdata/templates/matrix-flag-on/module/module.yaml new file mode 100644 index 000000000..f8198b335 --- /dev/null +++ b/test/e2e/testdata/templates/matrix-flag-on/module/module.yaml @@ -0,0 +1,2 @@ +name: matrix-flag +namespace: matrix-flag diff --git a/test/e2e/testdata/templates/matrix-flag-on/module/openapi/config-values.yaml b/test/e2e/testdata/templates/matrix-flag-on/module/openapi/config-values.yaml new file mode 100644 index 000000000..fc94be460 --- /dev/null +++ b/test/e2e/testdata/templates/matrix-flag-on/module/openapi/config-values.yaml @@ -0,0 +1,7 @@ +type: object +properties: + enabled: + type: boolean + # The default render picks the first example (false), so the guarded + # resource below is never produced without --matrix. + x-examples: [false, true] diff --git a/test/e2e/testdata/templates/matrix-flag-on/module/openapi/values.yaml b/test/e2e/testdata/templates/matrix-flag-on/module/openapi/values.yaml new file mode 100644 index 000000000..47180da56 --- /dev/null +++ b/test/e2e/testdata/templates/matrix-flag-on/module/openapi/values.yaml @@ -0,0 +1,4 @@ +x-extend: + schema: config-values.yaml +type: object +properties: {} diff --git a/test/e2e/testdata/templates/matrix-flag-on/module/templates/service.yaml b/test/e2e/testdata/templates/matrix-flag-on/module/templates/service.yaml new file mode 100644 index 000000000..9e42d5429 --- /dev/null +++ b/test/e2e/testdata/templates/matrix-flag-on/module/templates/service.yaml @@ -0,0 +1,17 @@ +{{- if .Values.matrixFlag.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: matrix-flag-svc + namespace: matrix-flag +spec: + selector: + app: matrix-flag + ports: + - name: http + port: 80 + targetPort: http + # Invalid: `bogusField` is not part of the Service schema. Only reachable + # when enabled=true, i.e. only under --matrix. + bogusField: true +{{- end }} diff --git a/test/e2e/testdata/templates/matrix-mode-oneof/expected.yaml b/test/e2e/testdata/templates/matrix-mode-oneof/expected.yaml new file mode 100644 index 000000000..b2cf19de4 --- /dev/null +++ b/test/e2e/testdata/templates/matrix-mode-oneof/expected.yaml @@ -0,0 +1,11 @@ +description: > + A Service rendered only for the "Static" oneOf/enum branch carries a schema + violation. --matrix expands both the oneOf branches and the mode enum, reaches + the Static variant, and the schema-validation rule flags the undeclared field. +module: module +matrix: true +expect: + - linter: templates + rule: schema-validation + level: error + textContains: 'unknown field "spec.anotherBogusField"' diff --git a/test/e2e/testdata/templates/matrix-mode-oneof/module/module.yaml b/test/e2e/testdata/templates/matrix-mode-oneof/module/module.yaml new file mode 100644 index 000000000..c9681e187 --- /dev/null +++ b/test/e2e/testdata/templates/matrix-mode-oneof/module/module.yaml @@ -0,0 +1,2 @@ +name: matrix-mode +namespace: matrix-mode diff --git a/test/e2e/testdata/templates/matrix-mode-oneof/module/openapi/config-values.yaml b/test/e2e/testdata/templates/matrix-mode-oneof/module/openapi/config-values.yaml new file mode 100644 index 000000000..b178eac0b --- /dev/null +++ b/test/e2e/testdata/templates/matrix-mode-oneof/module/openapi/config-values.yaml @@ -0,0 +1,20 @@ +type: object +properties: + resourcesMode: + type: object + default: {} + # oneOf branches select the shape by `mode`. The default render picks the + # first enum value ("Balanced"); only --matrix expands the oneOf/enum and + # reaches the "Static" branch that renders the faulty resource. + oneOf: + - properties: + mode: + enum: ["Balanced"] + - properties: + mode: + enum: ["Static"] + properties: + mode: + type: string + enum: ["Balanced", "Static"] + default: "Balanced" diff --git a/test/e2e/testdata/templates/matrix-mode-oneof/module/openapi/values.yaml b/test/e2e/testdata/templates/matrix-mode-oneof/module/openapi/values.yaml new file mode 100644 index 000000000..47180da56 --- /dev/null +++ b/test/e2e/testdata/templates/matrix-mode-oneof/module/openapi/values.yaml @@ -0,0 +1,4 @@ +x-extend: + schema: config-values.yaml +type: object +properties: {} diff --git a/test/e2e/testdata/templates/matrix-mode-oneof/module/templates/service.yaml b/test/e2e/testdata/templates/matrix-mode-oneof/module/templates/service.yaml new file mode 100644 index 000000000..6ba5bf7f7 --- /dev/null +++ b/test/e2e/testdata/templates/matrix-mode-oneof/module/templates/service.yaml @@ -0,0 +1,16 @@ +{{- if eq .Values.matrixMode.resourcesMode.mode "Static" }} +apiVersion: v1 +kind: Service +metadata: + name: matrix-mode-svc + namespace: matrix-mode +spec: + selector: + app: matrix-mode + ports: + - name: http + port: 80 + targetPort: http + # Invalid: only rendered for the "Static" oneOf branch, which --matrix reaches. + anotherBogusField: true +{{- end }} diff --git a/test/e2e/testdata/templates/schema-validation-deployment/expected.yaml b/test/e2e/testdata/templates/schema-validation-deployment/expected.yaml new file mode 100644 index 000000000..4f22e9a3b --- /dev/null +++ b/test/e2e/testdata/templates/schema-validation-deployment/expected.yaml @@ -0,0 +1,10 @@ +description: > + A rendered Deployment whose spec.replicas is a string instead of an integer + must be flagged by the templates schema-validation rule, which decodes every + standard resource into its Kubernetes Go type. +module: module +expect: + - linter: templates + rule: schema-validation + level: error + textContains: "does not match the Deployment API" diff --git a/test/e2e/testdata/templates/schema-validation-deployment/module/module.yaml b/test/e2e/testdata/templates/schema-validation-deployment/module/module.yaml new file mode 100644 index 000000000..b1a2a1de7 --- /dev/null +++ b/test/e2e/testdata/templates/schema-validation-deployment/module/module.yaml @@ -0,0 +1,2 @@ +name: e2e-schema-deployment +namespace: e2e-schema-deployment diff --git a/test/e2e/testdata/templates/schema-validation-deployment/module/openapi/config-values.yaml b/test/e2e/testdata/templates/schema-validation-deployment/module/openapi/config-values.yaml new file mode 100644 index 000000000..03b0d8bfe --- /dev/null +++ b/test/e2e/testdata/templates/schema-validation-deployment/module/openapi/config-values.yaml @@ -0,0 +1,2 @@ +type: object +properties: {} diff --git a/test/e2e/testdata/templates/schema-validation-deployment/module/openapi/values.yaml b/test/e2e/testdata/templates/schema-validation-deployment/module/openapi/values.yaml new file mode 100644 index 000000000..47180da56 --- /dev/null +++ b/test/e2e/testdata/templates/schema-validation-deployment/module/openapi/values.yaml @@ -0,0 +1,4 @@ +x-extend: + schema: config-values.yaml +type: object +properties: {} diff --git a/test/e2e/testdata/templates/schema-validation-deployment/module/templates/deployment.yaml b/test/e2e/testdata/templates/schema-validation-deployment/module/templates/deployment.yaml new file mode 100644 index 000000000..22cb1b5b9 --- /dev/null +++ b/test/e2e/testdata/templates/schema-validation-deployment/module/templates/deployment.yaml @@ -0,0 +1,20 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: e2e-deploy + namespace: e2e-schema-deployment +spec: + # replicas must be an integer; a string value is a schema violation that the + # built-in Kubernetes Deployment schema catches. + replicas: "three" + selector: + matchLabels: + app: e2e-app + template: + metadata: + labels: + app: e2e-app + spec: + containers: + - name: main + image: e2e-app:latest diff --git a/test/e2e/testdata/templates/schema-validation-resources-field/expected.yaml b/test/e2e/testdata/templates/schema-validation-resources-field/expected.yaml new file mode 100644 index 000000000..e54ad542a --- /dev/null +++ b/test/e2e/testdata/templates/schema-validation-resources-field/expected.yaml @@ -0,0 +1,11 @@ +description: > + A rendered DaemonSet with a container whose resources block sets `memory` + directly (instead of resources.limits/requests.memory) must be flagged by the + templates schema-validation rule. This is the same undeclared-field error that + Kubernetes server-side apply reports as "field not declared in schema". +module: module +expect: + - linter: templates + rule: schema-validation + level: error + textContains: 'unknown field "spec.template.spec.containers[0].resources.memory"' diff --git a/test/e2e/testdata/templates/schema-validation-resources-field/module/module.yaml b/test/e2e/testdata/templates/schema-validation-resources-field/module/module.yaml new file mode 100644 index 000000000..5f6210e6d --- /dev/null +++ b/test/e2e/testdata/templates/schema-validation-resources-field/module/module.yaml @@ -0,0 +1,2 @@ +name: e2e-schema-resources-field +namespace: e2e-schema-resources-field diff --git a/test/e2e/testdata/templates/schema-validation-resources-field/module/openapi/config-values.yaml b/test/e2e/testdata/templates/schema-validation-resources-field/module/openapi/config-values.yaml new file mode 100644 index 000000000..03b0d8bfe --- /dev/null +++ b/test/e2e/testdata/templates/schema-validation-resources-field/module/openapi/config-values.yaml @@ -0,0 +1,2 @@ +type: object +properties: {} diff --git a/test/e2e/testdata/templates/schema-validation-resources-field/module/openapi/values.yaml b/test/e2e/testdata/templates/schema-validation-resources-field/module/openapi/values.yaml new file mode 100644 index 000000000..47180da56 --- /dev/null +++ b/test/e2e/testdata/templates/schema-validation-resources-field/module/openapi/values.yaml @@ -0,0 +1,4 @@ +x-extend: + schema: config-values.yaml +type: object +properties: {} diff --git a/test/e2e/testdata/templates/schema-validation-resources-field/module/templates/daemonset.yaml b/test/e2e/testdata/templates/schema-validation-resources-field/module/templates/daemonset.yaml new file mode 100644 index 000000000..d255f9207 --- /dev/null +++ b/test/e2e/testdata/templates/schema-validation-resources-field/module/templates/daemonset.yaml @@ -0,0 +1,23 @@ +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: log-shipper-agent + namespace: d8-log-shipper +spec: + selector: + matchLabels: + app: log-shipper + template: + metadata: + labels: + app: log-shipper + spec: + containers: + - name: vector + image: vector:latest + resources: + # Wrong: memory must live under resources.limits / resources.requests, + # not directly under resources. Kubernetes server-side apply rejects + # this as "field not declared in schema"; schema-validation catches it + # at lint time as an undeclared property. + memory: "100Mi"