diff --git a/bundle/regal/config/provided/data.yaml b/bundle/regal/config/provided/data.yaml index a3d3a0c2..cbdce0c9 100644 --- a/bundle/regal/config/provided/data.yaml +++ b/bundle/regal/config/provided/data.yaml @@ -1,3 +1,6 @@ +features: + remote: + check-version: true rules: bugs: constant-condition: diff --git a/cmd/lint.go b/cmd/lint.go index 8455d90f..392ae044 100644 --- a/cmd/lint.go +++ b/cmd/lint.go @@ -15,15 +15,19 @@ import ( "github.com/spf13/cobra" "gopkg.in/yaml.v3" + "github.com/open-policy-agent/opa/bundle" "github.com/open-policy-agent/opa/metrics" "github.com/open-policy-agent/opa/topdown" + rbundle "github.com/styrainc/regal/bundle" rio "github.com/styrainc/regal/internal/io" regalmetrics "github.com/styrainc/regal/internal/metrics" + "github.com/styrainc/regal/internal/update" "github.com/styrainc/regal/pkg/config" "github.com/styrainc/regal/pkg/linter" "github.com/styrainc/regal/pkg/report" "github.com/styrainc/regal/pkg/reporter" + "github.com/styrainc/regal/pkg/version" ) type lintCommandParams struct { @@ -243,7 +247,13 @@ func lint(args []string, params *lintCommandParams) (report.Report, error) { m.Timer(regalmetrics.RegalConfigSearch).Stop() } - regal := linter.NewLinter(). + // regal rules are loaded here and passed to the linter separately + // as the configuration is also used to determine feature toggles + // and the defaults from the data.yaml here. + regalRules := rio.MustLoadRegalBundleFS(rbundle.Bundle) + + regal := linter.NewEmptyLinter(). + WithAddedBundle(regalRules). WithDisableAll(params.disableAll). WithDisabledCategories(params.disableCategory.v...). WithDisabledRules(params.disable.v...). @@ -312,6 +322,8 @@ func lint(args []string, params *lintCommandParams) (report.Report, error) { m.Timer(regalmetrics.RegalConfigParse).Stop() } + go updateCheckAndWarn(params, regalRules, &userConfig) + result, err := regal.Lint(ctx) if err != nil { return report.Report{}, formatError(params.format, fmt.Errorf("error(s) encountered while linting: %w", err)) @@ -325,6 +337,27 @@ func lint(args []string, params *lintCommandParams) (report.Report, error) { return result, rep.Publish(ctx, result) //nolint:wrapcheck } +func updateCheckAndWarn(params *lintCommandParams, regalRules bundle.Bundle, userConfig *config.Config) { + mergedConfig, err := config.LoadConfigWithDefaultsFromBundle(®alRules, userConfig) + if err != nil { + if params.debug { + log.Printf("failed to merge user config with default config when checking version: %v", err) + } + + return + } + + if mergedConfig.Features.Remote.CheckVersion && + os.Getenv(update.CheckVersionDisableEnvVar) != "" { + update.CheckAndWarn(update.Options{ + CurrentVersion: version.Version, + CurrentTime: time.Now().UTC(), + Debug: params.debug, + StateDir: config.GlobalDir(), + }, os.Stderr) + } +} + func getReporter(format string, outputWriter io.Writer) (reporter.Reporter, error) { switch format { case formatPretty: diff --git a/docs/remote-features.md b/docs/remote-features.md new file mode 100644 index 00000000..b8d11d64 --- /dev/null +++ b/docs/remote-features.md @@ -0,0 +1,27 @@ +# Remote Features + +This page outlines the features of Regal that need internet access to function. + +## Checking for Updates + +Regal will check for updates on startup. If a new version is available, +Regal will notify you by writing a message in stderr. + +An example of such a message is: + +```txt +A new version of Regal is available (v0.23.1). You are running v0.23.0. +See https://github.com/StyraInc/regal/releases/tag/v0.23.1 for the latest release. +``` + +This message is based on the local version set in the Regal binary, and **no +user data is sent** to GitHub where the releases are hosted. + +This same function will also write to the file at: `$HOME/.config/regal/latest_version.json`, +this is used as a cache of the latest version to avoid consuming excessive +GitHub API rate limits when using Regal. + +This functionality can be disabled in two ways: + +* Using `.regal/config.yaml`: set `features.remote.check-version` to `false`. +* Using an environment variable: set `REGAL_DISABLE_CHECK_VERSION` to `true`. diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 69288095..93255226 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -12,6 +12,7 @@ import ( "strconv" "strings" "sync" + "time" "github.com/sourcegraph/jsonrpc2" "gopkg.in/yaml.v3" @@ -19,6 +20,8 @@ import ( "github.com/open-policy-agent/opa/ast" "github.com/open-policy-agent/opa/format" + "github.com/styrainc/regal/bundle" + rio "github.com/styrainc/regal/internal/io" "github.com/styrainc/regal/internal/lsp/cache" "github.com/styrainc/regal/internal/lsp/clients" "github.com/styrainc/regal/internal/lsp/commands" @@ -30,10 +33,12 @@ import ( "github.com/styrainc/regal/internal/lsp/types" "github.com/styrainc/regal/internal/lsp/uri" rparse "github.com/styrainc/regal/internal/parse" + "github.com/styrainc/regal/internal/update" "github.com/styrainc/regal/internal/util" "github.com/styrainc/regal/pkg/config" "github.com/styrainc/regal/pkg/fixer/fixes" "github.com/styrainc/regal/pkg/linter" + "github.com/styrainc/regal/pkg/version" ) const ( @@ -278,6 +283,13 @@ func (l *LanguageServer) StartConfigWorker(ctx context.Context) { return } + regalRules, err := rio.LoadRegalBundleFS(bundle.Bundle) + if err != nil { + l.logError(fmt.Errorf("failed to load regal bundle for defaulting of user config: %w", err)) + + return + } + for { select { case <-ctx.Done(): @@ -290,24 +302,44 @@ func (l *LanguageServer) StartConfigWorker(ctx context.Context) { continue } - var loadedConfig config.Config + var userConfig config.Config - err = yaml.NewDecoder(configFile).Decode(&loadedConfig) + err = yaml.NewDecoder(configFile).Decode(&userConfig) if err != nil && !errors.Is(err, io.EOF) { l.logError(fmt.Errorf("failed to reload config: %w", err)) return } + mergedConfig, err := config.LoadConfigWithDefaultsFromBundle(®alRules, &userConfig) + if err != nil { + l.logError(fmt.Errorf("failed to load config: %w", err)) + + return + } + // if the config is now blank, then we need to clear it l.loadedConfigLock.Lock() if errors.Is(err, io.EOF) { l.loadedConfig = nil } else { - l.loadedConfig = &loadedConfig + l.loadedConfig = &mergedConfig } l.loadedConfigLock.Unlock() + //nolint:contextcheck + go func() { + if l.loadedConfig.Features.Remote.CheckVersion && + os.Getenv(update.CheckVersionDisableEnvVar) != "" { + update.CheckAndWarn(update.Options{ + CurrentVersion: version.Version, + CurrentTime: time.Now().UTC(), + Debug: false, + StateDir: config.GlobalDir(), + }, os.Stderr) + } + }() + l.diagnosticRequestWorkspace <- "config file changed" case <-l.configWatcher.Drop: l.loadedConfigLock.Lock() diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 00000000..602bccc0 --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,186 @@ +//nolint:errcheck +package update + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/open-policy-agent/opa/rego" + + _ "embed" +) + +//go:embed update.rego +var updateModule string + +const CheckVersionDisableEnvVar = "REGAL_DISABLE_VERSION_CHECK" + +type Options struct { + CurrentVersion string + CurrentTime time.Time + + StateDir string + + ReleaseServerHost string + ReleaseServerPath string + + CTAURLPrefix string + + Debug bool +} + +type latestVersionFileContents struct { + LatestVersion string `json:"latest_version"` + CheckedAt time.Time `json:"checked_at"` +} + +func CheckAndWarn(opts Options, w io.Writer) { + // this is a shortcut heuristic to avoid and version checking + // when in dev/test etc. + if !strings.HasPrefix(opts.CurrentVersion, "v") { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + latestVersion, err := getLatestVersion(ctx, opts) + if err != nil { + if opts.Debug { + w.Write([]byte(err.Error())) + } + + return + } + + regoArgs := []func(*rego.Rego){ + rego.Module("update.rego", updateModule), + rego.Query(`data.update.needs_update`), + rego.Input(map[string]interface{}{ + "current_version": opts.CurrentVersion, + "latest_version": latestVersion, + }), + } + + rs, err := rego.New(regoArgs...).Eval(context.Background()) + if err != nil { + if opts.Debug { + w.Write([]byte(err.Error())) + } + + return + } + + if !rs.Allowed() { + if opts.Debug { + w.Write([]byte("Regal is up to date")) + } + + return + } + + ctaURLPrefix := "https://github.com/StyraInc/regal/releases/tag/" + if opts.CTAURLPrefix != "" { + ctaURLPrefix = opts.CTAURLPrefix + } + + ctaURL := ctaURLPrefix + latestVersion + + tmpl := `A new version of Regal is available (%s). You are running %s. +See %s for the latest release. +` + + w.Write([]byte(fmt.Sprintf(tmpl, latestVersion, opts.CurrentVersion, ctaURL))) +} + +func getLatestVersion(ctx context.Context, opts Options) (string, error) { + if opts.StateDir != "" { + // first, attempt to get the file from previous invocations to save on remote calls + latestVersionFilePath := filepath.Join(opts.StateDir, "latest_version.json") + + _, err := os.Stat(latestVersionFilePath) + if err == nil { + var preExistingState latestVersionFileContents + + file, err := os.Open(latestVersionFilePath) + if err != nil { + return "", fmt.Errorf("failed to open file: %w", err) + } + + err = json.NewDecoder(file).Decode(&preExistingState) + if err != nil { + return "", fmt.Errorf("failed to decode existing version state file: %w", err) + } + + if opts.CurrentTime.Sub(preExistingState.CheckedAt) < 3*24*time.Hour { + return preExistingState.LatestVersion, nil + } + } + } + + client := http.Client{} + + releaseServerHost := "https://api.github.com" + if opts.ReleaseServerHost != "" { + releaseServerHost = strings.TrimSuffix(opts.ReleaseServerHost, "/") + + if !strings.HasPrefix(releaseServerHost, "http") { + releaseServerHost = "https://" + releaseServerHost + } + } + + releaseServerURL, err := url.Parse(releaseServerHost) + if err != nil { + return "", fmt.Errorf("failed to parse release server URL: %w", err) + } + + releaseServerPath := "/repos/styrainc/regal/releases/latest" + if opts.ReleaseServerPath != "" { + releaseServerPath = opts.ReleaseServerPath + } + + releaseServerURL.Path = releaseServerPath + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, releaseServerURL.String(), nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() + + var responseData struct { + TagName string `json:"tag_name"` + } + + err = json.NewDecoder(resp.Body).Decode(&responseData) + if err != nil { + return "", fmt.Errorf("failed to decode response: %w", err) + } + + stateBs, err := json.MarshalIndent(latestVersionFileContents{ + LatestVersion: responseData.TagName, + CheckedAt: opts.CurrentTime, + }, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal state file: %w", err) + } + + err = os.WriteFile(opts.StateDir+"/latest_version.json", stateBs, 0o600) + if err != nil { + return "", fmt.Errorf("failed to write state file: %w", err) + } + + return responseData.TagName, nil +} diff --git a/internal/update/update.rego b/internal/update/update.rego new file mode 100644 index 00000000..76e2230b --- /dev/null +++ b/internal/update/update.rego @@ -0,0 +1,14 @@ +package update + +import rego.v1 + +current_version := trim(input.current_version, "v") + +latest_version := trim(input.latest_version, "v") + +default needs_update := false + +needs_update if { + semver.is_valid(current_version) + semver.compare(current_version, latest_version) == -1 +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 00000000..2e35634c --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,127 @@ +package update + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestCheckAndWarn(t *testing.T) { + t.Parallel() + + remoteCalls := 0 + + localReleasesServer := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write([]byte(`{"tag_name": "v0.2.0"}`)) + if err != nil { + t.Fatal(err) + } + + remoteCalls++ + }), + ) + + w := bytes.NewBuffer(nil) + + tempStateDir := t.TempDir() + + opts := Options{ + CurrentVersion: "v0.1.0", + CurrentTime: time.Now().UTC(), + StateDir: tempStateDir, + + ReleaseServerHost: localReleasesServer.URL, + ReleaseServerPath: "/repos/styrainc/regal/releases/latest", + + CTAURLPrefix: "https://github.com/StyraInc/regal/releases/tag/", + + Debug: true, + } + + CheckAndWarn(opts, w) + + output := w.String() + + if remoteCalls != 1 { + t.Errorf("expected 1 remote call, got %d", remoteCalls) + } + + expectedOutput := `A new version of Regal is available (v0.2.0). You are running v0.1.0. +See https://github.com/StyraInc/regal/releases/tag/v0.2.0 for the latest release.` + + if !strings.Contains(output, expectedOutput) { + t.Fatalf("expected output to contain\n%s,\ngot\n%s", expectedOutput, output) + } + + // run the function again and check that the state is loaded from disk + w = bytes.NewBuffer(nil) + CheckAndWarn(opts, w) + + if remoteCalls != 1 { + t.Errorf("expected remote to only be called once, got %d", remoteCalls) + } + + output = w.String() + + // the same output is expected based on the data on disk + if !strings.Contains(output, expectedOutput) { + t.Fatalf("expected output to contain\n%s,\ngot\n%s", expectedOutput, output) + } + + // update the time to sometime in the future + opts.CurrentTime = opts.CurrentTime.Add(4 * 24 * time.Hour) + + // run the function again and check that the state is loaded from the remote again + w = bytes.NewBuffer(nil) + CheckAndWarn(opts, w) + + if remoteCalls != 2 { + t.Errorf("expected remote to be called again, got %d", remoteCalls) + } + + // the same output is expected again + if !strings.Contains(output, expectedOutput) { + t.Fatalf("expected output to contain\n%s,\ngot\n%s", expectedOutput, output) + } + + // if the version is not a semver, then there should be no output + opts.CurrentVersion = "not-semver" + + w = bytes.NewBuffer(nil) + CheckAndWarn(opts, w) + + output = w.String() + + if output != "" { + t.Fatalf("expected no output, got\n%s", output) + } + + // if the version is greater than the latest version, then there should be no output + opts.CurrentVersion = "v0.3.0" + opts.Debug = false + + w = bytes.NewBuffer(nil) + CheckAndWarn(opts, w) + + output = w.String() + + if output != "" { + t.Fatalf("expected no output, got\n%s", output) + } + + // if the version is the same as the latest version, then there should be no output + opts.CurrentVersion = "v0.2.0" + + w = bytes.NewBuffer(nil) + CheckAndWarn(opts, w) + + output = w.String() + + if output != "" { + t.Fatalf("expected no output, got\n%s", output) + } +} diff --git a/pkg/config/bundle.go b/pkg/config/bundle.go new file mode 100644 index 00000000..d761fcfd --- /dev/null +++ b/pkg/config/bundle.go @@ -0,0 +1,111 @@ +package config + +import ( + "errors" + "fmt" + "strings" + + "dario.cat/mergo" + + "github.com/open-policy-agent/opa/bundle" + + "github.com/styrainc/regal/internal/util" +) + +func LoadConfigWithDefaultsFromBundle(regalBundle *bundle.Bundle, userConfig *Config) (Config, error) { + path := []string{"regal", "config", "provided"} + + bundled, err := util.SearchMap(regalBundle.Data, path) + if err != nil { + return Config{}, fmt.Errorf("config path not found %s: %w", strings.Join(path, "."), err) + } + + bundledConf, ok := bundled.(map[string]any) + if !ok { + return Config{}, errors.New("expected 'rules' of object type") + } + + defaultConfig, err := FromMap(bundledConf) + if err != nil { + return Config{}, fmt.Errorf("failed to convert config from map: %w", err) + } + + if userConfig == nil { + defaultConfig.Capabilities = CapabilitiesForThisVersion() + + return defaultConfig, nil + } + + providedRuleLevels := providedConfLevels(&defaultConfig) + + err = mergo.Merge(&defaultConfig, userConfig, mergo.WithOverride) + if err != nil { + return Config{}, fmt.Errorf("failed to merge user config: %w", err) + } + + if defaultConfig.Capabilities == nil { + defaultConfig.Capabilities = CapabilitiesForThisVersion() + } + + // adopt user rule levels based on config and defaults + // If the user configuration contains rules with the level unset, copy the level from the provided configuration + extractUserRuleLevels(userConfig, &defaultConfig, providedRuleLevels) + + return defaultConfig, nil +} + +// extractUserRuleLevels uses defaulting config and per-rule levels from user configuration to set the level for each +// rule. +func extractUserRuleLevels(userConfig *Config, mergedConf *Config, providedRuleLevels map[string]string) { + for categoryName, rulesByCategory := range mergedConf.Rules { + for ruleName, rule := range rulesByCategory { + var providedLevel string + + var ok bool + + if providedLevel, ok = providedRuleLevels[ruleName]; !ok { + continue + } + + // use the level from the provided configuration as the fallback + selectedRuleLevel := providedLevel + + var userHasConfiguredRule bool + + if _, ok := userConfig.Rules[categoryName]; ok { + _, userHasConfiguredRule = userConfig.Rules[categoryName][ruleName] + } + + if userHasConfiguredRule && userConfig.Rules[categoryName][ruleName].Level != "" { + // if the user config has a level for the rule, use that + selectedRuleLevel = userConfig.Rules[categoryName][ruleName].Level + } else if categoryDefault, ok := mergedConf.Defaults.Categories[categoryName]; ok { + // if the config has a default level for the category, use that + if categoryDefault.Level != "" { + selectedRuleLevel = categoryDefault.Level + } + } else if mergedConf.Defaults.Global.Level != "" { + // if the config has a global default level, use that + selectedRuleLevel = mergedConf.Defaults.Global.Level + } + + rule.Level = selectedRuleLevel + mergedConf.Rules[categoryName][ruleName] = rule + } + } +} + +// Copy the level of each rule from the provided configuration. +func providedConfLevels(conf *Config) map[string]string { + ruleLevels := make(map[string]string) + + for categoryName, rulesByCategory := range conf.Rules { + for ruleName := range rulesByCategory { + // Note that this assumes all rules have unique names, + // which we'll likely always want for provided rules + ruleLevels[ruleName] = conf.Rules[categoryName][ruleName].Level + } + } + + return ruleLevels +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 7c4d0d8e..08ec6ffd 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -29,6 +29,8 @@ type Config struct { // Defaults state is loaded from configuration under rules and so is not (un)marshalled // in the same way. Defaults Defaults `json:"-" yaml:"-"` + + Features *Features `json:"features,omitempty" yaml:"features,omitempty"` } type Category map[string]Rule @@ -46,6 +48,14 @@ type Default struct { Level string `json:"level" yaml:"level"` } +type Features struct { + Remote *RemoteFeatures `json:"remote,omitempty" yaml:"remote,omitempty"` +} + +type RemoteFeatures struct { + CheckVersion bool `json:"check-version,omitempty" yaml:"check-version,omitempty"` +} + func (d *Default) mapToConfig(result any) error { resultMap, ok := result.(map[string]any) if !ok { @@ -229,6 +239,11 @@ type marshallingIntermediary struct { } `yaml:"builtins"` } `yaml:"minus"` } `yaml:"capabilities"` + Features struct { + RemoteFeatures struct { + CheckVersion bool `yaml:"check_version"` + } `yaml:"remote"` + } `yaml:"features"` } func (config *Config) UnmarshalYAML(value *yaml.Node) error { @@ -303,6 +318,15 @@ func (config *Config) UnmarshalYAML(value *yaml.Node) error { config.Capabilities.Builtins[plusBuiltin.Name] = fromOPABuiltin(*plusBuiltin) } + // feature defaults + if result.Features.RemoteFeatures.CheckVersion { + config.Features = &Features{ + Remote: &RemoteFeatures{ + CheckVersion: true, + }, + } + } + return nil } diff --git a/pkg/config/global.go b/pkg/config/global.go new file mode 100644 index 00000000..f9d09409 --- /dev/null +++ b/pkg/config/global.go @@ -0,0 +1,25 @@ +package config + +import ( + "os" + "path/filepath" +) + +// Dir is the config directory that will be used for user-wide configuration. +// This is different from the .regal directories that are searched for when +// linting. +func GlobalDir() string { + cfgDir, err := os.UserHomeDir() + if err != nil { + return "" + } + + regalDir := filepath.Join(cfgDir, ".config", "regal") + if _, err := os.Stat(regalDir); os.IsNotExist(err) { + if err := os.Mkdir(regalDir, os.ModePerm); err != nil { + return "" + } + } + + return regalDir +} diff --git a/pkg/linter/linter.go b/pkg/linter/linter.go index c44d3622..07362f28 100644 --- a/pkg/linter/linter.go +++ b/pkg/linter/linter.go @@ -12,7 +12,6 @@ import ( "strings" "sync" - "dario.cat/mergo" "github.com/gobwas/glob" "gopkg.in/yaml.v3" @@ -42,7 +41,7 @@ type Linter struct { rootDir string ruleBundles []*bundle.Bundle userConfig *config.Config - combinedConfig *config.Config + combinedCfg *config.Config dataBundle *bundle.Bundle customRulesPaths []string customRuleFS fs.FS @@ -81,6 +80,11 @@ func NewLinter() Linter { } } +// NewEmptyLinter creates a linter with no rule bundles. +func NewEmptyLinter() Linter { + return Linter{} +} + // WithInputPaths sets the inputPaths to lint. Note that these will be // filtered according to the ignore options. func (l Linter) WithInputPaths(paths []string) Linter { @@ -222,13 +226,11 @@ func (l Linter) Lint(ctx context.Context) (report.Report, error) { return report.Report{}, errors.New("nothing provided to lint") } - conf, err := l.mergedConfig() + conf, err := l.combinedConfig() if err != nil { return report.Report{}, fmt.Errorf("failed to merge config: %w", err) } - l.combinedConfig = &conf - l.dataBundle = &bundle.Bundle{ Manifest: bundle.Manifest{ Roots: &[]string{"internal"}, @@ -236,7 +238,7 @@ func (l Linter) Lint(ctx context.Context) (report.Report, error) { }, Data: map[string]any{ "internal": map[string]any{ - "combined_config": config.ToMap(*l.combinedConfig), + "combined_config": config.ToMap(*conf), "capabilities": rio.ToMap(config.CapabilitiesForThisVersion()), }, }, @@ -860,121 +862,34 @@ func resultSetToReport(resultSet rego.ResultSet) (report.Report, error) { return r, nil } -func (l Linter) readProvidedConfig() (config.Config, error) { - regalBundle, err := l.getBundleByName("regal") - if err != nil { - return config.Config{}, fmt.Errorf("failed to get regal bundle: %w", err) +func (l Linter) combinedConfig() (*config.Config, error) { + if l.combinedCfg != nil { + return l.combinedCfg, nil } - path := []string{"regal", "config", "provided"} - - bundled, err := util.SearchMap(regalBundle.Data, path) + regalBundle, err := l.getBundleByName("regal") if err != nil { - return config.Config{}, fmt.Errorf("config path not found %s: %w", strings.Join(path, "."), err) - } - - bundledConf, ok := bundled.(map[string]any) - if !ok { - return config.Config{}, errors.New("expected 'rules' of object type") - } - - return config.FromMap(bundledConf) //nolint:wrapcheck -} - -func (l Linter) mergedConfig() (config.Config, error) { - if l.combinedConfig != nil { - return *l.combinedConfig, nil + return &config.Config{}, fmt.Errorf("failed to get regal bundle: %w", err) } - mergedConf, err := l.readProvidedConfig() + mergedConf, err := config.LoadConfigWithDefaultsFromBundle(regalBundle, l.userConfig) if err != nil { - return config.Config{}, fmt.Errorf("failed to read provided config: %w", err) - } - - providedRuleLevels := providedConfLevels(mergedConf) - - if l.userConfig != nil { - err = mergo.Merge(&mergedConf, l.userConfig, mergo.WithOverride) - if err != nil { - return config.Config{}, fmt.Errorf("failed to merge config: %w", err) - } - - // adopt user rule levels based on config and defaults - // If the user configuration contains rules with the level unset, copy the level from the provided configuration - extractUserRuleLevels(l.userConfig, &mergedConf, providedRuleLevels) - } - - if mergedConf.Capabilities == nil { - mergedConf.Capabilities = config.CapabilitiesForThisVersion() + return &config.Config{}, fmt.Errorf("failed to read provided config: %w", err) } if l.debugMode { bs, err := yaml.Marshal(mergedConf) if err != nil { - return config.Config{}, fmt.Errorf("failed to marshal config: %w", err) + return &config.Config{}, fmt.Errorf("failed to marshal config: %w", err) } log.Println("merged provided and user config:") log.Println(string(bs)) } - return mergedConf, nil -} - -// extractUserRuleLevels uses defaulting config and per-rule levels from user configuration to set the level for each -// rule. -func extractUserRuleLevels(userConfig *config.Config, mergedConf *config.Config, providedRuleLevels map[string]string) { - for categoryName, rulesByCategory := range mergedConf.Rules { - for ruleName, rule := range rulesByCategory { - var providedLevel string - - var ok bool - - if providedLevel, ok = providedRuleLevels[ruleName]; !ok { - continue - } - - // use the level from the provided configuration as the fallback - selectedRuleLevel := providedLevel - - var userHasConfiguredRule bool - - if _, ok := userConfig.Rules[categoryName]; ok { - _, userHasConfiguredRule = userConfig.Rules[categoryName][ruleName] - } - - if userHasConfiguredRule && userConfig.Rules[categoryName][ruleName].Level != "" { - // if the user config has a level for the rule, use that - selectedRuleLevel = userConfig.Rules[categoryName][ruleName].Level - } else if categoryDefault, ok := mergedConf.Defaults.Categories[categoryName]; ok { - // if the config has a default level for the category, use that - if categoryDefault.Level != "" { - selectedRuleLevel = categoryDefault.Level - } - } else if mergedConf.Defaults.Global.Level != "" { - // if the config has a global default level, use that - selectedRuleLevel = mergedConf.Defaults.Global.Level - } - - rule.Level = selectedRuleLevel - mergedConf.Rules[categoryName][ruleName] = rule - } - } -} - -// Copy the level of each rule from the provided configuration. -func providedConfLevels(conf config.Config) map[string]string { - ruleLevels := make(map[string]string) - - for categoryName, rulesByCategory := range conf.Rules { - for ruleName := range rulesByCategory { - // Note that this assumes all rules have unique names, - // which we'll likely always want for provided rules - ruleLevels[ruleName] = conf.Rules[categoryName][ruleName].Level - } - } + l.combinedCfg = &mergedConf - return ruleLevels + return l.combinedCfg, nil } func (l Linter) enabledGoRules() ([]rules.Rule, error) { @@ -1003,12 +918,12 @@ func (l Linter) enabledGoRules() ([]rules.Rule, error) { return enabledGoRules, nil } - conf, err := l.mergedConfig() + conf, err := l.combinedConfig() if err != nil { return nil, fmt.Errorf("failed to create merged config: %w", err) } - for _, rule := range rules.AllGoRules(conf) { + for _, rule := range rules.AllGoRules(*conf) { // disabling specific rule has the highest precedence if util.Contains(l.disable, rule.Name()) { continue diff --git a/pkg/linter/linter_test.go b/pkg/linter/linter_test.go index 4bcf0cf7..659d51c2 100644 --- a/pkg/linter/linter_test.go +++ b/pkg/linter/linter_test.go @@ -438,7 +438,7 @@ func TestLintMergedConfigInheritsLevelFromProvided(t *testing.T) { WithUserConfig(userConfig). WithInputModules(&input) - mergedConfig := testutil.Must(linter.mergedConfig())(t) + mergedConfig := testutil.Must(linter.combinedConfig())(t) // Since no level was provided, "error" should be inherited from the provided configuration for the rule if mergedConfig.Rules["style"]["file-length"].Level != "error" { @@ -477,7 +477,7 @@ func TestLintMergedConfigUsesProvidedDefaults(t *testing.T) { WithUserConfig(userConfig). WithInputModules(&input) - mergedConfig := testutil.Must(linter.mergedConfig())(t) + mergedConfig := testutil.Must(linter.combinedConfig())(t) // specifically configured rule should not be affected by the default if mergedConfig.Rules["style"]["opa-fmt"].Level != "warning" {