Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/modules/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ func mapModuleRules(linterSettings *pkg.LintersSettings, configSettings *config.
rules.EnabledScriptRule.SetLevel(globalRules.EnabledScriptRule.Impact, fallbackImpact)
rules.ReleaseLayoutRule.SetLevel(globalRules.ReleaseLayoutRule.Impact, fallbackImpact)
rules.BundleLayoutRule.SetLevel(globalRules.BundleLayoutRule.Impact, fallbackImpact)
rules.HelmignoreCoverageRule.SetLevel(globalRules.HelmignoreCoverageRule.Impact, fallbackImpact)
}

// mapTemplatesRules configures Templates linter rules
Expand Down
1 change: 1 addition & 0 deletions pkg/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ type ModuleLinterRules struct {
EnabledScriptRule RuleConfig
ReleaseLayoutRule RuleConfig
BundleLayoutRule RuleConfig
HelmignoreCoverageRule RuleConfig
}
type OSSRuleSettings struct {
Disable bool
Expand Down
1 change: 1 addition & 0 deletions pkg/config/global/global.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ type ModuleLinterRules struct {
EnabledScriptRule RuleConfig `mapstructure:"enabled-script"`
ReleaseLayoutRule RuleConfig `mapstructure:"release-layout"`
BundleLayoutRule RuleConfig `mapstructure:"bundle-layout"`
HelmignoreCoverageRule RuleConfig `mapstructure:"helmignore-coverage"`
}

type TemplatesLinterConfig struct {
Expand Down
22 changes: 21 additions & 1 deletion pkg/linters/module/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ The Module linter performs automated checks on Deckhouse modules to validate con

## Rules

The Module linter includes **9 validation rules**:
The Module linter includes the following validation rules:

| Rule | Description | Configurable |
|------|-------------|--------------|
| [**definition-file**](#definition-file) | Validates `module.yaml` structure, accessibility, and update sections | βœ… Yes |
| [**oss**](#oss) | Validates open-source software attribution in `oss.yaml` | βœ… Yes |
| [**conversions**](#conversions) | Validates OpenAPI conversion files and documentation | βœ… Yes |
| [**helmignore**](#helmignore) | Validates `.helmignore` file presence and content | βœ… Yes |
| [**helmignore-coverage**](#helmignore-coverage) | Reports bundle-image files no `.helmignore` pattern excludes | βœ… Yes |
| [**license**](#license) | Validates license headers in source files | βœ… Yes |
| [**requirements**](#requirements) | Validates version requirements for features | ❌ No |
| [**package-yaml**](#package-yaml) | Validates `package.yaml` metadata and new requirements schema | βœ… Yes |
Expand Down Expand Up @@ -298,6 +299,25 @@ openapi/
# Chart.yaml
```

**Scope:** `static` only. Whether the patterns cover what the module actually ships is
checked by [helmignore-coverage](#helmignore-coverage) against the built image.

---

### Helmignore-coverage

Reports entries in the bundle image root that no `.helmignore` pattern excludes β€” files Helm would therefore pull into the chart.

**Purpose:** an uncovered non-chart file bloats every chart Helm packs from the module. This is the same check the `helmignore` rule used to run over the source tree, moved to the image: CI writes scratch files into a checkout, and each one read as an uncovered entry. The image holds only what werf's `includePaths` let through, so what it carries is what the module actually ships.

**Checks:**
- βœ… Every package-root entry is matched by a `.helmignore` pattern
- βœ… Chart material needs no pattern and is exempt β€” `templates/`, `charts/`, `monitoring/`, `Chart.yaml`, `values.yaml`, plus the build-generated `images_digests.json`, which exists in no source tree

Findings are reported at `warn`. Only the package root is walked, and only patterns the module wrote itself apply.

**Scope:** `bundle` only. It needs a packed tree; running it over a source tree would report the scratch files CI writes and the build never ships.

---

### License
Expand Down
1 change: 1 addition & 0 deletions pkg/linters/module/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ func (l *Module) rules() []pkg.Rule {
rules.NewEnabledScriptRule(m, level(cfg.Rules.EnabledScriptRule)),
rules.NewReleaseLayoutRule(m, level(cfg.Rules.ReleaseLayoutRule)),
rules.NewBundleLayoutRule(m, level(cfg.Rules.BundleLayoutRule)),
rules.NewHelmignoreCoverageRule(m, level(cfg.Rules.HelmignoreCoverageRule)),
}
}

Expand Down
85 changes: 2 additions & 83 deletions pkg/linters/module/rules/helmignore.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import (
"path/filepath"
"strings"

"helm.sh/helm/v3/pkg/ignore"
"k8s.io/utils/ptr"

"github.com/deckhouse/dmt/pkg"
Expand All @@ -41,19 +40,8 @@ const (
helmChartYaml = "Chart.yaml"
)

// moduleTemplateExclude is the set of files and directories that belong to
// the Deckhouse module and therefore should NOT be listed in .helmignore.
// These are either required by Helm for rendering or read by Deckhouse
// directly from the module filesystem.
var moduleTemplateExclude = map[string]bool{
// Required by Helm for chart rendering
"templates": true,
"charts": true,
"monitoring": true,
"Chart.yaml": true,
"values.yaml": true,
}

// HelmignoreRule validates the .helmignore file itself: that it exists, says
// something, and that its patterns are well formed.
func NewHelmignoreRule(disable bool,
m pkg.Module, errorList *errors.LintRuleErrorsList) *HelmignoreRule {
return &HelmignoreRule{
Expand Down Expand Up @@ -134,75 +122,6 @@ func (r *HelmignoreRule) Check(_ context.Context) {

// Validate patterns
validatePatterns(lines, errorList)

// Validate that all module root files/dirs (except module-template entries)
// are covered by .helmignore patterns.
r.checkModuleRootCoverage(modulePath, raw, errorList)
}

// checkModuleRootCoverage scans the module root for all files and directories
// and verifies that everything except the standard module-template entries
// (templates/, charts/, Chart.yaml, values.yaml) is covered by a pattern in
// .helmignore. Helm's own ignore.Rules are used for proper pattern matching
// (wildcards, negation, directory-only rules, etc.).
func (r *HelmignoreRule) checkModuleRootCoverage(modulePath string, raw []byte, errorList *errors.LintRuleErrorsList) {
entries, err := os.ReadDir(modulePath)
if err != nil {
errorList.WithFilePath(helmignoreFile).
Errorf("Cannot read module directory: %s", err)

return
}

// Parse .helmignore using Helm's own rules engine.
rules, err := ignore.Parse(bytes.NewReader(raw))
if err != nil {
errorList.WithFilePath(helmignoreFile).
Errorf("Cannot parse .helmignore: %s", err)

return
}

rules.AddDefaults()

for _, entry := range entries {
name := entry.Name()

// Skip .helmignore itself.
if name == helmignoreFile {
continue
}

// Skip entries that are part of the standard module template and
// should NOT be ignored by Helm.
if moduleTemplateExclude[name] {
continue
}

info, err := entry.Info()
if err != nil {
errorList.WithFilePath(helmignoreFile).
Errorf("Cannot stat '%s': %s", name, err)

continue
}

// Use Helm's ignore rules: if the entry is NOT ignored, it would be
// included in the Helm chart β€” which we don't want for non-template
// files/dirs.
if rules.Ignore(name, info) {
continue
}

entryType := "File"
if entry.IsDir() {
entryType = "Directory"
name += "/"
}

errorList.WithFilePath(helmignoreFile).
Warnf("%s '%s' is not listed in .helmignore", entryType, name)
}
}

func validatePatterns(patterns []string, errorList *errors.LintRuleErrorsList) {
Expand Down
134 changes: 134 additions & 0 deletions pkg/linters/module/rules/helmignore_coverage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/*
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 (
"bytes"
"context"
"os"
"path/filepath"

"helm.sh/helm/v3/pkg/ignore"

"github.com/deckhouse/dmt/pkg"
"github.com/deckhouse/dmt/pkg/errors"
)

const HelmignoreCoverageRuleName = "helmignore-coverage"

// chartMaterial is the set of package-root entries Helm needs in the chart, so
// .helmignore must NOT exclude them and their being uncovered is not a finding.
var chartMaterial = map[string]bool{
"templates": true,
"charts": true,
"monitoring": true,
"Chart.yaml": true,
"values.yaml": true,

// Generated by the build and imported straight into the image.
"images_digests.json": true,
}

// CoverageRule reports package-root entries the bundle image carries that no .helmignore
// pattern excludes β€” files Helm would therefore pull into the chart.
type CoverageRule struct {
pkg.RuleMeta

module pkg.Module
errorList *errors.LintRuleErrorsList
}

var _ pkg.Rule = (*CoverageRule)(nil)

func NewHelmignoreCoverageRule(m pkg.Module, errorList *errors.LintRuleErrorsList) *CoverageRule {
return &CoverageRule{
RuleMeta: pkg.RuleMeta{Name: HelmignoreCoverageRuleName},
module: m,
errorList: errorList.WithRule(HelmignoreCoverageRuleName),
}
}

func (r *CoverageRule) Check(_ context.Context) {
root := r.module.GetPath()
if root == "" {
return
}

raw, err := os.ReadFile(filepath.Join(root, helmignoreFile))
if err != nil {
// A missing .helmignore leaves nothing to compare the tree against. Its absence
// is bundle-layout's finding to report, not a second copy of it here.
if os.IsNotExist(err) {
return
}

r.errorList.WithFilePath(helmignoreFile).
Errorf("Cannot read .helmignore file: %s", err)

return
}

rules, err := ignore.Parse(bytes.NewReader(raw))
if err != nil {
r.errorList.WithFilePath(helmignoreFile).
Errorf("Cannot parse .helmignore: %s", err)

return
}

entries, err := os.ReadDir(root)
if err != nil {
r.errorList.WithFilePath(helmignoreFile).
Errorf("Cannot read package root: %s", err)

return
}

for _, entry := range entries {
r.checkEntry(rules, entry)
}
}

// checkEntry reports one package-root entry that no pattern excludes.
func (r *CoverageRule) checkEntry(rules *ignore.Rules, entry os.DirEntry) {
name := entry.Name()

if name == helmignoreFile || chartMaterial[name] {
return
}

info, err := entry.Info()
if err != nil {
r.errorList.WithFilePath(name).
Errorf("Cannot stat '%s': %s", name, err)

return
}

if rules.Ignore(name, info) {
return
}

kind := "File"
if entry.IsDir() {
kind = "Directory"
name += "/"
}

r.errorList.WithFilePath(entry.Name()).
Warnf("%s '%s' is present in the bundle image and is not listed in .helmignore", kind, name)
}
Loading
Loading