Skip to content
Draft
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 go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ require (
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/spf13/afero v1.14.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/texttheater/golang-levenshtein/levenshtein v0.0.0-20200805054039-cae8b0eaed6c
github.com/u-root/u-root v0.14.0 // indirect
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect
github.com/vbatts/tar-split v0.12.1 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/texttheater/golang-levenshtein/levenshtein v0.0.0-20200805054039-cae8b0eaed6c h1:HelZ2kAFadG0La9d+4htN4HzQ68Bm2iM9qKMSMES6xg=
github.com/texttheater/golang-levenshtein/levenshtein v0.0.0-20200805054039-cae8b0eaed6c/go.mod h1:JlzghshsemAMDGZLytTFY8C1JQxQPhnatWqNwUXjggo=
github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg=
github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
Expand Down
1 change: 1 addition & 0 deletions pkg/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ func New() *cobra.Command {
cmd.AddCommand(keygen())
cmd.AddCommand(licenseCheck())
cmd.AddCommand(lint())
cmd.AddCommand(lintConfigCmd())
cmd.AddCommand(packageVersion())
cmd.AddCommand(query())
cmd.AddCommand(scan())
Expand Down
80 changes: 80 additions & 0 deletions pkg/cli/lint_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package cli

import (
"context"
"errors"
"strings"

"github.com/spf13/cobra"

"chainguard.dev/melange/pkg/configlint"
)

type lintConfigOptions struct {
args []string
list bool
skipRules []string
severity string
}

func lintConfigCmd() *cobra.Command {
o := &lintConfigOptions{}
cmd := &cobra.Command{
Use: "lint-config",
DisableAutoGenTag: true,
SilenceUsage: true,
SilenceErrors: true,
Short: "Lint melange configuration files",
RunE: func(cmd *cobra.Command, args []string) error {
o.args = args
return o.lint(cmd.Context())
},
}
cmd.Flags().BoolVarP(&o.list, "list", "l", false, "prints all available rules and exits")
cmd.Flags().StringArrayVarP(&o.skipRules, "skip-rule", "", []string{}, "list of rules to skip")
cmd.Flags().StringVarP(&o.severity, "severity", "s", "warning", "minimum severity level to report (error, warning, info)")
return cmd
}

func (o lintConfigOptions) lint(ctx context.Context) error {
l := configlint.New(o.makeOptions()...)

if o.list {
l.PrintRules(ctx)
return nil
}

minSeverity := configlint.SeverityWarning
switch strings.ToLower(o.severity) {
case "error":
minSeverity = configlint.SeverityError
case "info":
minSeverity = configlint.SeverityInfo
}

result, err := l.Lint(ctx, minSeverity)
if err != nil {
return err
}
if result.HasErrors() {
l.Print(ctx, result)
for _, res := range result {
for _, e := range res.Errors {
if e.Rule.Severity.Value == configlint.SeverityErrorLevel {
return errors.New("linting failed")
}
}
}
}
return nil
}

func (o lintConfigOptions) makeOptions() []configlint.Option {
if len(o.args) == 0 {
o.args = []string{"."}
}
return []configlint.Option{
configlint.WithPath(o.args[0]),
configlint.WithSkipRules(o.skipRules),
}
}
111 changes: 111 additions & 0 deletions pkg/configlint/linter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package configlint

import (
"context"
"fmt"
"sort"

"golang.org/x/exp/slices"
"golang.org/x/text/cases"
"golang.org/x/text/language"

"github.com/chainguard-dev/clog"
)

// Linter represents a linter instance.
type Linter struct {
options Options
}

// New initializes a new instance of Linter.
func New(opts ...Option) *Linter {
o := Options{}
for _, opt := range opts {
opt(&o)
}
return &Linter{options: o}
}

// Lint evaluates all rules and returns the result.
func (l *Linter) Lint(ctx context.Context, minSeverity Severity) (Result, error) {
log := clog.FromContext(ctx)
rules := AllRules(l)

namesToPkg, err := ReadAllPackagesFromRepo(ctx, l.options.Path)
if err != nil {
return Result{}, err
}

// reset host tracking for each run
seenHosts = map[string]bool{}

results := make(Result, 0)

sortedNames := make([]string, 0, len(namesToPkg))
for n := range namesToPkg {
sortedNames = append(sortedNames, n)
}
sort.Strings(sortedNames)

for _, name := range sortedNames {
failedRules := make(EvalRuleErrors, 0)
for _, rule := range rules {
shouldEvaluate := true
if len(rule.ConditionFuncs) > 0 {
for _, cond := range rule.ConditionFuncs {
if !cond() {
shouldEvaluate = false
break
}
}
}
if !shouldEvaluate {
log.Debugf("%s: skipping rule %s because condition is not met\n", name, rule.Name)
continue
}
if slices.Contains(l.options.SkipRules, rule.Name) {
log.Debugf("%s: skipping rule %s because --skip-rule flag set\n", name, rule.Name)
continue
}
if slices.Contains(namesToPkg[name].NoLint, rule.Name) {
log.Debugf("%s: skipping rule %s because file contains #nolint:%s\n", name, rule.Name, rule.Name)
continue
}
if err := rule.LintFunc(namesToPkg[name].Config); err != nil {
if rule.Severity.Value <= minSeverity.Value {
msg := fmt.Sprintf("[%s]: %s (%s)", rule.Name, err.Error(), rule.Severity.Name)
failedRules = append(failedRules, EvalRuleError{Rule: rule, Error: fmt.Errorf("%s", msg)})
}
}
}
if failedRules.WrapErrors() != nil {
results = append(results, EvalResult{File: name, Errors: failedRules})
}
}

return results, nil
}

// Print prints lint results.
func (l *Linter) Print(ctx context.Context, result Result) {
log := clog.FromContext(ctx)
foundAny := false
for _, res := range result {
if res.Errors.WrapErrors() != nil {
foundAny = true
log.Errorf("Package: %s: %s", res.File, res.Errors.WrapErrors())
}
}
if !foundAny {
log.Infof("No linting issues found!")
}
}

// PrintRules prints the rules to stdout.
func (l *Linter) PrintRules(ctx context.Context) {
log := clog.FromContext(ctx)
log.Info("Available rules:")
for _, rule := range AllRules(l) {
log.Infof("* %s: %s\n", rule.Name, cases.Title(language.Und).String(rule.Description))
}
}
23 changes: 23 additions & 0 deletions pkg/configlint/options.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package configlint

// Options represents the options to configure the linter.
type Options struct {
// Path is the path to the file or directory to lint
Path string

// SkipRules removes the given slice of rules from evaluation
SkipRules []string
}

// Option represents a linter option.
type Option func(*Options)

// WithPath sets the path to the file or directory to lint.
func WithPath(path string) Option {
return func(o *Options) { o.Path = path }
}

// WithSkipRules sets the skip rules option.
func WithSkipRules(skipRules []string) Option {
return func(o *Options) { o.SkipRules = skipRules }
}
129 changes: 129 additions & 0 deletions pkg/configlint/repo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package configlint

import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"

"gopkg.in/yaml.v3"

"chainguard.dev/melange/pkg/config"
)

const yamlExtension = ".yaml"

// Packages represents a Melange package configuration loaded from disk.
type Packages struct {
Config config.Configuration
Filename string
Dir string
NoLint []string
Hash string
}

type configCheck struct {
Package struct {
Name string `yaml:"name"`
Version string `yaml:"version"`
} `yaml:"package"`
}

func (c configCheck) isMelangeConfig() bool {
if c.Package.Name == "" {
return false
}
if c.Package.Version == "" {
return false
}
return true
}

// findNoLint reads the given file and returns any #nolint directives.
func findNoLint(filename string) ([]string, error) {
b, err := os.ReadFile(filename)
if err != nil {
return nil, err
}
lines := strings.Split(string(b), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "#nolint:") {
return strings.Split(strings.TrimPrefix(line, "#nolint:"), ","), nil
}
}
return nil, nil
}

