Skip to content

Commit

Permalink
HIP-0019 adds .helmlintignore capability
Browse files Browse the repository at this point in the history
See HIP-0019 proposal at helm/community: helm/community#353

Co-authored-by: Danilo Patrucco <[email protected]>

Signed-off-by: Daniel J. Pritchett <[email protected]>
  • Loading branch information
dpritchett committed Aug 15, 2024
1 parent c86e0d3 commit c64d08e
Show file tree
Hide file tree
Showing 6 changed files with 576 additions and 0 deletions.
24 changes: 24 additions & 0 deletions pkg/lint/ignore/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

//Package ignore
/*
Package ignore contains tools for linting charts.
Linting is the process of testing charts for errors or warnings regarding
formatting, compilation, or standards compliance.
*/
package ignore // import "helm.sh/helm/v3/pkg/lint/ignore"
89 changes: 89 additions & 0 deletions pkg/lint/ignore/ignorer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package ignore

import (
"helm.sh/helm/v3/pkg/lint/support"
"log/slog"
"path/filepath"
"strings"
)

type Ignorer struct {
ChartPath string
Rules []Rule
logger *slog.Logger
RuleLoader *RuleLoader
}

type PathlessRule struct {
RuleText string
MessageText string
}

// Ignorer is used to create the ignorer object that contains the ignore rules
func NewActionIgnorer(chartPath string, lintIgnorePath string, debugLogFn func(string, ...interface{})) (*Ignorer, error) {
cmdIgnorer, err := NewRuleLoader(chartPath, lintIgnorePath, debugLogFn)
if err != nil {
return nil, err
}

return &Ignorer{ChartPath: chartPath, RuleLoader: cmdIgnorer}, nil
}

// FilterMessages Verify what messages can be kept in the output, using also the error as a verification (calling ShouldKeepError)
func (i *Ignorer) FilterMessages(messages []support.Message) []support.Message {
out := make([]support.Message, 0, len(messages))
for _, msg := range messages {
if i.ShouldKeepError(msg.Err) {
out = append(out, msg)
}
}
return out
}

// ShouldKeepError is used to verify if the error associated with the message need to be kept, or it can be ignored, called by FilterMessages and in the pkg/action/lint.go Run main function
func (i *Ignorer) ShouldKeepError(err error) bool {
errText := err.Error()

// if any of our Matchers match the rule, we can discard it
for _, rule := range i.RuleLoader.Matchers {
match := rule.Match(errText)
if match != nil {
i.RuleLoader.Debug("lint ignore rule matched", match.LogAttrs())
return false
}
}

// if we can't find a reason to discard it, we keep it
return true
}

type MatchesErrors interface {
Match(string) *RuleMatch
}

type RuleMatch struct {
ErrText string
RuleText string
}

func (rm RuleMatch) LogAttrs() slog.Attr {
return slog.Group("rule_match", slog.String("err_text", rm.ErrText), slog.String("rule_text", rm.RuleText))
}

// Match errors that have no file path in their body with ignorer rules.
// An examples of errors with no file path in their body is chart metadata errors `chart metadata is missing these dependencies`
func (pr PathlessRule) Match(errText string) *RuleMatch {
ignorableError := pr.MessageText
parts := strings.SplitN(ignorableError, ":", 2)
prefix := strings.TrimSpace(parts[0])

if match, _ := filepath.Match(ignorableError, errText); match {
return &RuleMatch{ErrText: errText, RuleText: pr.RuleText}
}

if matched, _ := filepath.Match(prefix, errText); matched {
return &RuleMatch{ErrText: errText, RuleText: pr.RuleText}
}

return nil
}
64 changes: 64 additions & 0 deletions pkg/lint/ignore/rule.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package ignore

import (
"fmt"
"log/slog"
"path/filepath"
"strings"
)

type Rule struct {
RuleText string
MessagePath string
MessageText string
}

type LintedMessage struct {
ChartPath string
MessagePath string
MessageText string
}

func NewRule(ruleText string) *Rule {
return &Rule{RuleText: ruleText}
}

// ShouldKeepLintedMessage Function used to test the test data in rule_test.go and verify that the ignore capability work as needed
func (r Rule) ShouldKeepLintedMessage(msg LintedMessage) bool {
cmdIgnorer := RuleLoader{}
rdr := strings.NewReader(r.RuleText)
cmdIgnorer.LoadFromReader(rdr)

actionIgnorer := Ignorer{RuleLoader: &cmdIgnorer}
return actionIgnorer.ShouldKeepError(fmt.Errorf(msg.MessageText))
}

// LogAttrs Used for troubleshooting and gathering data
func (r Rule) LogAttrs() slog.Attr {
return slog.Group("Rule",
slog.String("rule_text", r.RuleText),
slog.String("key", r.MessagePath),
slog.String("value", r.MessageText),
)
}

// Match errors that have a file path in their body with ignorer rules.
// Ignorer rules are built from the lint ignore file
func (r Rule) Match(errText string) *RuleMatch {
errorFullPath, err := extractFullPathFromError(errText)
if err != nil {
return nil
}

ignorablePath := r.MessagePath
ignorableText := r.MessageText
cleanIgnorablePath := filepath.Clean(ignorablePath)

if strings.Contains(errorFullPath, cleanIgnorablePath) {
if strings.Contains(errText, ignorableText) {
return &RuleMatch{ErrText: errText, RuleText: r.RuleText}
}
}

return nil
}
146 changes: 146 additions & 0 deletions pkg/lint/ignore/rule_loader.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package ignore

import (
"bufio"
"fmt"
"io"
"log"
"log/slog"
"os"
"path/filepath"
"strings"
)

// RuleLoader provides a means of suppressing unwanted helm lint errors and messages
// by comparing them to an ignore list provided in a plaintext helm lint ignore file.
type RuleLoader struct {
Matchers []MatchesErrors
debugFnOverride func(string, ...interface{})
}

func (i *RuleLoader) LogAttrs() slog.Attr {
return slog.Group("RuleLoader",
slog.String("Matchers", fmt.Sprintf("%v", i.Matchers)),
)
}

// DefaultIgnoreFileName is the name of the lint ignore file
// an RuleLoader will seek out at load/parse time.
const DefaultIgnoreFileName = ".helmlintignore"

const NoMessageText = ""

// NewRuleLoader builds an RuleLoader object that enables helm to discard specific lint result Messages
// and Errors should they match the ignore rules in the specified .helmlintignore file.
func NewRuleLoader(chartPath, ignoreFilePath string, debugLogFn func(string, ...interface{})) (*RuleLoader, error) {
out := &RuleLoader{
debugFnOverride: debugLogFn,
}

if ignoreFilePath == "" {
ignoreFilePath = filepath.Join(chartPath, DefaultIgnoreFileName)
out.Debug("\nNo HelmLintIgnore file specified, will try and use the following: %s\n", ignoreFilePath)
}

// attempt to load ignore patterns from ignoreFilePath.
// if none are found, return an empty ignorer so the program can keep running.
out.Debug("\nUsing ignore file: %s\n", ignoreFilePath)
file, err := os.Open(ignoreFilePath)
if err != nil {
out.Debug("failed to open lint ignore file: %s", ignoreFilePath)
return out, nil
}
defer file.Close()

out.LoadFromReader(file)
out.Debug("RuleLoader loaded.", out.LogAttrs())
return out, nil
}

// Debug provides an RuleLoader with a caller-overridable logging function
// intended to match the behavior of the top level debug() method from package main.
//
// When no i.debugFnOverride is present Debug will fall back to a naive
// implementation that assumes all debug output should be logged and not swallowed.
func (i *RuleLoader) Debug(format string, args ...interface{}) {
if i.debugFnOverride == nil {
i.debugFnOverride = func(format string, v ...interface{}) {
format = fmt.Sprintf("[debug] %s\n", format)
log.Output(2, fmt.Sprintf(format, v...))
}
}

i.debugFnOverride(format, args...)
}

// TODO: figure out & fix or remove
func extractFullPathFromError(errText string) (string, error) {
delimiter := ":"
// splits into N parts delimited by colons
parts := strings.Split(errText, delimiter)
// if 3 or more parts, return the second part, after trimming its spaces
if len(parts) > 2 {
return strings.TrimSpace(parts[1]), nil
}
// if fewer than 3 parts, return empty string
return "", fmt.Errorf("fewer than three [%s]-delimited parts found, no path here: %s", delimiter, errText)
}

func (i *RuleLoader) LoadFromReader(rdr io.Reader) {
const pathlessPatternPrefix = "error_lint_ignore="
scanner := bufio.NewScanner(rdr)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}

isPathlessPattern := strings.HasPrefix(line, pathlessPatternPrefix)

if isPathlessPattern {
i.storePathlessPattern(line, pathlessPatternPrefix)
} else {
i.storePathfulPattern(line)
}
}
}

func (i *RuleLoader) storePathlessPattern(line string, pathlessPatternPrefix string) {
// handle chart-level errors
// Drop 'error_lint_ignore=' prefix from rule before saving it
const numSplits = 2
tokens := strings.SplitN(line[len(pathlessPatternPrefix):], pathlessPatternPrefix, numSplits)
if len(tokens) == numSplits {
// TODO: find an example for this one - not sure we still use it
messageText, _ := tokens[0], tokens[1]
i.Matchers = append(i.Matchers, PathlessRule{RuleText: line, MessageText: messageText})
} else {
messageText := tokens[0]
i.Matchers = append(i.Matchers, PathlessRule{RuleText: line, MessageText: messageText})
}
}

func (i *RuleLoader) storePathfulPattern(line string) {
const separator = " "
const numSplits = 2

// handle chart yaml file errors in specific template files
parts := strings.SplitN(line, separator, numSplits)
if len(parts) == numSplits {
messagePath, messageText := parts[0], parts[1]
i.Matchers = append(i.Matchers, Rule{RuleText: line, MessagePath: messagePath, MessageText: messageText})
} else {
messagePath := parts[0]
i.Matchers = append(i.Matchers, Rule{RuleText: line, MessagePath: messagePath, MessageText: NoMessageText})
}
}

func (i *RuleLoader) loadFromFilePath(filePath string) {
file, err := os.Open(filePath)
if err != nil {
i.Debug("failed to open lint ignore file: %s", filePath)
return
}
defer file.Close()
i.LoadFromReader(file)
}
30 changes: 30 additions & 0 deletions pkg/lint/ignore/rule_loader_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package ignore

import (
"fmt"
"github.com/stretchr/testify/assert"
"path/filepath"
"testing"
)

func TestNewIgnorer(t *testing.T) {
chartPath := "../rules/testdata/withsubchartlintignore"
ignoreFilePath := filepath.Join(chartPath, ".helmlintignore")
ignorer, err := NewRuleLoader(chartPath, ignoreFilePath, func(format string, args ...interface{}) {
t.Logf(format, args...)
})
assert.NoError(t, err)
assert.NotNil(t, ignorer, "RuleLoader should not be nil")
}

func TestDebug(t *testing.T) {
var captured string
debugFn := func(format string, args ...interface{}) {
captured = fmt.Sprintf(format, args...)
}
ignorer := &RuleLoader{
debugFnOverride: debugFn,
}
ignorer.Debug("test %s", "debug")
assert.Equal(t, "test debug", captured)
}
Loading

0 comments on commit c64d08e

Please sign in to comment.