// ReadAllPackagesFromRepo walks the provided directory and returns all Melange
// package configurations it finds keyed by package name.
func ReadAllPackagesFromRepo(ctx context.Context, dir string) (map[string]*Packages, error) {
p := make(map[string]*Packages)

var fileList []string
err := filepath.WalkDir(dir, func(path string, fi os.DirEntry, _ error) error {
if fi == nil {
return fmt.Errorf("%s does not exist", dir)
}
if fi.IsDir() && path != dir {
return filepath.SkipDir
}
if filepath.Ext(path) == yamlExtension {
fileList = append(fileList, path)
}
return nil
})
if err != nil {
return p, fmt.Errorf("failed walking files in cloned directory %s: %w", dir, err)
}

sort.Strings(fileList)

for _, fi := range fileList {
data, err := os.ReadFile(fi)
if err != nil {
return p, fmt.Errorf("failed to read file %s: %w", fi, err)
}
check := &configCheck{}
if err := yaml.Unmarshal(data, check); err != nil {
continue
}
if !check.isMelangeConfig() {
continue
}

packageConfig, err := config.ParseConfiguration(ctx, fi)
if err != nil {
return p, fmt.Errorf("failed to read package config %s: %w", fi, err)
}
relativeFilename, err := filepath.Rel(dir, fi)
if err != nil {
return p, fmt.Errorf("failed to get relative path from dir %s and file %s package config %s: %w", dir, fi, packageConfig.Package.Name, err)
}

nolint, err := findNoLint(fi)
if err != nil {
return p, fmt.Errorf("failed to read package config %s: %w", fi, err)
}

name := packageConfig.Package.Name
fiBase := strings.TrimSuffix(filepath.Base(fi), filepath.Ext(fi))
if name != fiBase {
return p, fmt.Errorf("package name does not match file name in '%s': '%s' != '%s'", fi, name, fiBase)
}

if _, exists := p[name]; exists {
return p, fmt.Errorf("package config names must be unique. Found a package called '%s' in '%s' and '%s'", name, fi, p[name].Filename)
}

p[name] = &Packages{
Config: *packageConfig,
Filename: relativeFilename,
Dir: dir,
NoLint: nolint,
}
}

return p, nil
}
Loading
Loading