diff --git a/README.md b/README.md index f9e09907..91085e67 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,80 @@ $ goss serve --format json & $ curl -H "Accept: application/vnd.goss-rspecish" localhost:8080/healthz ``` +### Running a subset of tests with marks + +Tests can be tagged with arbitrary `marks` (inspired by `pytest -m`) and then +filtered at validation time. This is useful for: + +* **Selective alerting** - run only `critical` health checks in production +* **Incident response** - quickly run `network` or `storage` categories +* **Flaky-test quarantine** - tag flaky tests `flaky` and exclude them from CI + +Add marks to any resource in your gossfile: + +```yaml +command: + echo hello: + exit-status: 0 + stdout: [hello] + marks: + - critical + - fast + echo slow: + exit-status: 0 + stdout: [slow] + marks: + - slow +http: + https://api.example.com/health: + status: 200 + marks: + - critical + - network +``` + +Then filter at run time with `--marks` (include) and/or `--exclude-marks`: + +```console +# Only run tests marked "critical" +$ goss validate --marks critical + +# Run tests marked "critical" OR "fast" +$ goss validate --marks critical,fast + +# Exclude slow and flaky tests +$ goss validate --exclude-marks slow,flaky + +# Combine: run critical tests but skip any that are also marked flaky +$ goss validate --marks critical --exclude-marks flaky +``` + +Resources with **no marks** are always included unless filtered out by +`--exclude-marks` or other criteria. When `--marks` is set, unmarked resources +are skipped. See `docs/marks.md` or the design spec for the full evaluation +rules. + +Marks also work on the health endpoint - pass them as query parameters, which +override any defaults baked in via the serve command's flags: + +```console +$ goss serve --marks critical & +$ curl 'localhost:8080/healthz' # uses --marks critical +$ curl 'localhost:8080/healthz?marks=network' # overrides: only network +$ curl 'localhost:8080/healthz?exclude-marks=slow,flaky' # include all but slow/flaky +``` + +When using `goss add` or `goss autoadd`, pass `--marks` to tag newly created +resources automatically: + +```console +$ goss add --marks critical service sshd +$ goss autoadd --marks network nginx +``` + +Existing marks on previously parsed resources are preserved; the flag only +applies to newly created ones. + ### Manually editing Goss files Goss files can be manually edited to improve readability and expressiveness of tests. diff --git a/add.go b/add.go index 35cf1bcb..dcacd0d2 100644 --- a/add.go +++ b/add.go @@ -13,15 +13,15 @@ import ( // AddResources is a simple wrapper to add multiple resources func AddResources(fileName, resourceName string, keys []string, c *util.Config) error { - var err error - err = setLogLevel(c) + err := setLogLevel(c) if err != nil { return err } - outStoreFormat, err = getStoreFormatFromFileName(fileName) + storeFormat, err := getStoreFormatFromFileName(fileName) if err != nil { return err } + setStoreFormat(storeFormat) var gossConfig GossConfig if _, err := os.Stat(fileName); err == nil { @@ -41,7 +41,14 @@ func AddResources(fileName, resourceName string, keys []string, c *util.Config) } } - return WriteJSON(fileName, gossConfig) + warning, err := WriteJSON(fileName, gossConfig) + if err != nil { + return err + } + if warning != "" { + c.Log().Printf("%s", warning) + } + return nil } // AddResource adds a single resource to fileName @@ -89,18 +96,37 @@ func AddResource(fileName string, gossConfig GossConfig, resourceName, key strin return err } + applyMarksIfUnset(res, config.IncludeMarks) + resourcePrint(fileName, res, config.AnnounceToCLI) return nil } +// applyMarksIfUnset sets the supplied marks on res when res has no existing +// marks. It is called from the add/autoadd paths so that --marks tags newly +// created resources but never overwrites marks that existed in a previously +// parsed gossfile. A nil or empty marks slice is a no-op. +func applyMarksIfUnset(res resource.ResourceRead, marks []string) { + if res == nil || len(marks) == 0 { + return + } + if len(res.GetMarks()) > 0 { + return + } + // Copy to avoid aliasing the config's slice into every resource. + out := make([]string, len(marks)) + copy(out, marks) + res.SetMarks(out) +} + // AutoAddResources is a simple wrapper to add multiple resources func AutoAddResources(fileName string, keys []string, c *util.Config) error { - var err error - outStoreFormat, err = getStoreFormatFromFileName(fileName) + storeFormat, err := getStoreFormatFromFileName(fileName) if err != nil { return err } + setStoreFormat(storeFormat) var gossConfig GossConfig if _, err = os.Stat(fileName); err == nil { @@ -120,11 +146,20 @@ func AutoAddResources(fileName string, keys []string, c *util.Config) error { } } - return WriteJSON(fileName, gossConfig) + warning, err := WriteJSON(fileName, gossConfig) + if err != nil { + return err + } + if warning != "" { + c.Log().Printf("%s", warning) + } + return nil } // AutoAddResource adds a single resource to fileName with automatic detection of the type of resource func AutoAddResource(fileName string, gossConfig GossConfig, key string, c *util.Config, sys *system.System) error { + marks := c.IncludeMarks + // file if strings.Contains(key, "/") { res, _, ok, err := gossConfig.Files.AppendSysResourceIfExists(key, sys) @@ -132,6 +167,7 @@ func AutoAddResource(fileName string, gossConfig GossConfig, key string, c *util return err } if ok { + applyMarksIfUnset(res, marks) resourcePrint(fileName, res, c.AnnounceToCLI) } } @@ -141,6 +177,7 @@ func AutoAddResource(fileName string, gossConfig GossConfig, key string, c *util return err } else if ok { + applyMarksIfUnset(res, marks) resourcePrint(fileName, res, c.AnnounceToCLI) } @@ -150,6 +187,7 @@ func AutoAddResource(fileName string, gossConfig GossConfig, key string, c *util return err } else if ok { + applyMarksIfUnset(res, marks) resourcePrint(fileName, res, c.AnnounceToCLI) } @@ -158,6 +196,7 @@ func AutoAddResource(fileName string, gossConfig GossConfig, key string, c *util return err } else if ok { + applyMarksIfUnset(res, marks) resourcePrint(fileName, res, c.AnnounceToCLI) } @@ -165,6 +204,7 @@ func AutoAddResource(fileName string, gossConfig GossConfig, key string, c *util if res, sysres, ok, err := gossConfig.Processes.AppendSysResourceIfExists(key, sys); err != nil { return err } else if ok { + applyMarksIfUnset(res, marks) resourcePrint(fileName, res, c.AnnounceToCLI) ports := system.GetPorts(true) pids, _ := sysres.Pids() @@ -177,6 +217,7 @@ func AutoAddResource(fileName string, gossConfig GossConfig, key string, c *util if res, _, ok, err := gossConfig.Ports.AppendSysResourceIfExists(port, sys); err != nil { return err } else if ok { + applyMarksIfUnset(res, marks) resourcePrint(fileName, res, c.AnnounceToCLI) } } @@ -189,6 +230,7 @@ func AutoAddResource(fileName string, gossConfig GossConfig, key string, c *util if res, _, ok, err := gossConfig.Services.AppendSysResourceIfExists(key, sys); err != nil { return err } else if ok { + applyMarksIfUnset(res, marks) resourcePrint(fileName, res, c.AnnounceToCLI) } @@ -196,6 +238,7 @@ func AutoAddResource(fileName string, gossConfig GossConfig, key string, c *util if res, _, ok, err := gossConfig.Users.AppendSysResourceIfExists(key, sys); err != nil { return err } else if ok { + applyMarksIfUnset(res, marks) resourcePrint(fileName, res, c.AnnounceToCLI) } diff --git a/add_marks_test.go b/add_marks_test.go new file mode 100644 index 00000000..ae87480d --- /dev/null +++ b/add_marks_test.go @@ -0,0 +1,82 @@ +package goss + +import ( + "testing" + + "github.com/goss-org/goss/resource" + "github.com/stretchr/testify/assert" +) + +func TestApplyMarksIfUnset(t *testing.T) { + t.Parallel() + + t.Run("nil resource is a no-op", func(t *testing.T) { + t.Parallel() + // Must not panic. + applyMarksIfUnset(nil, []string{"critical"}) + }) + + t.Run("empty marks leaves resource untouched", func(t *testing.T) { + t.Parallel() + r := &resource.Addr{} + applyMarksIfUnset(r, nil) + assert.Empty(t, r.GetMarks()) + + applyMarksIfUnset(r, []string{}) + assert.Empty(t, r.GetMarks()) + }) + + t.Run("applies marks when resource has none", func(t *testing.T) { + t.Parallel() + r := &resource.Addr{} + applyMarksIfUnset(r, []string{"critical", "fast"}) + assert.Equal(t, []string{"critical", "fast"}, r.GetMarks()) + }) + + t.Run("preserves existing marks", func(t *testing.T) { + t.Parallel() + r := &resource.Addr{Marks: []string{"existing"}} + applyMarksIfUnset(r, []string{"critical"}) + assert.Equal(t, []string{"existing"}, r.GetMarks()) + }) + + t.Run("copies input slice to avoid aliasing", func(t *testing.T) { + t.Parallel() + r1 := &resource.Addr{} + r2 := &resource.Addr{} + input := []string{"a", "b"} + + applyMarksIfUnset(r1, input) + applyMarksIfUnset(r2, input) + + // Mutate r1's marks; r2 must not see the change. + r1.GetMarks()[0] = "mutated" + assert.Equal(t, []string{"a", "b"}, r2.GetMarks(), + "applyMarksIfUnset must copy the slice so resources don't share backing arrays") + }) + + t.Run("works across multiple resource types", func(t *testing.T) { + t.Parallel() + cases := []resource.ResourceRead{ + &resource.Addr{}, + &resource.Command{}, + &resource.File{}, + &resource.Group{}, + &resource.HTTP{}, + &resource.Package{}, + &resource.Port{}, + &resource.Process{}, + &resource.Service{}, + &resource.User{}, + &resource.DNS{}, + &resource.KernelParam{}, + &resource.Mount{}, + &resource.Interface{}, + &resource.Gossfile{}, + } + for _, r := range cases { + applyMarksIfUnset(r, []string{"production"}) + assert.Equal(t, []string{"production"}, r.GetMarks()) + } + }) +} diff --git a/cmd/goss/goss.go b/cmd/goss/goss.go index 0c6a061a..d800e0a3 100644 --- a/cmd/goss/goss.go +++ b/cmd/goss/goss.go @@ -18,6 +18,19 @@ import ( "github.com/urfave/cli" ) +// stringFromContextChain walks upward through the current context's ancestors +// returning the first non-empty value set for the named flag. This lets us +// declare a flag once on a parent command (e.g., "add") and have its value +// picked up when a nested subcommand (e.g., "add file") is being executed. +func stringFromContextChain(c *cli.Context, name string) string { + for ctx := c; ctx != nil; ctx = ctx.Parent() { + if v := ctx.String(name); v != "" { + return v + } + } + return "" +} + // converts a cli context into a goss Config func newRuntimeConfigFromCLI(c *cli.Context) *util.Config { cfg := &util.Config{ @@ -44,6 +57,8 @@ func newRuntimeConfigFromCLI(c *cli.Context) *util.Config { Username: c.String("username"), Vars: c.GlobalString("vars"), VarsInline: c.GlobalString("vars-inline"), + IncludeMarks: util.ParseMarksParam(stringFromContextChain(c, "marks")), + ExcludeMarks: util.ParseMarksParam(stringFromContextChain(c, "exclude-marks")), } if c.Bool("no-color") { @@ -143,6 +158,16 @@ func main() { Value: 50, EnvVar: "GOSS_MAX_CONCURRENT", }, + cli.StringFlag{ + Name: "marks", + Usage: "Comma-separated list of marks; only resources with at least one matching mark will be validated", + EnvVar: "GOSS_MARKS", + }, + cli.StringFlag{ + Name: "exclude-marks", + Usage: "Comma-separated list of marks; resources with any matching mark will be skipped", + EnvVar: "GOSS_EXCLUDE_MARKS", + }, }, Action: func(c *cli.Context) error { fatalAlphaIfNeeded(c) @@ -195,6 +220,16 @@ func main() { Value: 50, EnvVar: "GOSS_MAX_CONCURRENT", }, + cli.StringFlag{ + Name: "marks", + Usage: "Comma-separated list of marks; only resources with at least one matching mark will be validated (overridable via ?marks= query param)", + EnvVar: "GOSS_MARKS", + }, + cli.StringFlag{ + Name: "exclude-marks", + Usage: "Comma-separated list of marks; resources with any matching mark will be skipped (overridable via ?exclude-marks= query param)", + EnvVar: "GOSS_EXCLUDE_MARKS", + }, }, Action: func(c *cli.Context) error { fatalAlphaIfNeeded(c) @@ -227,6 +262,13 @@ func main() { Name: "autoadd", Aliases: []string{"aa"}, Usage: "automatically add all matching resource to the test suite", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "marks", + Usage: "Comma-separated list of marks to apply to newly added resources", + EnvVar: "GOSS_MARKS", + }, + }, Action: func(c *cli.Context) error { fatalAlphaIfNeeded(c) return goss.AutoAddResources(c.GlobalString("gossfile"), c.Args(), newRuntimeConfigFromCLI(c)) @@ -241,6 +283,11 @@ func main() { Name: "exclude-attr", Usage: "Exclude the following attributes when adding a new resource", }, + cli.StringFlag{ + Name: "marks", + Usage: "Comma-separated list of marks to apply to newly added resources", + EnvVar: "GOSS_MARKS", + }, }, Subcommands: []cli.Command{ { diff --git a/goss_config.go b/goss_config.go index 3f90ba64..14bb44cb 100644 --- a/goss_config.go +++ b/goss_config.go @@ -1,7 +1,7 @@ package goss import ( - "log" + "fmt" "reflect" "github.com/goss-org/goss/resource" @@ -47,75 +47,83 @@ func NewGossConfig() *GossConfig { } } -// Merge consumes all the resources in g2 into c, duplicate resources -// will be overwritten with the ones in g2 -func (c *GossConfig) Merge(g2 GossConfig) { - for k, v := range g2.Files { - mergeType(c.Files, "file", k, v) +// Merge consumes all the resources in g2 into c. Duplicate resources in g2 +// will overwrite the ones already present in c; a warning describing each +// duplicate is returned to the caller so it can be emitted at the +// appropriate architectural boundary (where a *util.Config and therefore a +// Logger is available). +// +// Merge itself performs no logging: keeping this function pure makes it +// trivially testable and decouples core config merging from the goss +// logging infrastructure. +func (c *GossConfig) Merge(g2 GossConfig) []string { + var warnings []string + collect := func(w string) { + if w != "" { + warnings = append(warnings, w) + } } + for k, v := range g2.Files { + collect(mergeType(c.Files, "file", k, v)) + } for k, v := range g2.Packages { - mergeType(c.Packages, "package", k, v) + collect(mergeType(c.Packages, "package", k, v)) } - for k, v := range g2.Addrs { - mergeType(c.Addrs, "addr", k, v) + collect(mergeType(c.Addrs, "addr", k, v)) } - for k, v := range g2.Ports { - mergeType(c.Ports, "port", k, v) + collect(mergeType(c.Ports, "port", k, v)) } - for k, v := range g2.Services { - mergeType(c.Services, "service", k, v) + collect(mergeType(c.Services, "service", k, v)) } - for k, v := range g2.Users { - mergeType(c.Users, "user", k, v) + collect(mergeType(c.Users, "user", k, v)) } - for k, v := range g2.Groups { - mergeType(c.Groups, "group", k, v) + collect(mergeType(c.Groups, "group", k, v)) } - for k, v := range g2.Commands { - mergeType(c.Commands, "command", k, v) + collect(mergeType(c.Commands, "command", k, v)) } - for k, v := range g2.DNS { - mergeType(c.DNS, "dns", k, v) + collect(mergeType(c.DNS, "dns", k, v)) } - for k, v := range g2.Processes { - mergeType(c.Processes, "process", k, v) + collect(mergeType(c.Processes, "process", k, v)) } - for k, v := range g2.KernelParams { - mergeType(c.KernelParams, "kernel-param", k, v) + collect(mergeType(c.KernelParams, "kernel-param", k, v)) } - for k, v := range g2.Mounts { - mergeType(c.Mounts, "mount", k, v) + collect(mergeType(c.Mounts, "mount", k, v)) } - for k, v := range g2.Interfaces { - mergeType(c.Interfaces, "interface", k, v) + collect(mergeType(c.Interfaces, "interface", k, v)) } - for k, v := range g2.HTTPs { - mergeType(c.HTTPs, "http", k, v) + collect(mergeType(c.HTTPs, "http", k, v)) } - for k, v := range g2.Matchings { - mergeType(c.Matchings, "matching", k, v) + collect(mergeType(c.Matchings, "matching", k, v)) } + + return warnings } -func mergeType[V any](m map[string]V, t, k string, v V) { - if _, ok := m[k]; ok { - log.Printf("[WARN] Duplicate key detected: '%s: %s'. The value from a later-loaded goss file has overwritten the previous value.", t, k) - } +// mergeType inserts v into m at key k, returning a non-empty warning string +// describing a duplicate if one was overwritten. This function performs no +// logging; the caller (ultimately a component at the edge layer that has +// access to a Logger) is responsible for emitting the returned warnings. +func mergeType[V any](m map[string]V, t, k string, v V) string { + _, duplicate := m[k] m[k] = v + if duplicate { + return fmt.Sprintf("[WARN] Duplicate key detected: '%s: %s'. The value from a later-loaded goss file has overwritten the previous value.", t, k) + } + return "" } func (c *GossConfig) Resources() []resource.Resource { @@ -171,10 +179,12 @@ func interfaceMap(slice any) map[string]any { return ret } -func mergeGoss(g1, g2 GossConfig) GossConfig { +// mergeGoss merges g2 into g1, discarding g1.Gossfiles first so the +// recursion boundary in mergeJSONData is respected. Warnings about +// duplicate keys are returned rather than logged; the caller (which has a +// *util.Config in scope) is expected to emit them via c.Log(). +func mergeGoss(g1, g2 GossConfig) (GossConfig, []string) { g1.Gossfiles = nil - - g1.Merge(g2) - - return g1 + warnings := g1.Merge(g2) + return g1, warnings } diff --git a/outputs/json.go b/outputs/json.go index c2413aaf..2f0329e3 100644 --- a/outputs/json.go +++ b/outputs/json.go @@ -4,10 +4,8 @@ import ( "encoding/json" "fmt" "io" - "log" "time" - "github.com/fatih/color" "github.com/goss-org/goss/resource" "github.com/goss-org/goss/util" ) @@ -24,6 +22,7 @@ func (r Json) ValidOptions() []*formatOption { func (r Json) Output(w io.Writer, results <-chan []resource.TestResult, outConfig util.OutputConfig) (exitCode int) { + logger := outConfig.Log() var pretty bool = util.IsValueInList(foPretty, outConfig.FormatOptions) includeRaw := !util.IsValueInList(foExcludeRaw, outConfig.FormatOptions) @@ -32,7 +31,7 @@ func (r Json) Output(w io.Writer, results <-chan []resource.TestResult, var startTime time.Time var endTime time.Time - color.NoColor = true + disableColor() testCount := 0 failed := 0 skipped := 0 @@ -47,9 +46,9 @@ func (r Json) Output(w io.Writer, results <-chan []resource.TestResult, } if testResult.Result == resource.FAIL { failed++ - logTrace("TRACE", "FAIL", testResult, true) + logTrace(logger, "TRACE", "FAIL", testResult, true) } else { - logTrace("TRACE", "SUCCESS", testResult, true) + logTrace(logger, "TRACE", "SUCCESS", testResult, true) } if testResult.Skipped { skipped++ @@ -87,11 +86,11 @@ func (r Json) Output(w io.Writer, results <-chan []resource.TestResult, fmt.Fprintln(w, resstr) if failed > 0 { - log.Printf("[DEBUG] FAIL SUMMARY: %s", resstr) + logger.Printf("[DEBUG] FAIL SUMMARY: %s", resstr) return 1 } - log.Printf("[DEBUG] OK SUMMARY: %s", resstr) + logger.Printf("[DEBUG] OK SUMMARY: %s", resstr) return 0 } diff --git a/outputs/junit.go b/outputs/junit.go index d50e3677..fc73f4ef 100644 --- a/outputs/junit.go +++ b/outputs/junit.go @@ -8,7 +8,6 @@ import ( "strconv" "time" - "github.com/fatih/color" "github.com/goss-org/goss/resource" "github.com/goss-org/goss/util" ) @@ -28,7 +27,7 @@ func (r JUnit) Output(w io.Writer, results <-chan []resource.TestResult, sort := util.IsValueInList(foSort, outConfig.FormatOptions) results = getResults(results, sort) - color.NoColor = true + disableColor() var testCount, failed, skipped int // ISO8601 timeformat diff --git a/outputs/outputs.go b/outputs/outputs.go index dca4a8ac..ddab4ef5 100644 --- a/outputs/outputs.go +++ b/outputs/outputs.go @@ -53,6 +53,14 @@ var red = color.New(color.FgRed).SprintfFunc() var yellow = color.New(color.FgYellow).SprintfFunc() var multiple_space = regexp.MustCompile(`\s+`) +// disableColor sets color.NoColor=true at most once per process. It delegates +// to util.InitNoColor so that this package and the root goss package share a +// single sync.Once; otherwise concurrent writes to color.NoColor from +// different paths could race even if each path was individually guarded. +func disableColor() { + util.InitNoColor(true) +} + func humanizeResult(r resource.TestResult, compact bool, includeRaw bool) string { sep := "\n" if compact { diff --git a/outputs/rspecish.go b/outputs/rspecish.go index 7fb5f2f9..1e2eb4ef 100644 --- a/outputs/rspecish.go +++ b/outputs/rspecish.go @@ -3,7 +3,6 @@ package outputs import ( "fmt" "io" - "log" "strings" "time" @@ -20,6 +19,7 @@ func (r Rspecish) ValidOptions() []*formatOption { func (r Rspecish) Output(w io.Writer, results <-chan []resource.TestResult, outConfig util.OutputConfig) (exitCode int) { + logger := outConfig.Log() sort := util.IsValueInList(foSort, outConfig.FormatOptions) results = getResults(results, sort) @@ -42,15 +42,15 @@ func (r Rspecish) Output(w io.Writer, results <-chan []resource.TestResult, } switch testResult.Result { case resource.SUCCESS: - logTrace("TRACE", "SUCCESS", testResult, false) + logTrace(logger, "TRACE", "SUCCESS", testResult, false) fmt.Fprint(w, green(".")) case resource.SKIP: - logTrace("TRACE", "SKIP", testResult, false) + logTrace(logger, "TRACE", "SKIP", testResult, false) fmt.Fprint(w, yellow("S")) failedOrSkippedGroup = append(failedOrSkippedGroup, testResult) skipped++ case resource.FAIL: - logTrace("TRACE", "FAIL", testResult, false) + logTrace(logger, "TRACE", "FAIL", testResult, false) fmt.Fprint(w, red("F")) failedOrSkippedGroup = append(failedOrSkippedGroup, testResult) failed++ @@ -71,9 +71,9 @@ func (r Rspecish) Output(w io.Writer, results <-chan []resource.TestResult, fmt.Fprint(w, outstr) resstr := strings.ReplaceAll(outstr, "\n", " ") if failed > 0 { - log.Printf("[DEBUG] FAIL SUMMARY: %s", resstr) + logger.Printf("[DEBUG] FAIL SUMMARY: %s", resstr) return 1 } - log.Printf("[DEBUG] OK SUMMARY: %s", resstr) + logger.Printf("[DEBUG] OK SUMMARY: %s", resstr) return 0 } diff --git a/outputs/traces.go b/outputs/traces.go index 2a37d5da..0c14b91b 100644 --- a/outputs/traces.go +++ b/outputs/traces.go @@ -1,14 +1,16 @@ package outputs import ( - "log" - "github.com/goss-org/goss/resource" + "github.com/goss-org/goss/util" ) -func logTrace(level string, msg string, testResult resource.TestResult, withIntResult bool) { +// logTrace emits a per-test trace line via logger. Outputers call this for +// every individual test result; accepting the logger as a parameter keeps +// logTrace free of hidden dependencies on any process-wide log sink. +func logTrace(logger util.Logger, level string, msg string, testResult resource.TestResult, withIntResult bool) { if withIntResult { - log.Printf("[%s] %s: %s => %s (%s %+v %+v) [%.02f] [%d]", + logger.Printf("[%s] %s: %s => %s (%s %+v %+v) [%.02f] [%d]", level, msg, testResult.ResourceType, @@ -20,7 +22,7 @@ func logTrace(level string, msg string, testResult resource.TestResult, withIntR testResult.Result, ) } else { - log.Printf("[%s] %s: %s => %s (%s %+v %+v) [%.02f]", + logger.Printf("[%s] %s: %s => %s (%s %+v %+v) [%.02f]", level, msg, testResult.ResourceType, diff --git a/resource/addr.go b/resource/addr.go index f98eb630..e1dfa827 100644 --- a/resource/addr.go +++ b/resource/addr.go @@ -10,14 +10,15 @@ import ( ) type Addr struct { - Title string `json:"title,omitempty" yaml:"title,omitempty"` - Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` - id string `json:"-" yaml:"-"` - Address string `json:"address,omitempty" yaml:"address,omitempty"` - LocalAddress string `json:"local-address,omitempty" yaml:"local-address,omitempty"` - Reachable matcher `json:"reachable" yaml:"reachable"` - Timeout int `json:"timeout" yaml:"timeout"` - Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` + Marks []string `json:"marks,omitempty" yaml:"marks,omitempty"` + id string `json:"-" yaml:"-"` + Address string `json:"address,omitempty" yaml:"address,omitempty"` + LocalAddress string `json:"local-address,omitempty" yaml:"local-address,omitempty"` + Reachable matcher `json:"reachable" yaml:"reachable"` + Timeout int `json:"timeout" yaml:"timeout"` + Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"` } type idKey struct{} @@ -43,8 +44,10 @@ func (a *Addr) TypeKey() string { return AddrResourceKey } func (a *Addr) TypeName() string { return AddResourceName } // FIXME: Can this be refactored? -func (a *Addr) GetTitle() string { return a.Title } -func (a *Addr) GetMeta() meta { return a.Meta } +func (a *Addr) GetTitle() string { return a.Title } +func (a *Addr) GetMeta() meta { return a.Meta } +func (a *Addr) GetMarks() []string { return a.Marks } +func (a *Addr) SetMarks(m []string) { a.Marks = m } func (a *Addr) GetAddress() string { if a.Address != "" { return a.Address diff --git a/resource/command.go b/resource/command.go index 365867d2..a7c48c43 100644 --- a/resource/command.go +++ b/resource/command.go @@ -13,15 +13,16 @@ import ( ) type Command struct { - Title string `json:"title,omitempty" yaml:"title,omitempty"` - Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` - id string `json:"-" yaml:"-"` - Exec string `json:"exec,omitempty" yaml:"exec,omitempty"` - ExitStatus matcher `json:"exit-status" yaml:"exit-status"` - Stdout matcher `json:"stdout" yaml:"stdout"` - Stderr matcher `json:"stderr" yaml:"stderr"` - Timeout int `json:"timeout" yaml:"timeout"` - Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` + Marks []string `json:"marks,omitempty" yaml:"marks,omitempty"` + id string `json:"-" yaml:"-"` + Exec string `json:"exec,omitempty" yaml:"exec,omitempty"` + ExitStatus matcher `json:"exit-status" yaml:"exit-status"` + Stdout matcher `json:"stdout" yaml:"stdout"` + Stderr matcher `json:"stderr" yaml:"stderr"` + Timeout int `json:"timeout" yaml:"timeout"` + Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"` } const ( @@ -39,8 +40,10 @@ func (c *Command) SetSkip() { c.Skip = true } func (c *Command) TypeKey() string { return CommandResourceKey } func (c *Command) TypeName() string { return CommandResourceName } -func (c *Command) GetTitle() string { return c.Title } -func (c *Command) GetMeta() meta { return c.Meta } +func (c *Command) GetTitle() string { return c.Title } +func (c *Command) GetMeta() meta { return c.Meta } +func (c *Command) GetMarks() []string { return c.Marks } +func (c *Command) SetMarks(m []string) { c.Marks = m } func (c *Command) GetExec() string { if c.Exec != "" { return c.Exec diff --git a/resource/dns.go b/resource/dns.go index 6cd11400..410ce791 100644 --- a/resource/dns.go +++ b/resource/dns.go @@ -11,16 +11,17 @@ import ( ) type DNS struct { - Title string `json:"title,omitempty" yaml:"title,omitempty"` - Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` - id string `json:"-" yaml:"-"` - Resolve string `json:"resolve,omitempty" yaml:"resolve,omitempty"` - Resolveable matcher `json:"resolveable,omitempty" yaml:"resolveable,omitempty"` - Resolvable matcher `json:"resolvable" yaml:"resolvable"` - Addrs matcher `json:"addrs,omitempty" yaml:"addrs,omitempty"` - Timeout int `json:"timeout" yaml:"timeout"` - Server string `json:"server,omitempty" yaml:"server,omitempty"` - Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` + Marks []string `json:"marks,omitempty" yaml:"marks,omitempty"` + id string `json:"-" yaml:"-"` + Resolve string `json:"resolve,omitempty" yaml:"resolve,omitempty"` + Resolveable matcher `json:"resolveable,omitempty" yaml:"resolveable,omitempty"` + Resolvable matcher `json:"resolvable" yaml:"resolvable"` + Addrs matcher `json:"addrs,omitempty" yaml:"addrs,omitempty"` + Timeout int `json:"timeout" yaml:"timeout"` + Server string `json:"server,omitempty" yaml:"server,omitempty"` + Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"` } const ( @@ -38,12 +39,14 @@ func (d *DNS) ID() string { } return d.id } -func (d *DNS) SetID(id string) { d.id = id } -func (d *DNS) SetSkip() { d.Skip = true } -func (d *DNS) TypeKey() string { return DNSResourceKey } -func (d *DNS) TypeName() string { return DNSResourceName } -func (d *DNS) GetTitle() string { return d.Title } -func (d *DNS) GetMeta() meta { return d.Meta } +func (d *DNS) SetID(id string) { d.id = id } +func (d *DNS) SetSkip() { d.Skip = true } +func (d *DNS) TypeKey() string { return DNSResourceKey } +func (d *DNS) TypeName() string { return DNSResourceName } +func (d *DNS) GetTitle() string { return d.Title } +func (d *DNS) GetMeta() meta { return d.Meta } +func (d *DNS) GetMarks() []string { return d.Marks } +func (d *DNS) SetMarks(m []string) { d.Marks = m } func (d *DNS) GetResolve() string { if d.Resolve != "" { return d.Resolve diff --git a/resource/file.go b/resource/file.go index 6b3c94c4..ad00c804 100644 --- a/resource/file.go +++ b/resource/file.go @@ -10,25 +10,26 @@ import ( ) type File struct { - Title string `json:"title,omitempty" yaml:"title,omitempty"` - Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` - id string `json:"-" yaml:"-"` - Path string `json:"path,omitempty" yaml:"path,omitempty"` - Exists matcher `json:"exists" yaml:"exists"` - Mode matcher `json:"mode,omitempty" yaml:"mode,omitempty"` - Size matcher `json:"size,omitempty" yaml:"size,omitempty"` - Owner matcher `json:"owner,omitempty" yaml:"owner,omitempty"` - Uid matcher `json:"uid,omitempty" yaml:"uid,omitempty"` - Group matcher `json:"group,omitempty" yaml:"group,omitempty"` - Gid matcher `json:"gid,omitempty" yaml:"gid,omitempty"` - LinkedTo matcher `json:"linked-to,omitempty" yaml:"linked-to,omitempty"` - Filetype matcher `json:"filetype,omitempty" yaml:"filetype,omitempty"` - Contains matcher `json:"contains,omitempty" yaml:"contains,omitempty"` - Contents matcher `json:"contents" yaml:"contents"` - Md5 matcher `json:"md5,omitempty" yaml:"md5,omitempty"` - Sha256 matcher `json:"sha256,omitempty" yaml:"sha256,omitempty"` - Sha512 matcher `json:"sha512,omitempty" yaml:"sha512,omitempty"` - Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` + Marks []string `json:"marks,omitempty" yaml:"marks,omitempty"` + id string `json:"-" yaml:"-"` + Path string `json:"path,omitempty" yaml:"path,omitempty"` + Exists matcher `json:"exists" yaml:"exists"` + Mode matcher `json:"mode,omitempty" yaml:"mode,omitempty"` + Size matcher `json:"size,omitempty" yaml:"size,omitempty"` + Owner matcher `json:"owner,omitempty" yaml:"owner,omitempty"` + Uid matcher `json:"uid,omitempty" yaml:"uid,omitempty"` + Group matcher `json:"group,omitempty" yaml:"group,omitempty"` + Gid matcher `json:"gid,omitempty" yaml:"gid,omitempty"` + LinkedTo matcher `json:"linked-to,omitempty" yaml:"linked-to,omitempty"` + Filetype matcher `json:"filetype,omitempty" yaml:"filetype,omitempty"` + Contains matcher `json:"contains,omitempty" yaml:"contains,omitempty"` + Contents matcher `json:"contents" yaml:"contents"` + Md5 matcher `json:"md5,omitempty" yaml:"md5,omitempty"` + Sha256 matcher `json:"sha256,omitempty" yaml:"sha256,omitempty"` + Sha512 matcher `json:"sha512,omitempty" yaml:"sha512,omitempty"` + Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"` } const ( @@ -51,8 +52,10 @@ func (f *File) SetSkip() { f.Skip = true } func (f *File) TypeKey() string { return FileResourceKey } func (f *File) TypeName() string { return FileResourceName } -func (f *File) GetTitle() string { return f.Title } -func (f *File) GetMeta() meta { return f.Meta } +func (f *File) GetTitle() string { return f.Title } +func (f *File) GetMeta() meta { return f.Meta } +func (f *File) GetMarks() []string { return f.Marks } +func (f *File) SetMarks(m []string) { f.Marks = m } func (f *File) GetPath() string { if f.Path != "" { return f.Path diff --git a/resource/gossfile.go b/resource/gossfile.go index 0be10f53..f331e613 100644 --- a/resource/gossfile.go +++ b/resource/gossfile.go @@ -6,11 +6,12 @@ import ( ) type Gossfile struct { - Title string `json:"title,omitempty" yaml:"title,omitempty"` - Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` - Path string `json:"-" yaml:"-"` - Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"` - File string `json:"file,omitempty" yaml:"file,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` + Marks []string `json:"marks,omitempty" yaml:"marks,omitempty"` + Path string `json:"-" yaml:"-"` + Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"` + File string `json:"file,omitempty" yaml:"file,omitempty"` } const ( @@ -28,8 +29,10 @@ func (g *Gossfile) SetSkip() {} func (g *Gossfile) TypeKey() string { return GossFileResourceKey } func (g *Gossfile) TypeName() string { return GossFileResourceName } -func (g *Gossfile) GetTitle() string { return g.Title } -func (g *Gossfile) GetMeta() meta { return g.Meta } +func (g *Gossfile) GetTitle() string { return g.Title } +func (g *Gossfile) GetMeta() meta { return g.Meta } +func (g *Gossfile) GetMarks() []string { return g.Marks } +func (g *Gossfile) SetMarks(m []string) { g.Marks = m } func (g *Gossfile) GetSkip() bool { return g.Skip } diff --git a/resource/group.go b/resource/group.go index cfd71a3a..d3797b44 100644 --- a/resource/group.go +++ b/resource/group.go @@ -9,13 +9,14 @@ import ( ) type Group struct { - Title string `json:"title,omitempty" yaml:"title,omitempty"` - Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` - id string `json:"-" yaml:"-"` - Groupname string `json:"groupname,omitempty" yaml:"groupname,omitempty"` - Exists matcher `json:"exists" yaml:"exists"` - GID matcher `json:"gid,omitempty" yaml:"gid,omitempty"` - Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` + Marks []string `json:"marks,omitempty" yaml:"marks,omitempty"` + id string `json:"-" yaml:"-"` + Groupname string `json:"groupname,omitempty" yaml:"groupname,omitempty"` + Exists matcher `json:"exists" yaml:"exists"` + GID matcher `json:"gid,omitempty" yaml:"gid,omitempty"` + Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"` } const ( @@ -33,12 +34,14 @@ func (g *Group) ID() string { } return g.id } -func (g *Group) SetID(id string) { g.id = id } -func (g *Group) SetSkip() { g.Skip = true } -func (g *Group) TypeKey() string { return GroupResourceKey } -func (g *Group) TypeName() string { return GroupResourceName } -func (g *Group) GetTitle() string { return g.Title } -func (g *Group) GetMeta() meta { return g.Meta } +func (g *Group) SetID(id string) { g.id = id } +func (g *Group) SetSkip() { g.Skip = true } +func (g *Group) TypeKey() string { return GroupResourceKey } +func (g *Group) TypeName() string { return GroupResourceName } +func (g *Group) GetTitle() string { return g.Title } +func (g *Group) GetMeta() meta { return g.Meta } +func (g *Group) GetMarks() []string { return g.Marks } +func (g *Group) SetMarks(m []string) { g.Marks = m } func (g *Group) GetGroupname() string { if g.Groupname != "" { return g.Groupname diff --git a/resource/http.go b/resource/http.go index bbd82009..b16f5faa 100644 --- a/resource/http.go +++ b/resource/http.go @@ -12,6 +12,7 @@ import ( type HTTP struct { Title string `json:"title,omitempty" yaml:"title,omitempty"` Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` + Marks []string `json:"marks,omitempty" yaml:"marks,omitempty"` id string `json:"-" yaml:"-"` URL string `json:"url,omitempty" yaml:"url,omitempty"` Method string `json:"method,omitempty" yaml:"method,omitempty"` @@ -53,8 +54,10 @@ func (u *HTTP) TypeKey() string { return HTTPResourceKey } func (u *HTTP) TypeName() string { return HTTPResourceName } // FIXME: Can this be refactored? -func (r *HTTP) GetTitle() string { return r.Title } -func (r *HTTP) GetMeta() meta { return r.Meta } +func (r *HTTP) GetTitle() string { return r.Title } +func (r *HTTP) GetMeta() meta { return r.Meta } +func (r *HTTP) GetMarks() []string { return r.Marks } +func (r *HTTP) SetMarks(m []string) { r.Marks = m } func (r *HTTP) getURL() string { if r.URL != "" { return r.URL diff --git a/resource/interface.go b/resource/interface.go index 68e3fa3d..6f6c8ae8 100644 --- a/resource/interface.go +++ b/resource/interface.go @@ -9,14 +9,15 @@ import ( ) type Interface struct { - Title string `json:"title,omitempty" yaml:"title,omitempty"` - Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` - id string `json:"-" yaml:"-"` - Name string `json:"name,omitempty" yaml:"name,omitempty"` - Exists matcher `json:"exists" yaml:"exists"` - Addrs matcher `json:"addrs,omitempty" yaml:"addrs,omitempty"` - MTU matcher `json:"mtu,omitempty" yaml:"mtu,omitempty"` - Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` + Marks []string `json:"marks,omitempty" yaml:"marks,omitempty"` + id string `json:"-" yaml:"-"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Exists matcher `json:"exists" yaml:"exists"` + Addrs matcher `json:"addrs,omitempty" yaml:"addrs,omitempty"` + MTU matcher `json:"mtu,omitempty" yaml:"mtu,omitempty"` + Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"` } const ( @@ -40,8 +41,10 @@ func (i *Interface) TypeKey() string { return InterfaceResourceKey } func (i *Interface) TypeName() string { return InterfaceResourceName } // FIXME: Can this be refactored? -func (i *Interface) GetTitle() string { return i.Title } -func (i *Interface) GetMeta() meta { return i.Meta } +func (i *Interface) GetTitle() string { return i.Title } +func (i *Interface) GetMeta() meta { return i.Meta } +func (i *Interface) GetMarks() []string { return i.Marks } +func (i *Interface) SetMarks(m []string) { i.Marks = m } func (i *Interface) GetName() string { if i.Name != "" { return i.Name diff --git a/resource/kernel_param.go b/resource/kernel_param.go index 7f50270b..3e250caa 100644 --- a/resource/kernel_param.go +++ b/resource/kernel_param.go @@ -9,13 +9,14 @@ import ( ) type KernelParam struct { - Title string `json:"title,omitempty" yaml:"title,omitempty"` - Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` - id string `json:"-" yaml:"-"` - Name string `json:"name,omitempty" yaml:"name,omitempty"` - Key string `json:"-" yaml:"-"` - Value matcher `json:"value" yaml:"value"` - Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` + Marks []string `json:"marks,omitempty" yaml:"marks,omitempty"` + id string `json:"-" yaml:"-"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Key string `json:"-" yaml:"-"` + Value matcher `json:"value" yaml:"value"` + Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"` } const ( @@ -40,8 +41,10 @@ func (a *KernelParam) TypeKey() string { return KernelParamResourceKey } func (a *KernelParam) TypeName() string { return KernelParamResourceName } // FIXME: Can this be refactored? -func (k *KernelParam) GetTitle() string { return k.Title } -func (k *KernelParam) GetMeta() meta { return k.Meta } +func (k *KernelParam) GetTitle() string { return k.Title } +func (k *KernelParam) GetMeta() meta { return k.Meta } +func (k *KernelParam) GetMarks() []string { return k.Marks } +func (k *KernelParam) SetMarks(m []string) { k.Marks = m } func (k *KernelParam) GetName() string { if k.Name != "" { return k.Name diff --git a/resource/marks_test.go b/resource/marks_test.go new file mode 100644 index 00000000..40fdb098 --- /dev/null +++ b/resource/marks_test.go @@ -0,0 +1,156 @@ +package resource + +import ( + "encoding/json" + "testing" + + yaml "gopkg.in/yaml.v3" +) + +// TestResourceMarksRoundTrip ensures the Marks field round-trips through both +// JSON and YAML encoders for every resource type that supports it. +func TestResourceMarksRoundTrip(t *testing.T) { + t.Parallel() + + marks := []string{"critical", "network"} + + cases := []struct { + name string + newRes func() interface { + GetMarks() []string + } + }{ + {"Addr", func() interface{ GetMarks() []string } { return &Addr{Marks: marks} }}, + {"Command", func() interface{ GetMarks() []string } { return &Command{Marks: marks} }}, + {"DNS", func() interface{ GetMarks() []string } { return &DNS{Marks: marks} }}, + {"File", func() interface{ GetMarks() []string } { return &File{Marks: marks} }}, + {"Gossfile", func() interface{ GetMarks() []string } { return &Gossfile{Marks: marks} }}, + {"Group", func() interface{ GetMarks() []string } { return &Group{Marks: marks} }}, + {"HTTP", func() interface{ GetMarks() []string } { return &HTTP{Marks: marks} }}, + {"Interface", func() interface{ GetMarks() []string } { return &Interface{Marks: marks} }}, + {"KernelParam", func() interface{ GetMarks() []string } { return &KernelParam{Marks: marks} }}, + {"Matching", func() interface{ GetMarks() []string } { return &Matching{Marks: marks} }}, + {"Mount", func() interface{ GetMarks() []string } { return &Mount{Marks: marks} }}, + {"Package", func() interface{ GetMarks() []string } { return &Package{Marks: marks} }}, + {"Port", func() interface{ GetMarks() []string } { return &Port{Marks: marks} }}, + {"Process", func() interface{ GetMarks() []string } { return &Process{Marks: marks} }}, + {"Service", func() interface{ GetMarks() []string } { return &Service{Marks: marks} }}, + {"User", func() interface{ GetMarks() []string } { return &User{Marks: marks} }}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name+"/json", func(t *testing.T) { + t.Parallel() + res := tc.newRes() + data, err := json.Marshal(res) + if err != nil { + t.Fatalf("marshal: %v", err) + } + out := tc.newRes() + // reset to zero by allocating a fresh instance via reflection-free path: + // simply unmarshal into a freshly constructed value of the same type. + out = freshOf(res) + if err := json.Unmarshal(data, out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + got := out.GetMarks() + if len(got) != len(marks) || got[0] != "critical" || got[1] != "network" { + t.Errorf("marks round-trip failed: got %v, want %v", got, marks) + } + }) + t.Run(tc.name+"/yaml", func(t *testing.T) { + t.Parallel() + res := tc.newRes() + data, err := yaml.Marshal(res) + if err != nil { + t.Fatalf("marshal: %v", err) + } + out := freshOf(res) + if err := yaml.Unmarshal(data, out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + got := out.GetMarks() + if len(got) != len(marks) || got[0] != "critical" || got[1] != "network" { + t.Errorf("marks round-trip failed: got %v, want %v", got, marks) + } + }) + } +} + +// freshOf returns a freshly-constructed value of the same dynamic type as src +// so we can round-trip into a clean target. +func freshOf(src interface{ GetMarks() []string }) interface{ GetMarks() []string } { + switch src.(type) { + case *Addr: + return &Addr{} + case *Command: + return &Command{} + case *DNS: + return &DNS{} + case *File: + return &File{} + case *Gossfile: + return &Gossfile{} + case *Group: + return &Group{} + case *HTTP: + return &HTTP{} + case *Interface: + return &Interface{} + case *KernelParam: + return &KernelParam{} + case *Matching: + return &Matching{} + case *Mount: + return &Mount{} + case *Package: + return &Package{} + case *Port: + return &Port{} + case *Process: + return &Process{} + case *Service: + return &Service{} + case *User: + return &User{} + default: + return nil + } +} + +// TestResourceMarksEmptyOmitted ensures the Marks field is omitted from JSON +// output when empty, preserving backward compatibility for existing gossfiles. +func TestResourceMarksEmptyOmitted(t *testing.T) { + t.Parallel() + cases := []struct { + name string + res interface{} + }{ + {"HTTP", &HTTP{Status: 200}}, + {"Command", &Command{ExitStatus: 0}}, + {"File", &File{Exists: true}}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + data, err := json.Marshal(tc.res) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if containsString(string(data), "marks") { + t.Errorf("expected no 'marks' key in JSON output, got: %s", string(data)) + } + }) + } +} + +func containsString(haystack, needle string) bool { + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return true + } + } + return false +} diff --git a/resource/matching.go b/resource/matching.go index 5b608753..98480939 100644 --- a/resource/matching.go +++ b/resource/matching.go @@ -12,13 +12,14 @@ import ( ) type Matching struct { - Title string `json:"title,omitempty" yaml:"title,omitempty"` - Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` - Content any `json:"content,omitempty" yaml:"content,omitempty"` - AsReader bool `json:"as-reader,omitempty" yaml:"as-reader,omitempty"` - id string `json:"-" yaml:"-"` - Matches matcher `json:"matches" yaml:"matches"` - Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` + Marks []string `json:"marks,omitempty" yaml:"marks,omitempty"` + Content any `json:"content,omitempty" yaml:"content,omitempty"` + AsReader bool `json:"as-reader,omitempty" yaml:"as-reader,omitempty"` + id string `json:"-" yaml:"-"` + Matches matcher `json:"matches" yaml:"matches"` + Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"` } const ( @@ -35,8 +36,10 @@ func (a *Matching) TypeKey() string { return MatchingResourceKey } func (a *Matching) TypeName() string { return MatchingResourceName } // FIXME: Can this be refactored? -func (r *Matching) GetTitle() string { return r.Title } -func (r *Matching) GetMeta() meta { return r.Meta } +func (r *Matching) GetTitle() string { return r.Title } +func (r *Matching) GetMeta() meta { return r.Meta } +func (r *Matching) GetMarks() []string { return r.Marks } +func (r *Matching) SetMarks(m []string) { r.Marks = m } func (a *Matching) Validate(sys *system.System) []TestResult { skip := false diff --git a/resource/mount.go b/resource/mount.go index 4410cbb2..ceae7577 100644 --- a/resource/mount.go +++ b/resource/mount.go @@ -9,18 +9,19 @@ import ( ) type Mount struct { - Title string `json:"title,omitempty" yaml:"title,omitempty"` - Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` - id string `json:"-" yaml:"-"` - MountPoint string `json:"mountpoint,omitempty" yaml:"mountpoint,omitempty"` - Exists matcher `json:"exists" yaml:"exists"` - Opts matcher `json:"opts,omitempty" yaml:"opts,omitempty"` - VfsOpts matcher `json:"vfs-opts,omitempty" yaml:"vfs-opts,omitempty"` - Source matcher `json:"source,omitempty" yaml:"source,omitempty"` - Filesystem matcher `json:"filesystem,omitempty" yaml:"filesystem,omitempty"` - Timeout int `json:"timeout" yaml:"timeout"` - Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"` - Usage matcher `json:"usage,omitempty" yaml:"usage,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` + Marks []string `json:"marks,omitempty" yaml:"marks,omitempty"` + id string `json:"-" yaml:"-"` + MountPoint string `json:"mountpoint,omitempty" yaml:"mountpoint,omitempty"` + Exists matcher `json:"exists" yaml:"exists"` + Opts matcher `json:"opts,omitempty" yaml:"opts,omitempty"` + VfsOpts matcher `json:"vfs-opts,omitempty" yaml:"vfs-opts,omitempty"` + Source matcher `json:"source,omitempty" yaml:"source,omitempty"` + Filesystem matcher `json:"filesystem,omitempty" yaml:"filesystem,omitempty"` + Timeout int `json:"timeout" yaml:"timeout"` + Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"` + Usage matcher `json:"usage,omitempty" yaml:"usage,omitempty"` } const ( @@ -44,8 +45,10 @@ func (m *Mount) TypeKey() string { return MountResourceKey } func (m *Mount) TypeName() string { return MountResourceName } // FIXME: Can this be refactored? -func (m *Mount) GetTitle() string { return m.Title } -func (m *Mount) GetMeta() meta { return m.Meta } +func (m *Mount) GetTitle() string { return m.Title } +func (m *Mount) GetMeta() meta { return m.Meta } +func (m *Mount) GetMarks() []string { return m.Marks } +func (m *Mount) SetMarks(ms []string) { m.Marks = ms } func (m *Mount) GetMountPoint() string { if m.MountPoint != "" { return m.MountPoint diff --git a/resource/package.go b/resource/package.go index 6d393a1b..7d1b268b 100644 --- a/resource/package.go +++ b/resource/package.go @@ -9,13 +9,14 @@ import ( ) type Package struct { - Title string `json:"title,omitempty" yaml:"title,omitempty"` - Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` - id string `json:"-" yaml:"-"` - Name string `json:"name,omitempty" yaml:"name,omitempty"` - Installed matcher `json:"installed" yaml:"installed"` - Versions matcher `json:"versions,omitempty" yaml:"versions,omitempty"` - Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` + Marks []string `json:"marks,omitempty" yaml:"marks,omitempty"` + id string `json:"-" yaml:"-"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Installed matcher `json:"installed" yaml:"installed"` + Versions matcher `json:"versions,omitempty" yaml:"versions,omitempty"` + Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"` } const ( @@ -33,12 +34,14 @@ func (p *Package) ID() string { } return p.id } -func (p *Package) SetID(id string) { p.id = id } -func (p *Package) SetSkip() { p.Skip = true } -func (p *Package) TypeKey() string { return PackageResourceKey } -func (p *Package) TypeName() string { return PackageResourceName } -func (p *Package) GetTitle() string { return p.Title } -func (p *Package) GetMeta() meta { return p.Meta } +func (p *Package) SetID(id string) { p.id = id } +func (p *Package) SetSkip() { p.Skip = true } +func (p *Package) TypeKey() string { return PackageResourceKey } +func (p *Package) TypeName() string { return PackageResourceName } +func (p *Package) GetTitle() string { return p.Title } +func (p *Package) GetMeta() meta { return p.Meta } +func (p *Package) GetMarks() []string { return p.Marks } +func (p *Package) SetMarks(m []string) { p.Marks = m } func (p *Package) GetName() string { if p.Name != "" { return p.Name diff --git a/resource/port.go b/resource/port.go index 02c32859..88fce3b5 100644 --- a/resource/port.go +++ b/resource/port.go @@ -9,13 +9,14 @@ import ( ) type Port struct { - Title string `json:"title,omitempty" yaml:"title,omitempty"` - Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` - id string `json:"-" yaml:"-"` - Port string `json:"port,omitempty" yaml:"port,omitempty"` - Listening matcher `json:"listening" yaml:"listening"` - IP matcher `json:"ip,omitempty" yaml:"ip,omitempty"` - Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` + Marks []string `json:"marks,omitempty" yaml:"marks,omitempty"` + id string `json:"-" yaml:"-"` + Port string `json:"port,omitempty" yaml:"port,omitempty"` + Listening matcher `json:"listening" yaml:"listening"` + IP matcher `json:"ip,omitempty" yaml:"ip,omitempty"` + Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"` } const ( @@ -33,12 +34,14 @@ func (p *Port) ID() string { } return p.id } -func (p *Port) SetID(id string) { p.id = id } -func (p *Port) SetSkip() { p.Skip = true } -func (p *Port) TypeKey() string { return PortResourceKey } -func (p *Port) TypeName() string { return PortResourceName } -func (p *Port) GetTitle() string { return p.Title } -func (p *Port) GetMeta() meta { return p.Meta } +func (p *Port) SetID(id string) { p.id = id } +func (p *Port) SetSkip() { p.Skip = true } +func (p *Port) TypeKey() string { return PortResourceKey } +func (p *Port) TypeName() string { return PortResourceName } +func (p *Port) GetTitle() string { return p.Title } +func (p *Port) GetMeta() meta { return p.Meta } +func (p *Port) GetMarks() []string { return p.Marks } +func (p *Port) SetMarks(m []string) { p.Marks = m } func (p *Port) GetPort() string { if p.Port != "" { return p.Port diff --git a/resource/process.go b/resource/process.go index c7413d7e..7eafa4f2 100644 --- a/resource/process.go +++ b/resource/process.go @@ -9,12 +9,13 @@ import ( ) type Process struct { - Title string `json:"title,omitempty" yaml:"title,omitempty"` - Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` - id string `json:"-" yaml:"-"` - Comm string `json:"comm,omitempty" yaml:"comm,omitempty"` - Running matcher `json:"running" yaml:"running"` - Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` + Marks []string `json:"marks,omitempty" yaml:"marks,omitempty"` + id string `json:"-" yaml:"-"` + Comm string `json:"comm,omitempty" yaml:"comm,omitempty"` + Running matcher `json:"running" yaml:"running"` + Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"` } const ( @@ -32,12 +33,14 @@ func (p *Process) ID() string { } return p.id } -func (p *Process) SetID(id string) { p.id = id } -func (p *Process) SetSkip() { p.Skip = true } -func (p *Process) TypeKey() string { return ProcessResourceKey } -func (p *Process) TypeName() string { return ProcessResourceName } -func (p *Process) GetTitle() string { return p.Title } -func (p *Process) GetMeta() meta { return p.Meta } +func (p *Process) SetID(id string) { p.id = id } +func (p *Process) SetSkip() { p.Skip = true } +func (p *Process) TypeKey() string { return ProcessResourceKey } +func (p *Process) TypeName() string { return ProcessResourceName } +func (p *Process) GetTitle() string { return p.Title } +func (p *Process) GetMeta() meta { return p.Meta } +func (p *Process) GetMarks() []string { return p.Marks } +func (p *Process) SetMarks(m []string) { p.Marks = m } func (p *Process) GetComm() string { if p.Comm != "" { return p.Comm diff --git a/resource/resource.go b/resource/resource.go index 104972ad..0c50eb35 100644 --- a/resource/resource.go +++ b/resource/resource.go @@ -16,6 +16,7 @@ type Resource interface { SetSkip() TypeKey() string TypeName() string + GetMarks() []string } var ( @@ -37,6 +38,8 @@ type ResourceRead interface { ID() string GetTitle() string GetMeta() meta + GetMarks() []string + SetMarks([]string) } type matcher any diff --git a/resource/resource_list.go b/resource/resource_list.go index 61b38159..b415d372 100644 --- a/resource/resource_list.go +++ b/resource/resource_list.go @@ -27,6 +27,7 @@ func (r AddrMap) AppendSysResource(sr string, sys *system.System, config util.Co if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, nil @@ -45,6 +46,7 @@ func (r AddrMap) AppendSysResourceIfExists(sr string, sys *system.System) (*Addr if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, sysres, true, nil @@ -128,6 +130,7 @@ func (r CommandMap) AppendSysResource(sr string, sys *system.System, config util if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, nil @@ -146,6 +149,7 @@ func (r CommandMap) AppendSysResourceIfExists(sr string, sys *system.System) (*C if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, sysres, true, nil @@ -229,6 +233,7 @@ func (r DNSMap) AppendSysResource(sr string, sys *system.System, config util.Con if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, nil @@ -247,6 +252,7 @@ func (r DNSMap) AppendSysResourceIfExists(sr string, sys *system.System) (*DNS, if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, sysres, true, nil @@ -330,6 +336,7 @@ func (r FileMap) AppendSysResource(sr string, sys *system.System, config util.Co if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, nil @@ -348,6 +355,7 @@ func (r FileMap) AppendSysResourceIfExists(sr string, sys *system.System) (*File if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, sysres, true, nil @@ -431,6 +439,7 @@ func (r GossfileMap) AppendSysResource(sr string, sys *system.System, config uti if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, nil @@ -449,6 +458,7 @@ func (r GossfileMap) AppendSysResourceIfExists(sr string, sys *system.System) (* if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, sysres, true, nil @@ -532,6 +542,7 @@ func (r GroupMap) AppendSysResource(sr string, sys *system.System, config util.C if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, nil @@ -550,6 +561,7 @@ func (r GroupMap) AppendSysResourceIfExists(sr string, sys *system.System) (*Gro if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, sysres, true, nil @@ -633,6 +645,7 @@ func (r PackageMap) AppendSysResource(sr string, sys *system.System, config util if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, nil @@ -651,6 +664,7 @@ func (r PackageMap) AppendSysResourceIfExists(sr string, sys *system.System) (*P if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, sysres, true, nil @@ -734,6 +748,7 @@ func (r PortMap) AppendSysResource(sr string, sys *system.System, config util.Co if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, nil @@ -752,6 +767,7 @@ func (r PortMap) AppendSysResourceIfExists(sr string, sys *system.System) (*Port if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, sysres, true, nil @@ -835,6 +851,7 @@ func (r ProcessMap) AppendSysResource(sr string, sys *system.System, config util if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, nil @@ -853,6 +870,7 @@ func (r ProcessMap) AppendSysResourceIfExists(sr string, sys *system.System) (*P if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, sysres, true, nil @@ -936,6 +954,7 @@ func (r ServiceMap) AppendSysResource(sr string, sys *system.System, config util if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, nil @@ -954,6 +973,7 @@ func (r ServiceMap) AppendSysResourceIfExists(sr string, sys *system.System) (*S if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, sysres, true, nil @@ -1037,6 +1057,7 @@ func (r UserMap) AppendSysResource(sr string, sys *system.System, config util.Co if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, nil @@ -1055,6 +1076,7 @@ func (r UserMap) AppendSysResourceIfExists(sr string, sys *system.System) (*User if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, sysres, true, nil @@ -1138,6 +1160,7 @@ func (r KernelParamMap) AppendSysResource(sr string, sys *system.System, config if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, nil @@ -1156,6 +1179,7 @@ func (r KernelParamMap) AppendSysResourceIfExists(sr string, sys *system.System) if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, sysres, true, nil @@ -1239,6 +1263,7 @@ func (r MountMap) AppendSysResource(sr string, sys *system.System, config util.C if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, nil @@ -1257,6 +1282,7 @@ func (r MountMap) AppendSysResourceIfExists(sr string, sys *system.System) (*Mou if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, sysres, true, nil @@ -1340,6 +1366,7 @@ func (r InterfaceMap) AppendSysResource(sr string, sys *system.System, config ut if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, nil @@ -1358,6 +1385,7 @@ func (r InterfaceMap) AppendSysResourceIfExists(sr string, sys *system.System) ( if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, sysres, true, nil @@ -1441,6 +1469,7 @@ func (r HTTPMap) AppendSysResource(sr string, sys *system.System, config util.Co if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, nil @@ -1459,6 +1488,7 @@ func (r HTTPMap) AppendSysResourceIfExists(sr string, sys *system.System) (*HTTP if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, sysres, true, nil diff --git a/resource/resource_list_genny.go b/resource/resource_list_genny.go index fccdea34..5b13b132 100644 --- a/resource/resource_list_genny.go +++ b/resource/resource_list_genny.go @@ -35,6 +35,7 @@ func (r ResourceTypeMap) AppendSysResource(sr string, sys *system.System, config if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, nil @@ -53,6 +54,7 @@ func (r ResourceTypeMap) AppendSysResourceIfExists(sr string, sys *system.System if old_res, ok := r[res.ID()]; ok { res.Title = old_res.Title res.Meta = old_res.Meta + res.Marks = old_res.Marks } r[res.ID()] = res return res, sysres, true, nil diff --git a/resource/service.go b/resource/service.go index 2f35e9d3..d946682e 100644 --- a/resource/service.go +++ b/resource/service.go @@ -9,14 +9,15 @@ import ( ) type Service struct { - Title string `json:"title,omitempty" yaml:"title,omitempty"` - Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` - id string `json:"-" yaml:"-"` - Name string `json:"name,omitempty" yaml:"name,omitempty"` - Enabled matcher `json:"enabled" yaml:"enabled"` - Running matcher `json:"running" yaml:"running"` - Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"` - RunLevels matcher `json:"runlevels,omitempty" yaml:"runlevels,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` + Marks []string `json:"marks,omitempty" yaml:"marks,omitempty"` + id string `json:"-" yaml:"-"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Enabled matcher `json:"enabled" yaml:"enabled"` + Running matcher `json:"running" yaml:"running"` + Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"` + RunLevels matcher `json:"runlevels,omitempty" yaml:"runlevels,omitempty"` } const ( @@ -34,12 +35,14 @@ func (s *Service) ID() string { } return s.id } -func (s *Service) SetID(id string) { s.id = id } -func (s *Service) SetSkip() { s.Skip = true } -func (s *Service) TypeKey() string { return ServiceResourceKey } -func (s *Service) TypeName() string { return ServiceResourceName } -func (s *Service) GetTitle() string { return s.Title } -func (s *Service) GetMeta() meta { return s.Meta } +func (s *Service) SetID(id string) { s.id = id } +func (s *Service) SetSkip() { s.Skip = true } +func (s *Service) TypeKey() string { return ServiceResourceKey } +func (s *Service) TypeName() string { return ServiceResourceName } +func (s *Service) GetTitle() string { return s.Title } +func (s *Service) GetMeta() meta { return s.Meta } +func (s *Service) GetMarks() []string { return s.Marks } +func (s *Service) SetMarks(m []string) { s.Marks = m } func (s *Service) GetName() string { if s.Name != "" { return s.Name diff --git a/resource/user.go b/resource/user.go index c1b64b06..33d49a8d 100644 --- a/resource/user.go +++ b/resource/user.go @@ -9,17 +9,18 @@ import ( ) type User struct { - Title string `json:"title,omitempty" yaml:"title,omitempty"` - Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` - id string `json:"-" yaml:"-"` - Username string `json:"username,omitempty" yaml:"username,omitempty"` - Exists matcher `json:"exists" yaml:"exists"` - UID matcher `json:"uid,omitempty" yaml:"uid,omitempty"` - GID matcher `json:"gid,omitempty" yaml:"gid,omitempty"` - Groups matcher `json:"groups,omitempty" yaml:"groups,omitempty"` - Home matcher `json:"home,omitempty" yaml:"home,omitempty"` - Shell matcher `json:"shell,omitempty" yaml:"shell,omitempty"` - Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Meta meta `json:"meta,omitempty" yaml:"meta,omitempty"` + Marks []string `json:"marks,omitempty" yaml:"marks,omitempty"` + id string `json:"-" yaml:"-"` + Username string `json:"username,omitempty" yaml:"username,omitempty"` + Exists matcher `json:"exists" yaml:"exists"` + UID matcher `json:"uid,omitempty" yaml:"uid,omitempty"` + GID matcher `json:"gid,omitempty" yaml:"gid,omitempty"` + Groups matcher `json:"groups,omitempty" yaml:"groups,omitempty"` + Home matcher `json:"home,omitempty" yaml:"home,omitempty"` + Shell matcher `json:"shell,omitempty" yaml:"shell,omitempty"` + Skip bool `json:"skip,omitempty" yaml:"skip,omitempty"` } const ( @@ -37,12 +38,14 @@ func (u *User) ID() string { } return u.id } -func (u *User) SetID(id string) { u.id = id } -func (u *User) SetSkip() { u.Skip = true } -func (u *User) TypeKey() string { return UserResourceKey } -func (u *User) TypeName() string { return UserResourceName } -func (u *User) GetTitle() string { return u.Title } -func (u *User) GetMeta() meta { return u.Meta } +func (u *User) SetID(id string) { u.id = id } +func (u *User) SetSkip() { u.Skip = true } +func (u *User) TypeKey() string { return UserResourceKey } +func (u *User) TypeName() string { return UserResourceName } +func (u *User) GetTitle() string { return u.Title } +func (u *User) GetMeta() meta { return u.Meta } +func (u *User) GetMarks() []string { return u.Marks } +func (u *User) SetMarks(m []string) { u.Marks = m } func (u *User) GetUsername() string { if u.Username != "" { return u.Username diff --git a/resource/validate_test.go b/resource/validate_test.go index 55a45020..b1354dce 100644 --- a/resource/validate_test.go +++ b/resource/validate_test.go @@ -19,6 +19,9 @@ func (f *FakeResource) GetTitle() string { return "title" } func (f *FakeResource) GetMeta() meta { return meta{"foo": "bar"} } +func (f *FakeResource) GetMarks() []string { return nil } +func (f *FakeResource) SetMarks(m []string) {} + var stringTests = []struct { in, in2 any want int diff --git a/serve.go b/serve.go index 858973c7..7a1ebf24 100644 --- a/serve.go +++ b/serve.go @@ -3,13 +3,11 @@ package goss import ( "bytes" "fmt" - "log" "net/http" "strings" "sync" "time" - "github.com/fatih/color" "github.com/goss-org/goss/outputs" "github.com/goss-org/goss/resource" "github.com/goss-org/goss/system" @@ -30,15 +28,18 @@ func Serve(c *util.Config) error { } http.Handle(endpoint, health) http.Handle("/metrics", promhttp.Handler()) - log.Printf("[INFO] Starting to listen on: %s", c.ListenAddress) + c.Log().Printf("[INFO] Starting to listen on: %s", c.ListenAddress) return http.ListenAndServe(c.ListenAddress, nil) } func newHealthHandler(c *util.Config) (*healthHandler, error) { - color.NoColor = true + // The serve endpoint always produces machine-readable output, so disable + // ANSI color codes. Using util.InitNoColor (sync.Once) avoids racing with + // concurrent requests or other initialization paths. + util.InitNoColor(true) cache := cache.New(c.Cache, 30*time.Second) - cfg, err := getGossConfig(c.Vars, c.VarsInline, c.Spec) + cfg, err := getGossConfig(c, c.Vars, c.VarsInline, c.Spec) if err != nil { return nil, err } @@ -74,17 +75,27 @@ type healthHandler struct { maxConcurrent int } +// markFilter holds the mark filters effective for a single request. Query +// parameters take precedence over the values from the server config. +type markFilter struct { + includeMarks []string + excludeMarks []string +} + func (h healthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + logger := h.c.Log() outputFormat, outputer, err := h.negotiateResponseContentType(r) if err != nil { - log.Printf("[DEBUG] Warn: Using process-level output-format. %s", err) + logger.Printf("[DEBUG] Warn: Using process-level output-format. %s", err) outputFormat = h.c.OutputFormat outputer = h.outputer } negotiatedContentType := h.responseContentType(outputFormat) - log.Printf("[TRACE] %v: requesting health probe", r.RemoteAddr) - resp := h.processAndEnsureCached(negotiatedContentType, outputer) + mf := h.resolveMarkFilter(r) + + logger.Printf("[TRACE] %v: requesting health probe", r.RemoteAddr) + resp := h.processAndEnsureCached(negotiatedContentType, outputer, mf) w.Header().Set(http.CanonicalHeaderKey("Content-Type"), negotiatedContentType) //nolint:gosimple w.WriteHeader(resp.statusCode) logBody := "" @@ -92,20 +103,55 @@ func (h healthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { logBody = " - " + resp.body.String() } resp.body.WriteTo(w) - log.Printf("[DEBUG] %v: status %d%s", r.RemoteAddr, resp.statusCode, logBody) + logger.Printf("[DEBUG] %v: status %d%s", r.RemoteAddr, resp.statusCode, logBody) +} + +// resolveMarkFilter builds the effective mark filter for a request. +// Query parameters override the server-level config values when set. +func (h healthHandler) resolveMarkFilter(r *http.Request) markFilter { + mf := markFilter{ + includeMarks: h.c.IncludeMarks, + excludeMarks: h.c.ExcludeMarks, + } + q := r.URL.Query() + if q.Has("marks") { + mf.includeMarks = util.ParseMarksParam(q.Get("marks")) + } + if q.Has("exclude-marks") { + mf.excludeMarks = util.ParseMarksParam(q.Get("exclude-marks")) + } + return mf } -func (h healthHandler) processAndEnsureCached(negotiatedContentType string, outputer outputs.Outputer) res { +// cacheKey returns a unique cache key per (include, exclude) mark combination. +// The empty filter case yields the legacy "res" key, preserving existing +// cache semantics for callers that do not use marks. +func (mf markFilter) cacheKey() string { + if len(mf.includeMarks) == 0 && len(mf.excludeMarks) == 0 { + return "res" + } + key := "res" + if len(mf.includeMarks) > 0 { + key += ":include=" + strings.Join(mf.includeMarks, ",") + } + if len(mf.excludeMarks) > 0 { + key += ":exclude=" + strings.Join(mf.excludeMarks, ",") + } + return key +} + +func (h healthHandler) processAndEnsureCached(negotiatedContentType string, outputer outputs.Outputer, mf markFilter) res { + logger := h.c.Log() var tra [][]resource.TestResult - cacheKey := "res" + cacheKey := mf.cacheKey() tmp, found := h.cache.Get(cacheKey) if found { - log.Printf("[TRACE] Returning cached[%s].", cacheKey) + logger.Printf("[TRACE] Returning cached[%s].", cacheKey) tra = tmp.([][]resource.TestResult) } else { - log.Printf("Stale cache[%s], running tests", cacheKey) + logger.Printf("Stale cache[%s], running tests", cacheKey) h.sys = system.New(h.c.PackageManager) - tra = h.validate() + tra = h.validate(mf) h.cache.SetDefault(cacheKey, tra) } trc := testResultArrayToChan(tra) @@ -116,6 +162,7 @@ func (h healthHandler) output(trc <-chan []resource.TestResult, outputer outputs var b bytes.Buffer outputConfig := util.OutputConfig{ FormatOptions: h.c.FormatOptions, + Logger: h.c.Logger, } exitCode := outputer.Output(&b, trc, outputConfig) resp := res{ @@ -128,16 +175,142 @@ func (h healthHandler) output(trc <-chan []resource.TestResult, outputer outputs } return resp } -func (h healthHandler) validate() [][]resource.TestResult { +func (h healthHandler) validate(mf markFilter) [][]resource.TestResult { + // Serialize concurrent validations against the shared gossConfig so that + // per-request mark filters do not leak Skip state across requests. + // validate() may call SetSkip() on resources to filter them; we snapshot + // and restore the originals to keep the in-memory config stable. + h.gossMu.Lock() + defer h.gossMu.Unlock() + + originalSkips := snapshotSkips(h.gossConfig) + defer restoreSkips(h.gossConfig, originalSkips) + h.sys = system.New(h.c.PackageManager) res := make([][]resource.TestResult, 0) - tr := validate(h.sys, h.gossConfig, h.c.DisabledResourceTypes, h.maxConcurrent) + tr := validate(h.sys, h.gossConfig, h.c.DisabledResourceTypes, mf.includeMarks, mf.excludeMarks, h.maxConcurrent, h.c.Log()) for i := range tr { res = append(res, i) } return res } +// snapshotSkips records the current Skip state of every resource so it can be +// restored after validate() mutates them via SetSkip(). +// +// Background: validate() calls Resource.SetSkip() to filter resources from +// execution. In the serve path, the gossConfig is shared across requests, so +// per-request filters (e.g. ?marks=critical) would otherwise leak Skip state +// into subsequent requests. We snapshot before validation and restore after +// to keep the in-memory config stable. +func snapshotSkips(cfg GossConfig) map[string]bool { + m := make(map[string]bool) + for _, r := range cfg.Resources() { + m[resourceKey(r)] = readSkip(r) + } + return m +} + +// restoreSkips re-applies the original Skip flags so subsequent validate() +// invocations start from a clean state. +func restoreSkips(cfg GossConfig, originals map[string]bool) { + for _, r := range cfg.Resources() { + key := resourceKey(r) + writeSkip(r, originals[key]) + } +} + +// resourceKey produces a stable identity for a Resource within a single +// gossConfig. TypeKey + ID is unique because each resource type has its +// own keyed map in the config. +func resourceKey(r resource.Resource) string { + type ider interface{ ID() string } + id := "" + if i, ok := r.(ider); ok { + id = i.ID() + } + return r.TypeKey() + ":" + id +} + +// readSkip returns the current Skip flag of a resource via a type switch. +// We avoid reflection for hot-path performance. +func readSkip(r resource.Resource) bool { + switch v := r.(type) { + case *resource.Addr: + return v.Skip + case *resource.Command: + return v.Skip + case *resource.DNS: + return v.Skip + case *resource.File: + return v.Skip + case *resource.Gossfile: + return v.Skip + case *resource.Group: + return v.Skip + case *resource.HTTP: + return v.Skip + case *resource.Interface: + return v.Skip + case *resource.KernelParam: + return v.Skip + case *resource.Matching: + return v.Skip + case *resource.Mount: + return v.Skip + case *resource.Package: + return v.Skip + case *resource.Port: + return v.Skip + case *resource.Process: + return v.Skip + case *resource.Service: + return v.Skip + case *resource.User: + return v.Skip + default: + return false + } +} + +// writeSkip sets the Skip flag of a resource via a type switch. +func writeSkip(r resource.Resource, skip bool) { + switch v := r.(type) { + case *resource.Addr: + v.Skip = skip + case *resource.Command: + v.Skip = skip + case *resource.DNS: + v.Skip = skip + case *resource.File: + v.Skip = skip + case *resource.Gossfile: + v.Skip = skip + case *resource.Group: + v.Skip = skip + case *resource.HTTP: + v.Skip = skip + case *resource.Interface: + v.Skip = skip + case *resource.KernelParam: + v.Skip = skip + case *resource.Matching: + v.Skip = skip + case *resource.Mount: + v.Skip = skip + case *resource.Package: + v.Skip = skip + case *resource.Port: + v.Skip = skip + case *resource.Process: + v.Skip = skip + case *resource.Service: + v.Skip = skip + case *resource.User: + v.Skip = skip + } +} + func testResultArrayToChan(tra [][]resource.TestResult) <-chan []resource.TestResult { c := make(chan []resource.TestResult) go func(c chan []resource.TestResult) { diff --git a/serve_marks_test.go b/serve_marks_test.go new file mode 100644 index 00000000..e0680a74 --- /dev/null +++ b/serve_marks_test.go @@ -0,0 +1,301 @@ +package goss + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/goss-org/goss/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// jsonOutput is the minimal subset of the JSON response we care about +// when asserting on which tests ran. +type jsonOutput struct { + Results []struct { + Resource string `json:"resource-id"` + ResourceType string `json:"resource-type"` + Property string `json:"property"` + Skipped bool `json:"skipped"` + } `json:"results"` + Summary struct { + TestCount int `json:"test-count"` + SkippedCount int `json:"skipped-count"` + FailedCount int `json:"failed-count"` + } `json:"summary"` +} + +func makeMarkRequest(t *testing.T, query string) *http.Request { + t.Helper() + url := "/healthz" + if query != "" { + url += "?" + query + } + req, err := http.NewRequest(http.MethodGet, url, nil) + require.NoError(t, err) + req.Header.Set("Accept", "application/json") + return req +} + +func decodeJSON(t *testing.T, body *bytes.Buffer) jsonOutput { + t.Helper() + var out jsonOutput + require.NoError(t, json.Unmarshal(body.Bytes(), &out)) + return out +} + +// TestServeWithMarksQueryParam verifies that ?marks= filters tests to +// only those bearing one of the supplied marks. +func TestServeWithMarksQueryParam(t *testing.T) { + t.Parallel() + tl := util.NewTestLogger(t) + + cfg, err := util.NewConfig( + util.WithSpecFile(filepath.Join("testdata", "marks.goss.yaml")), + util.WithOutputFormat("json"), + util.WithLogger(tl), + ) + require.NoError(t, err) + + hh, err := newHealthHandler(cfg) + require.NoError(t, err) + + rr := httptest.NewRecorder() + hh.ServeHTTP(rr, makeMarkRequest(t, "marks=critical")) + + require.Equal(t, http.StatusOK, rr.Code) + out := decodeJSON(t, rr.Body) + + // We expect 6 result rows (3 commands * 2 properties), 4 skipped (slow + unmarked). + assert.Equal(t, 6, out.Summary.TestCount) + assert.Equal(t, 4, out.Summary.SkippedCount) +} + +// TestServeWithExcludeMarksQueryParam verifies that ?exclude-marks= +// skips tests that bear any of the supplied marks. +func TestServeWithExcludeMarksQueryParam(t *testing.T) { + t.Parallel() + tl := util.NewTestLogger(t) + + cfg, err := util.NewConfig( + util.WithSpecFile(filepath.Join("testdata", "marks.goss.yaml")), + util.WithOutputFormat("json"), + util.WithLogger(tl), + ) + require.NoError(t, err) + + hh, err := newHealthHandler(cfg) + require.NoError(t, err) + + rr := httptest.NewRecorder() + hh.ServeHTTP(rr, makeMarkRequest(t, "exclude-marks=slow")) + + require.Equal(t, http.StatusOK, rr.Code) + out := decodeJSON(t, rr.Body) + + // Only the slow command should be skipped (2 properties), critical + unmarked run. + assert.Equal(t, 6, out.Summary.TestCount) + assert.Equal(t, 2, out.Summary.SkippedCount) +} + +// TestServeWithCombinedMarksQueryParams verifies inclusion is applied first, +// then exclusion. +func TestServeWithCombinedMarksQueryParams(t *testing.T) { + t.Parallel() + tl := util.NewTestLogger(t) + + cfg, err := util.NewConfig( + util.WithSpecFile(filepath.Join("testdata", "marks.goss.yaml")), + util.WithOutputFormat("json"), + util.WithLogger(tl), + ) + require.NoError(t, err) + + hh, err := newHealthHandler(cfg) + require.NoError(t, err) + + rr := httptest.NewRecorder() + hh.ServeHTTP(rr, makeMarkRequest(t, "marks=critical,slow&exclude-marks=slow")) + + require.Equal(t, http.StatusOK, rr.Code) + out := decodeJSON(t, rr.Body) + + // Include both critical and slow, then exclude slow. Only critical should run (2 properties). + // Skipped: slow + unmarked = 4 properties. + assert.Equal(t, 6, out.Summary.TestCount) + assert.Equal(t, 4, out.Summary.SkippedCount) +} + +// TestServeCacheIsolationPerMarks verifies that different mark combinations +// use distinct cache keys, preventing cross-contamination of cached results. +func TestServeCacheIsolationPerMarks(t *testing.T) { + t.Parallel() + tl := util.NewTestLogger(t) + + cfg, err := util.NewConfig( + util.WithSpecFile(filepath.Join("testdata", "marks.goss.yaml")), + util.WithOutputFormat("json"), + util.WithCache(5*time.Second), + util.WithLogger(tl), + ) + require.NoError(t, err) + + hh, err := newHealthHandler(cfg) + require.NoError(t, err) + + // Subtests are NOT parallel within this test: they deliberately share + // cache state and must run sequentially. + t.Run("first request: no marks", func(t *testing.T) { + rr := httptest.NewRecorder() + hh.ServeHTTP(rr, makeMarkRequest(t, "")) + require.Equal(t, http.StatusOK, rr.Code) + out := decodeJSON(t, rr.Body) + assert.Equal(t, 0, out.Summary.SkippedCount, "no marks: nothing should be skipped") + assert.Contains(t, tl.String(), "Stale cache[res]", "first request should miss cache") + tl.Reset() + }) + + t.Run("second request: ?marks=critical does not return cached unfiltered result", func(t *testing.T) { + rr := httptest.NewRecorder() + hh.ServeHTTP(rr, makeMarkRequest(t, "marks=critical")) + require.Equal(t, http.StatusOK, rr.Code) + out := decodeJSON(t, rr.Body) + // Critical filter: slow + unmarked skipped (4 properties) + assert.Equal(t, 4, out.Summary.SkippedCount, "must apply mark filter, not return cached unfiltered result") + assert.Contains(t, tl.String(), "res:include=critical", "should use mark-specific cache key") + tl.Reset() + }) + + t.Run("third request: same marks hits cache", func(t *testing.T) { + rr := httptest.NewRecorder() + hh.ServeHTTP(rr, makeMarkRequest(t, "marks=critical")) + require.Equal(t, http.StatusOK, rr.Code) + assert.NotContains(t, tl.String(), "Stale cache", "second identical request should hit cache") + assert.Contains(t, tl.String(), "Returning cached[res:include=critical]") + tl.Reset() + }) + + t.Run("fourth request: different marks miss cache", func(t *testing.T) { + rr := httptest.NewRecorder() + hh.ServeHTTP(rr, makeMarkRequest(t, "exclude-marks=slow")) + require.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, tl.String(), "Stale cache[res:exclude=slow]", "different mark combo should miss cache") + tl.Reset() + }) +} + +// TestServeQueryParamsOverrideConfig verifies that query parameters take +// precedence over server-level config marks. +func TestServeQueryParamsOverrideConfig(t *testing.T) { + t.Parallel() + tl := util.NewTestLogger(t) + + cfg, err := util.NewConfig( + util.WithSpecFile(filepath.Join("testdata", "marks.goss.yaml")), + util.WithOutputFormat("json"), + util.WithIncludeMarks("slow"), // server-level: only slow + util.WithLogger(tl), + ) + require.NoError(t, err) + + hh, err := newHealthHandler(cfg) + require.NoError(t, err) + + rr := httptest.NewRecorder() + // Override with query param: only critical + hh.ServeHTTP(rr, makeMarkRequest(t, "marks=critical")) + + require.Equal(t, http.StatusOK, rr.Code) + out := decodeJSON(t, rr.Body) + // Critical filter: slow + unmarked skipped (4 properties) + assert.Equal(t, 4, out.Summary.SkippedCount, + "query parameter marks must override config marks") + + // And the server-level config should not have been mutated + assert.Equal(t, []string{"slow"}, cfg.IncludeMarks, + "per-request mark filter must not mutate shared config") +} + +// TestServeQueryParamsConcurrent verifies that concurrent requests with +// different mark combinations do not race on shared state. This is the +// regression test for the Skip-state mutation issue documented in the +// marks-feature design doc (section "Resource Skip State Mutation Across +// Requests"); without the snapshot/restore of skip flags under gossMu, +// request N's skip decisions would leak into request N+1. +func TestServeQueryParamsConcurrent(t *testing.T) { + t.Parallel() + tl := util.NewTestLogger(t) + + cfg, err := util.NewConfig( + util.WithSpecFile(filepath.Join("testdata", "marks.goss.yaml")), + util.WithOutputFormat("json"), + util.WithLogger(tl), + ) + require.NoError(t, err) + + hh, err := newHealthHandler(cfg) + require.NoError(t, err) + + const N = 20 + var wg sync.WaitGroup + wg.Add(N) + for i := 0; i < N; i++ { + query := "marks=critical" + expected := 4 + if i%2 == 0 { + query = "exclude-marks=slow" + expected = 2 + } + go func(query string, expected int) { + defer wg.Done() + rr := httptest.NewRecorder() + hh.ServeHTTP(rr, makeMarkRequest(t, query)) + if rr.Code != http.StatusOK { + t.Errorf("query=%q got status %d", query, rr.Code) + return + } + out := decodeJSON(t, rr.Body) + if out.Summary.SkippedCount != expected { + t.Errorf("query=%q got SkippedCount=%d want %d", query, out.Summary.SkippedCount, expected) + } + }(query, expected) + } + wg.Wait() + + // Both cache keys should appear in logs. + logs := tl.String() + assert.True(t, strings.Contains(logs, "res:include=critical") || strings.Contains(logs, "Returning cached[res:include=critical]")) + assert.True(t, strings.Contains(logs, "res:exclude=slow") || strings.Contains(logs, "Returning cached[res:exclude=slow]")) +} + +// TestServeMarkFilterCacheKey verifies the cache key generation for various +// mark filter combinations. +func TestServeMarkFilterCacheKey(t *testing.T) { + t.Parallel() + cases := []struct { + name string + mf markFilter + want string + }{ + {"empty", markFilter{}, "res"}, + {"include only", markFilter{includeMarks: []string{"critical"}}, "res:include=critical"}, + {"exclude only", markFilter{excludeMarks: []string{"slow"}}, "res:exclude=slow"}, + {"both", markFilter{includeMarks: []string{"a", "b"}, excludeMarks: []string{"c"}}, "res:include=a,b:exclude=c"}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := tc.mf.cacheKey(); got != tc.want { + t.Errorf("cacheKey() = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/serve_test.go b/serve_test.go index c7f64a26..3ae48dd4 100644 --- a/serve_test.go +++ b/serve_test.go @@ -1,8 +1,6 @@ package goss import ( - "bytes" - "log" "net/http" "net/http/httptest" "path/filepath" @@ -15,7 +13,11 @@ import ( ) func TestServeWithNoContentNegotiation(t *testing.T) { - t.Parallel() + // NOTE: cannot use t.Parallel() because this test and its peers call + // log.SetOutput to redirect the process-wide standard logger into a + // local bytes.Buffer and then read from it. Running in parallel with + // other tests that do the same causes races on both the shared logger + // and the buffers. tests := map[string]struct { outputFormat string specFile string @@ -44,12 +46,13 @@ func TestServeWithNoContentNegotiation(t *testing.T) { for testName := range tests { tc := tests[testName] t.Run(testName, func(t *testing.T) { - var logOutput bytes.Buffer - log.SetOutput(&logOutput) + t.Parallel() + tl := util.NewTestLogger(t) config, err := util.NewConfig( util.WithSpecFile(tc.specFile), util.WithOutputFormat(tc.outputFormat), + util.WithLogger(tl), ) require.NoError(t, err) @@ -63,7 +66,7 @@ func TestServeWithNoContentNegotiation(t *testing.T) { handler.ServeHTTP(rr, req) - t.Logf("testName %q log output:\n%s", testName, logOutput.String()) + t.Logf("testName %q log output:\n%s", testName, tl.String()) assert.Equal(t, tc.expectedHTTPStatus, rr.Code) if tc.expectedContentType != "" { assert.Equal(t, tc.expectedContentType, rr.Result().Header.Get("Content-Type")) @@ -73,7 +76,11 @@ func TestServeWithNoContentNegotiation(t *testing.T) { } func TestServeNegotiatingContent(t *testing.T) { - t.Parallel() + // NOTE: cannot use t.Parallel() because this test and its peers call + // log.SetOutput to redirect the process-wide standard logger into a + // local bytes.Buffer and then read from it. Running in parallel with + // other tests that do the same causes races on both the shared logger + // and the buffers. tests := map[string]struct { acceptHeader []string outputFormat string @@ -158,12 +165,13 @@ func TestServeNegotiatingContent(t *testing.T) { for testName := range tests { tc := tests[testName] t.Run(testName, func(t *testing.T) { - var logOutput bytes.Buffer - log.SetOutput(&logOutput) + t.Parallel() + tl := util.NewTestLogger(t) config, err := util.NewConfig( util.WithSpecFile(tc.specFile), util.WithOutputFormat(tc.outputFormat), + util.WithLogger(tl), ) require.NoError(t, err) @@ -179,7 +187,7 @@ func TestServeNegotiatingContent(t *testing.T) { handler.ServeHTTP(rr, req) - t.Logf("testName %q log output:\n%s", testName, logOutput.String()) + t.Logf("testName %q log output:\n%s", testName, tl.String()) assert.Equal(t, tc.expectedHTTPStatus, rr.Code) if tc.expectedContentType != "" { assert.Equal(t, tc.expectedContentType, rr.Result().Header.Get("Content-Type")) @@ -189,12 +197,13 @@ func TestServeNegotiatingContent(t *testing.T) { } func TestServeCacheWithNoContentNegotiation(t *testing.T) { - var logOutput bytes.Buffer - log.SetOutput(&logOutput) + t.Parallel() + tl := util.NewTestLogger(t) const cache = time.Duration(time.Millisecond * 100) config, err := util.NewConfig( util.WithSpecFile(filepath.Join("testdata", "passing.goss.yaml")), util.WithCache(cache), + util.WithLogger(tl), ) require.NoError(t, err) @@ -210,18 +219,18 @@ func TestServeCacheWithNoContentNegotiation(t *testing.T) { handler.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Result().StatusCode) - assert.Contains(t, logOutput.String(), "Stale cache") - t.Log(logOutput.String()) - logOutput.Reset() + assert.Contains(t, tl.String(), "Stale cache") + t.Log(tl.String()) + tl.Reset() }) t.Run("immediately re-request, cache should be warm", func(t *testing.T) { handler.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Result().StatusCode) - assert.NotContains(t, logOutput.String(), "Stale cache") - t.Log(logOutput.String()) - logOutput.Reset() + assert.NotContains(t, tl.String(), "Stale cache") + t.Log(tl.String()) + tl.Reset() }) t.Run("allow cache to expire, cache should be cold", func(t *testing.T) { @@ -229,20 +238,21 @@ func TestServeCacheWithNoContentNegotiation(t *testing.T) { handler.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Result().StatusCode) - assert.Contains(t, logOutput.String(), "Stale cache") - t.Log(logOutput.String()) - logOutput.Reset() + assert.Contains(t, tl.String(), "Stale cache") + t.Log(tl.String()) + tl.Reset() }) } func TestServeCacheNegotiatingContent(t *testing.T) { - var logOutput bytes.Buffer - log.SetOutput(&logOutput) + t.Parallel() + tl := util.NewTestLogger(t) const cache = time.Duration(time.Millisecond * 100) config, err := util.NewConfig( util.WithSpecFile(filepath.Join("testdata", "passing.goss.yaml")), util.WithCache(cache), util.WithOutputFormat("structured"), + util.WithLogger(tl), ) require.NoError(t, err) @@ -260,9 +270,9 @@ func TestServeCacheNegotiatingContent(t *testing.T) { handler.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Result().StatusCode) - assert.Contains(t, logOutput.String(), "Stale cache") - t.Log(logOutput.String()) - logOutput.Reset() + assert.Contains(t, tl.String(), "Stale cache") + t.Log(tl.String()) + tl.Reset() }) t.Run("immediately re-request, cache should be warm", func(t *testing.T) { @@ -272,9 +282,9 @@ func TestServeCacheNegotiatingContent(t *testing.T) { handler.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Result().StatusCode) - assert.NotContains(t, logOutput.String(), "Stale cache") - t.Log(logOutput.String()) - logOutput.Reset() + assert.NotContains(t, tl.String(), "Stale cache") + t.Log(tl.String()) + tl.Reset() }) t.Run("immediately re-request but different accept header, cache should be warm", func(t *testing.T) { @@ -284,9 +294,9 @@ func TestServeCacheNegotiatingContent(t *testing.T) { handler.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Result().StatusCode) - assert.NotContains(t, logOutput.String(), "Stale cache") - t.Log(logOutput.String()) - logOutput.Reset() + assert.NotContains(t, tl.String(), "Stale cache") + t.Log(tl.String()) + tl.Reset() }) t.Run("allow cache to expire, cache should be cold", func(t *testing.T) { @@ -297,9 +307,9 @@ func TestServeCacheNegotiatingContent(t *testing.T) { handler.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Result().StatusCode) - assert.Contains(t, logOutput.String(), "Stale cache") - t.Log(logOutput.String()) - logOutput.Reset() + assert.Contains(t, tl.String(), "Stale cache") + t.Log(tl.String()) + tl.Reset() }) } diff --git a/store.go b/store.go index d3d3af0e..3346f45b 100644 --- a/store.go +++ b/store.go @@ -3,12 +3,12 @@ package goss import ( "encoding/json" "fmt" - "log" "os" "path/filepath" "reflect" "sort" "strings" + "sync" "gopkg.in/yaml.v3" @@ -22,9 +22,63 @@ const ( YAML ) -var outStoreFormat = UNSET -var currentTemplateFilter TemplateFilter -var debug = false +// storeStateMu guards the package-level store-configuration variables +// (outStoreFormat, currentTemplateFilter, debug). These are written during +// config load (RenderJSON, getGossConfig) and read during ReadJSONData. The +// goss serve command drives this code path concurrently from multiple +// goroutines, so accesses must be synchronised. +var storeStateMu sync.RWMutex + +// The following package-level variables are protected by storeStateMu. +// Do not read or write them directly from outside this file; use the +// get*/set* helpers below. +var ( + outStoreFormat = UNSET + currentTemplateFilter TemplateFilter = nil + debug = false +) + +// setStoreFormat atomically updates the package-level store format. +func setStoreFormat(f int) { + storeStateMu.Lock() + defer storeStateMu.Unlock() + outStoreFormat = f +} + +// getStoreFormat atomically reads the package-level store format. +func getStoreFormat() int { + storeStateMu.RLock() + defer storeStateMu.RUnlock() + return outStoreFormat +} + +// setTemplateFilter atomically updates the package-level template filter. +func setTemplateFilter(tf TemplateFilter) { + storeStateMu.Lock() + defer storeStateMu.Unlock() + currentTemplateFilter = tf +} + +// getTemplateFilter atomically reads the package-level template filter. +func getTemplateFilter() TemplateFilter { + storeStateMu.RLock() + defer storeStateMu.RUnlock() + return currentTemplateFilter +} + +// setDebug atomically updates the package-level debug flag. +func setDebug(d bool) { + storeStateMu.Lock() + defer storeStateMu.Unlock() + debug = d +} + +// getDebug atomically reads the package-level debug flag. +func getDebug() bool { + storeStateMu.RLock() + defer storeStateMu.RUnlock() + return debug +} func getStoreFormatFromFileName(f string) (int, error) { ext := filepath.Ext(f) @@ -130,18 +184,18 @@ func varsFromString(varsString string) (map[string]any, error) { // ReadJSONData Reads json byte array returning GossConfig func ReadJSONData(data []byte, detectFormat bool) (GossConfig, error) { var err error - if currentTemplateFilter != nil { - data, err = currentTemplateFilter(data) + if tf := getTemplateFilter(); tf != nil { + data, err = tf(data) if err != nil { return GossConfig{}, err } - if debug { + if getDebug() { fmt.Println("DEBUG: file after text/template render") fmt.Println(string(data)) } } - format := outStoreFormat + format := getStoreFormat() if detectFormat { format, err = getStoreFormatFromData(data) if err != nil { @@ -158,29 +212,35 @@ func ReadJSONData(data []byte, detectFormat bool) (GossConfig, error) { return *gossConfig, nil } -// RenderJSON reads json file recursively returning string +// RenderJSON reads json file recursively returning string. Any merge +// warnings accumulated while processing the spec (for example, duplicate +// resource keys across gossfiles) are emitted via c.Log(); RenderJSON is +// the edge layer between the pure config-merging core and the rest of the +// application, so it is the place where warnings become log lines. func RenderJSON(c *util.Config) (string, error) { - var err error - debug = c.Debug - currentTemplateFilter, err = NewTemplateFilter(c.Vars, c.VarsInline) + setDebug(c.Debug) + tf, err := NewTemplateFilter(c.Vars, c.VarsInline) if err != nil { return "", err } + setTemplateFilter(tf) - outStoreFormat, err = getStoreFormatFromFileName(c.Spec) + format, err := getStoreFormatFromFileName(c.Spec) if err != nil { return "", err } + setStoreFormat(format) j, err := ReadJSON(c.Spec) if err != nil { return "", err } - gossConfig, err := mergeJSONData(j, 0, filepath.Dir(c.Spec)) + gossConfig, warnings, err := mergeJSONData(j, 0, filepath.Dir(c.Spec)) if err != nil { return "", err } + logWarnings(c.Log(), warnings) b, err := marshal(gossConfig) if err != nil { @@ -190,14 +250,21 @@ func RenderJSON(c *util.Config) (string, error) { return string(b), nil } -func mergeJSONData(gossConfig GossConfig, depth int, path string) (GossConfig, error) { +// mergeJSONData walks the gossfile graph (up to a fixed recursion depth), +// merging the configs it reads along the way. It is intentionally pure with +// respect to logging: any warnings produced during merging are accumulated +// and returned for the caller to emit at the appropriate architectural +// boundary (typically the edge layer that holds a *util.Config). +func mergeJSONData(gossConfig GossConfig, depth int, path string) (GossConfig, []string, error) { depth++ if depth >= 50 { - return GossConfig{}, fmt.Errorf("max depth of 50 reached, possibly due to dependency loop in goss file") + return GossConfig{}, nil, fmt.Errorf("max depth of 50 reached, possibly due to dependency loop in goss file") } // Our return gossConfig ret := *NewGossConfig() - ret = mergeGoss(ret, gossConfig) + var warnings []string + ret, w := mergeGoss(ret, gossConfig) + warnings = append(warnings, w...) // Sort the gossfiles to ensure consistent ordering var keys []string @@ -221,50 +288,68 @@ func mergeJSONData(gossConfig GossConfig, depth int, path string) (GossConfig, e } matches, err := filepath.Glob(fpath) if err != nil { - return ret, fmt.Errorf("error in expanding glob pattern: %q", err) + return ret, warnings, fmt.Errorf("error in expanding glob pattern: %q", err) } if matches == nil { - return ret, fmt.Errorf("no matched files were found: %q", fpath) + return ret, warnings, fmt.Errorf("no matched files were found: %q", fpath) } for _, match := range matches { fdir := filepath.Dir(match) j, err := ReadJSON(match) if err != nil { - return GossConfig{}, fmt.Errorf("could not read json data in %s: %s", match, err) + return GossConfig{}, warnings, fmt.Errorf("could not read json data in %s: %s", match, err) } - j, err = mergeJSONData(j, depth, fdir) + var childWarnings []string + j, childWarnings, err = mergeJSONData(j, depth, fdir) + warnings = append(warnings, childWarnings...) if err != nil { - return ret, fmt.Errorf("could not write json data: %s", err) + return ret, warnings, fmt.Errorf("could not write json data: %s", err) } - ret = mergeGoss(ret, j) + var mergeWarnings []string + ret, mergeWarnings = mergeGoss(ret, j) + warnings = append(warnings, mergeWarnings...) } } - return ret, nil + return ret, warnings, nil } -func WriteJSON(filePath string, gossConfig GossConfig) error { +// WriteJSON marshals gossConfig and writes it to filePath. It is a pure +// function with respect to logging: if the configuration is empty (and +// therefore nothing is written), a human-readable warning string is +// returned alongside a nil error so the caller -- which has a *util.Config +// in scope -- can emit it via c.Log(). An empty warning string means the +// file was written normally. +func WriteJSON(filePath string, gossConfig GossConfig) (string, error) { jsonData, err := marshal(gossConfig) if err != nil { - return fmt.Errorf("failed to write %s: %s", filePath, err) + return "", fmt.Errorf("failed to write %s: %s", filePath, err) } // check if the auto added json data is empty before writing to file. emptyConfig := *NewGossConfig() emptyData, err := marshal(emptyConfig) if err != nil { - return fmt.Errorf("failed to write %s: %s", filePath, err) + return "", fmt.Errorf("failed to write %s: %s", filePath, err) } if string(emptyData) == string(jsonData) { - log.Printf("Can't write empty configuration file. Please check resource name(s).") - return nil + return "Can't write empty configuration file. Please check resource name(s).", nil } if err := os.WriteFile(filePath, jsonData, 0644); err != nil { - return fmt.Errorf("failed to write %s: %s", filePath, err) + return "", fmt.Errorf("failed to write %s: %s", filePath, err) } - return nil + return "", nil +} + +// logWarnings emits each entry of warnings via logger.Printf, preserving +// the pre-refactor format (warnings already include a "[WARN] " prefix +// where appropriate). It is a no-op for empty or nil slices. +func logWarnings(logger util.Logger, warnings []string) { + for _, w := range warnings { + logger.Printf("%s", w) + } } func resourcePrint(fileName string, res resource.ResourceRead, announce bool) { @@ -280,7 +365,7 @@ func resourcePrint(fileName string, res resource.ResourceRead, announce bool) { } func marshal(gossConfig any) ([]byte, error) { - switch outStoreFormat { + switch getStoreFormat() { case JSON: return marshalJSON(gossConfig) case YAML: diff --git a/system/command.go b/system/command.go index aea7ade9..f336b342 100644 --- a/system/command.go +++ b/system/command.go @@ -28,6 +28,7 @@ type DefCommand struct { loaded bool Timeout int err error + logger util.Logger } func NewDefCommand(ctx context.Context, command string, system *System, config util.Config) Command { @@ -35,6 +36,7 @@ func NewDefCommand(ctx context.Context, command string, system *System, config u Ctx: ctx, command: command, Timeout: config.TimeOutMilliSeconds(), + logger: config.Log(), } } @@ -55,8 +57,8 @@ func (c *DefCommand) setup() error { stdoutB := cmd.Stdout.Bytes() stderrB := cmd.Stderr.Bytes() id := c.Ctx.Value("id") - logBytes(stdoutB, fmt.Sprintf("[Command][%s][stdout] ", id)) - logBytes(stderrB, fmt.Sprintf("[Command][%s][stderr] ", id)) + logBytes(c.logger, stdoutB, fmt.Sprintf("[Command][%s][stdout] ", id)) + logBytes(c.logger, stderrB, fmt.Sprintf("[Command][%s][stderr] ", id)) c.stdout = bytes.NewReader(stdoutB) c.stderr = bytes.NewReader(stderrB) diff --git a/system/log.go b/system/log.go index f3c4d575..07e878e5 100644 --- a/system/log.go +++ b/system/log.go @@ -2,15 +2,21 @@ package system import ( "bytes" - "log" + + "github.com/goss-org/goss/util" ) -func logBytes(b []byte, prefix string) { +// logBytes emits each non-empty line of b to logger with the given prefix. +// Returning no values (rather than accumulating a string) keeps the caller +// simple; the logger is injected at the call site rather than looked up via +// a package-level global, which keeps this function free of hidden side +// effects on any shared sink. +func logBytes(logger util.Logger, b []byte, prefix string) { if len(b) == 0 { return } lines := bytes.Split(b, []byte("\n")) for _, l := range lines { - log.Printf("[DEBUG]%s %s", prefix, l) + logger.Printf("[DEBUG]%s %s", prefix, l) } } diff --git a/testdata/marks.goss.yaml b/testdata/marks.goss.yaml new file mode 100644 index 00000000..46bcf7de --- /dev/null +++ b/testdata/marks.goss.yaml @@ -0,0 +1,28 @@ +--- +command: + echo critical: + exit-status: 0 + exec: "echo critical" + stdout: + - critical + stderr: [] + timeout: 10000 + marks: + - critical + - fast + echo slow: + exit-status: 0 + exec: "echo slow" + stdout: + - slow + stderr: [] + timeout: 10000 + marks: + - slow + echo unmarked: + exit-status: 0 + exec: "echo unmarked" + stdout: + - unmarked + stderr: [] + timeout: 10000 diff --git a/util/color_init.go b/util/color_init.go new file mode 100644 index 00000000..76ee721c --- /dev/null +++ b/util/color_init.go @@ -0,0 +1,36 @@ +package util + +import ( + "sync" + + "github.com/fatih/color" +) + +// colorOnce ensures that color.NoColor is set exactly once, avoiding a data +// race when multiple goroutines (for example, concurrent HTTP requests served +// by goss in server mode, or parallel output writers) try to mutate the +// package-level color.NoColor variable from github.com/fatih/color. +// +// color.NoColor is a boolean that controls whether color output is disabled. +// Historically, goss code set it directly from several entry points which +// meant concurrent callers could race on the write. For goss's purposes the +// value only needs to be decided once, at process startup, based on the +// user's configuration (CLI flag, config, or output format). +// +// This lives in the util package (rather than goss or outputs) so both +// packages can share a single sync.Once instance -- otherwise two +// independent sync.Once guards would each perform a write to color.NoColor +// and racy reads from color formatting could observe one-or-the-other. +var colorOnce sync.Once + +// InitNoColor sets color.NoColor to the given value exactly once per process. +// Subsequent calls are no-ops. This is safe to call from multiple goroutines. +// +// Callers that want to explicitly disable color (e.g. machine-readable output +// formats such as JSON or JUnit) should call InitNoColor(true). Callers that +// want to respect the terminal's default should not call this function. +func InitNoColor(disable bool) { + colorOnce.Do(func() { + color.NoColor = disable + }) +} diff --git a/util/config.go b/util/config.go index 3be5fa84..bc29262e 100644 --- a/util/config.go +++ b/util/config.go @@ -55,6 +55,23 @@ type Config struct { Vars string VarsInline string DisabledResourceTypes []string + IncludeMarks []string + ExcludeMarks []string + // Logger is the sink for goss's own log output. If nil, Log() returns a + // DefaultLogger that delegates to the standard library log package, + // preserving pre-refactor behavior. Set via WithLogger for tests or + // embedders that want to redirect log output. + Logger Logger +} + +// Log returns the Logger configured on this Config, or a DefaultLogger when +// none has been set. It never returns nil and is safe to call on a +// zero-value *Config. +func (c *Config) Log() Logger { + if c.Logger != nil { + return c.Logger + } + return DefaultLogger{} } // TimeOutMilliSeconds is the timeout as milliseconds @@ -249,8 +266,69 @@ func WithDisabledResourceTypes(t ...string) ConfigOption { } } +// WithLogger injects a custom Logger. Passing nil explicitly clears any +// previously injected logger (Log() will then fall back to DefaultLogger). +func WithLogger(l Logger) ConfigOption { + return func(c *Config) error { + c.Logger = l + return nil + } +} + +// WithIncludeMarks restricts validation to resources that have at least one of the supplied marks +func WithIncludeMarks(marks ...string) ConfigOption { + return func(c *Config) error { + c.IncludeMarks = append(c.IncludeMarks, marks...) + return nil + } +} + +// WithExcludeMarks skips resources that have any of the supplied marks +func WithExcludeMarks(marks ...string) ConfigOption { + return func(c *Config) error { + c.ExcludeMarks = append(c.ExcludeMarks, marks...) + return nil + } +} + +// ParseMarksParam splits a comma-separated marks string into a normalized slice. +// Empty entries and surrounding whitespace are trimmed. Returns nil for empty input. +func ParseMarksParam(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + if len(out) == 0 { + return nil + } + return out +} + +// OutputConfig carries per-run configuration for Outputer implementations. +// It is deliberately narrow; fields here are the ones that outputs actually +// consume, not a mirror of the top-level Config. type OutputConfig struct { FormatOptions []string + // Logger is the sink for debug/trace messages emitted by output + // formatters (summary lines, per-test traces, etc.). If nil, Log() + // returns a DefaultLogger. + Logger Logger +} + +// Log returns the Logger configured on this OutputConfig, or a DefaultLogger +// when none has been set. Never returns nil. +func (o OutputConfig) Log() Logger { + if o.Logger != nil { + return o.Logger + } + return DefaultLogger{} } type format string diff --git a/util/config_test.go b/util/config_test.go index 91471874..fd95335d 100644 --- a/util/config_test.go +++ b/util/config_test.go @@ -49,3 +49,61 @@ func TestWithVarsData(t *testing.T) { t.Fatalf("expected %q got %q", `{"hello":"world"}`, c.VarsInline) } } + +func TestWithIncludeMarks(t *testing.T) { + c, err := NewConfig(WithIncludeMarks("critical", "network")) + if err != nil { + t.Fatal(err) + } + if len(c.IncludeMarks) != 2 || c.IncludeMarks[0] != "critical" || c.IncludeMarks[1] != "network" { + t.Fatalf("unexpected IncludeMarks: %v", c.IncludeMarks) + } +} + +func TestWithExcludeMarks(t *testing.T) { + c, err := NewConfig(WithExcludeMarks("slow", "flaky")) + if err != nil { + t.Fatal(err) + } + if len(c.ExcludeMarks) != 2 || c.ExcludeMarks[0] != "slow" || c.ExcludeMarks[1] != "flaky" { + t.Fatalf("unexpected ExcludeMarks: %v", c.ExcludeMarks) + } +} + +func TestWithMarksMultipleCallsAppend(t *testing.T) { + c, err := NewConfig(WithIncludeMarks("a"), WithIncludeMarks("b", "c")) + if err != nil { + t.Fatal(err) + } + if len(c.IncludeMarks) != 3 { + t.Fatalf("expected 3 marks got %v", c.IncludeMarks) + } +} + +func TestParseMarksParam(t *testing.T) { + cases := []struct { + in string + want []string + }{ + {"", nil}, + {" ", nil}, + {",,,", nil}, + {"critical", []string{"critical"}}, + {"critical,network", []string{"critical", "network"}}, + {" critical , network ", []string{"critical", "network"}}, + {"critical, ,network", []string{"critical", "network"}}, + {"a,b,c,d", []string{"a", "b", "c", "d"}}, + } + for _, tc := range cases { + got := ParseMarksParam(tc.in) + if len(got) != len(tc.want) { + t.Errorf("ParseMarksParam(%q) length = %d, want %d (got=%v)", tc.in, len(got), len(tc.want), got) + continue + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("ParseMarksParam(%q)[%d] = %q, want %q", tc.in, i, got[i], tc.want[i]) + } + } + } +} diff --git a/util/logger.go b/util/logger.go new file mode 100644 index 00000000..65f6ae45 --- /dev/null +++ b/util/logger.go @@ -0,0 +1,105 @@ +package util + +import ( + "bytes" + "fmt" + "log" + "sync" + "testing" +) + +// Logger is the minimal logging seam used throughout goss. It abstracts away +// the standard library log package to enable: +// +// 1. Parallel test execution with per-test log capture (the standard library +// log package exposes a single process-wide default logger whose output +// writer can only be swapped atomically, which races under t.Parallel). +// 2. Custom log sinks (files, structured writers, etc.) without requiring +// callers to mutate global state. +// 3. Test assertions on logged output without calling log.SetOutput. +// +// The interface intentionally mirrors the two standard library log functions +// most used by goss (Printf, Fatalf) so that migrating call sites requires +// only prefixing them with an accessor (e.g. "c.Log().Printf(...)"). +// Log levels continue to be expressed as message prefixes ("[DEBUG] ...", +// "[TRACE] ...") and filtered by hashicorp/logutils, matching the +// pre-refactor convention. +type Logger interface { + Printf(format string, v ...interface{}) + Fatalf(format string, v ...interface{}) +} + +// DefaultLogger is the production Logger. It delegates to the standard +// library log package, preserving byte-identical output with the pre-refactor +// implementation. It is the zero-value fallback returned by Config.Log when +// no logger has been explicitly injected. +type DefaultLogger struct{} + +// Printf delegates to log.Printf. +func (DefaultLogger) Printf(format string, v ...interface{}) { + log.Printf(format, v...) +} + +// Fatalf delegates to log.Fatalf (logs the message then calls os.Exit(1)). +func (DefaultLogger) Fatalf(format string, v ...interface{}) { + log.Fatalf(format, v...) +} + +// TestLogger is a goroutine-safe Logger for tests. It forwards messages to +// testing.T.Log (which the Go runtime documents as safe for concurrent use +// from any goroutine associated with the test) and also accumulates them in +// an internal buffer so tests can assert on log content with helpers like +// assert.Contains(t, tl.String(), "expected substring"). +// +// Construct via NewTestLogger. The zero value is not usable. +type TestLogger struct { + t *testing.T + mu sync.Mutex + buf bytes.Buffer +} + +// NewTestLogger returns a TestLogger bound to the given *testing.T. +func NewTestLogger(t *testing.T) *TestLogger { + return &TestLogger{t: t} +} + +// Printf formats according to a format specifier and records the result. +// The message is appended to the internal buffer (followed by a newline) and +// also emitted via t.Log so it appears in "-v" output next to the test that +// produced it. Safe for concurrent use. +func (tl *TestLogger) Printf(format string, v ...interface{}) { + msg := fmt.Sprintf(format, v...) + tl.mu.Lock() + tl.buf.WriteString(msg) + tl.buf.WriteByte('\n') + tl.mu.Unlock() + // t.Log is documented safe for concurrent use by goroutines associated + // with the test. It does not panic after the test has completed; it + // simply writes to the test's buffered log. + tl.t.Log(msg) +} + +// Fatalf formats the message and fails the current test via t.Fatalf. It +// does NOT call os.Exit (unlike log.Fatalf), which would tear down the whole +// test binary. Safe for concurrent use. +func (tl *TestLogger) Fatalf(format string, v ...interface{}) { + // t.Fatalf is documented safe for concurrent use from the test goroutine + // and goroutines created during the test. + tl.t.Fatalf(format, v...) +} + +// String returns the accumulated log output. Safe for concurrent use. +func (tl *TestLogger) String() string { + tl.mu.Lock() + defer tl.mu.Unlock() + return tl.buf.String() +} + +// Reset discards any accumulated log output. Useful between phases of a test +// that wants to assert on freshly-produced messages (e.g. separating +// cache-miss from cache-hit assertions). +func (tl *TestLogger) Reset() { + tl.mu.Lock() + defer tl.mu.Unlock() + tl.buf.Reset() +} diff --git a/util/logger_test.go b/util/logger_test.go new file mode 100644 index 00000000..e0b058eb --- /dev/null +++ b/util/logger_test.go @@ -0,0 +1,151 @@ +package util + +import ( + "bytes" + "fmt" + "log" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestLoggerInterfaceSatisfied is a compile-time check. If either +// DefaultLogger or *TestLogger stops satisfying Logger, this file will fail +// to compile. +var ( + _ Logger = DefaultLogger{} + _ Logger = (*TestLogger)(nil) +) + +func TestDefaultLogger_Printf(t *testing.T) { + // Capture the standard library log output via log.SetOutput, restoring + // the original sink afterwards so this test does not bleed into others. + var buf bytes.Buffer + origFlags := log.Flags() + origOutput := log.Writer() + log.SetFlags(0) // suppress timestamps for stable matching + log.SetOutput(&buf) + defer func() { + log.SetFlags(origFlags) + log.SetOutput(origOutput) + }() + + d := DefaultLogger{} + d.Printf("hello %s %d", "world", 42) + + assert.Equal(t, "hello world 42\n", buf.String()) +} + +func TestNewTestLogger_Printf_Records(t *testing.T) { + t.Parallel() + tl := NewTestLogger(t) + tl.Printf("hello %s", "world") + tl.Printf("second line: %d", 7) + + got := tl.String() + assert.Contains(t, got, "hello world") + assert.Contains(t, got, "second line: 7") + // Each Printf appends a newline. + assert.Equal(t, 2, strings.Count(got, "\n")) +} + +func TestTestLogger_Reset(t *testing.T) { + t.Parallel() + tl := NewTestLogger(t) + tl.Printf("before reset") + assert.Contains(t, tl.String(), "before reset") + + tl.Reset() + assert.Equal(t, "", tl.String()) + + tl.Printf("after reset") + got := tl.String() + assert.NotContains(t, got, "before reset") + assert.Contains(t, got, "after reset") +} + +// TestTestLogger_ConcurrentWrites exercises the mutex guarding the buffer. +// Run under -race, this asserts no data race and no lost writes. +func TestTestLogger_ConcurrentWrites(t *testing.T) { + t.Parallel() + tl := NewTestLogger(t) + + const goroutines = 50 + const perGoroutine = 20 + + var wg sync.WaitGroup + wg.Add(goroutines) + for i := 0; i < goroutines; i++ { + i := i + go func() { + defer wg.Done() + for j := 0; j < perGoroutine; j++ { + tl.Printf("g=%d j=%d", i, j) + } + }() + } + wg.Wait() + + // Every message must appear exactly once; no writes lost to races. + // Compare against the set of lines rather than substring-search, since + // "g=4 j=1" is a substring of "g=48 j=1". + lines := strings.Split(strings.TrimRight(tl.String(), "\n"), "\n") + seen := make(map[string]int, goroutines*perGoroutine) + for _, line := range lines { + seen[line]++ + } + assert.Equal(t, goroutines*perGoroutine, len(lines), "expected %d total lines", goroutines*perGoroutine) + for i := 0; i < goroutines; i++ { + for j := 0; j < perGoroutine; j++ { + want := fmt.Sprintf("g=%d j=%d", i, j) + assert.Equal(t, 1, seen[want], "expected exactly one occurrence of %q", want) + } + } +} + +func TestConfig_Log_NilFallback(t *testing.T) { + t.Parallel() + cfg := &Config{} + + got := cfg.Log() + require.NotNil(t, got, "Config.Log() must never return nil") + + // Must be usable without panicking. + assert.NotPanics(t, func() { + got.Printf("smoke test %d", 1) + }) +} + +func TestConfig_Log_CustomLogger(t *testing.T) { + t.Parallel() + tl := NewTestLogger(t) + cfg := &Config{Logger: tl} + + assert.Same(t, tl, cfg.Log(), "Config.Log() must return the injected logger verbatim") +} + +func TestWithLogger_SetsField(t *testing.T) { + t.Parallel() + tl := NewTestLogger(t) + + cfg, err := NewConfig(WithLogger(tl)) + require.NoError(t, err) + assert.Same(t, tl, cfg.Logger) + assert.Same(t, tl, cfg.Log()) +} + +// TestDefaultLogger_UsedWhenConfigLoggerNil ensures that calling Config.Log() +// on a freshly-built Config (no WithLogger applied) yields a DefaultLogger, +// not a zero value or nil. +func TestDefaultLogger_UsedWhenConfigLoggerNil(t *testing.T) { + t.Parallel() + cfg, err := NewConfig() + require.NoError(t, err) + require.Nil(t, cfg.Logger, "precondition: Config.Logger should be unset") + + _, ok := cfg.Log().(DefaultLogger) + assert.True(t, ok, "expected DefaultLogger, got %T", cfg.Log()) +} diff --git a/validate.go b/validate.go index 9e4c394c..a11556ee 100644 --- a/validate.go +++ b/validate.go @@ -18,16 +18,21 @@ import ( "github.com/goss-org/goss/util" ) -func getGossConfig(vars string, varsInline string, specFile string) (cfg *GossConfig, err error) { +// getGossConfig loads and merges gossfiles, emitting any merge warnings via +// c.Log(). It is an edge-layer function: it owns the translation between +// pure config-merging results (warnings returned as values) and the +// process's log sink. +func getGossConfig(c *util.Config, vars string, varsInline string, specFile string) (cfg *GossConfig, err error) { // handle stdin var fh *os.File var path, source string var gossConfig GossConfig - currentTemplateFilter, err = NewTemplateFilter(vars, varsInline) + tf, err := NewTemplateFilter(vars, varsInline) if err != nil { return nil, err } + setTemplateFilter(tf) if specFile == "-" { source = "STDIN" @@ -36,10 +41,11 @@ func getGossConfig(vars string, varsInline string, specFile string) (cfg *GossCo if err != nil { return nil, err } - outStoreFormat, err = getStoreFormatFromData(data) + storeFormat, err := getStoreFormatFromData(data) if err != nil { return nil, err } + setStoreFormat(storeFormat) gossConfig, err = ReadJSONData(data, true) if err != nil { @@ -48,10 +54,11 @@ func getGossConfig(vars string, varsInline string, specFile string) (cfg *GossCo } else { source = specFile path = filepath.Dir(specFile) - outStoreFormat, err = getStoreFormatFromFileName(specFile) + storeFormat, err := getStoreFormatFromFileName(specFile) if err != nil { return nil, err } + setStoreFormat(storeFormat) gossConfig, err = ReadJSON(specFile) if err != nil { @@ -59,10 +66,11 @@ func getGossConfig(vars string, varsInline string, specFile string) (cfg *GossCo } } - gossConfig, err = mergeJSONData(gossConfig, 0, path) + gossConfig, warnings, err := mergeJSONData(gossConfig, 0, path) if err != nil { return nil, err } + logWarnings(c.Log(), warnings) if len(gossConfig.Resources()) == 0 { return nil, fmt.Errorf("found 0 tests, source: %v", source) @@ -72,11 +80,13 @@ func getGossConfig(vars string, varsInline string, specFile string) (cfg *GossCo } func getOutputer(c *bool, format string) (outputs.Outputer, error) { - if c != nil && *c { - color.NoColor = true - } - if c != nil && !*c { - color.NoColor = false + // color.NoColor is a package-level global that was historically set + // directly here. To avoid races under parallel/serve workloads, we + // initialize it at most once per process. If the caller explicitly + // requested a value, honour it; otherwise leave the library's default + // (derived from terminal detection) in place. + if c != nil { + util.InitNoColor(*c) } return outputs.GetOutputer(format) @@ -85,14 +95,14 @@ func getOutputer(c *bool, format string) (outputs.Outputer, error) { // ValidateResults performs validation and provides programmatic access to validation results // no retries or outputs are supported func ValidateResults(c *util.Config) (results <-chan []resource.TestResult, err error) { - gossConfig, err := getGossConfig(c.Vars, c.VarsInline, c.Spec) + gossConfig, err := getGossConfig(c, c.Vars, c.VarsInline, c.Spec) if err != nil { return nil, err } sys := system.New(c.PackageManager) - return validate(sys, *gossConfig, c.DisabledResourceTypes, c.MaxConcurrent), nil + return validate(sys, *gossConfig, c.DisabledResourceTypes, c.IncludeMarks, c.ExcludeMarks, c.MaxConcurrent, c.Log()), nil } // Validate performs validation, writes formatted output to stdout by default @@ -104,21 +114,30 @@ func Validate(c *util.Config) (code int, err error) { if err != nil { return 1, err } - gossConfig, err := getGossConfig(c.Vars, c.VarsInline, c.Spec) + gossConfig, err := getGossConfig(c, c.Vars, c.VarsInline, c.Spec) if err != nil { return 78, err } return ValidateConfig(c, gossConfig) } +// gomegaFormatOnce guards the single mutation of the gomega format package's +// UseStringerRepresentation flag. gomega/format stores this as a package +// global, and ValidateConfig may be invoked concurrently under `goss serve`, +// so we set it exactly once per process. +var gomegaFormatOnce sync.Once + func ValidateConfig(c *util.Config, gossConfig *GossConfig) (code int, err error) { // Needed for contains-elements // Maybe we don't use this and use custom // contain_element_matcher is needed because it's single entry to avoid - // transform message - format.UseStringerRepresentation = true + // transform message. + gomegaFormatOnce.Do(func() { + format.UseStringerRepresentation = true + }) outputConfig := util.OutputConfig{ FormatOptions: c.FormatOptions, + Logger: c.Logger, } sys := system.New(c.PackageManager) @@ -138,7 +157,7 @@ func ValidateConfig(c *util.Config, gossConfig *GossConfig) (code int, err error i := 1 startTime := time.Now() for { - out := validate(sys, *gossConfig, c.DisabledResourceTypes, c.MaxConcurrent) + out := validate(sys, *gossConfig, c.DisabledResourceTypes, c.IncludeMarks, c.ExcludeMarks, c.MaxConcurrent, c.Log()) exitCode := outputer.Output(ofh, out, outputConfig) if retryTimeout == 0 || exitCode == 0 { return exitCode, nil @@ -156,19 +175,35 @@ func ValidateConfig(c *util.Config, gossConfig *GossConfig) (code int, err error } } -func validate(sys *system.System, gossConfig GossConfig, skipList []string, maxConcurrent int) <-chan []resource.TestResult { +func validate(sys *system.System, gossConfig GossConfig, skipList []string, includeMarks []string, excludeMarks []string, maxConcurrent int, logger util.Logger) <-chan []resource.TestResult { out := make(chan []resource.TestResult) in := make(chan resource.Resource) + // Emit a single informational summary when mark filters are active. + // This is gated behind the caller's log level (via logutils) by using + // the [DEBUG] prefix, so it is quiet at the default INFO level and + // opt-in when someone runs with -L DEBUG. + logMarkFilterSummary(logger, gossConfig, includeMarks, excludeMarks) + go func() { + filteredByMarks := 0 for _, t := range gossConfig.Resources() { if util.IsValueInList(t.TypeName(), skipList) || util.IsValueInList(t.TypeKey(), skipList) { t.SetSkip() } + if !shouldRunByMarks(t.GetMarks(), includeMarks, excludeMarks) { + t.SetSkip() + filteredByMarks++ + } + in <- t } close(in) + + if logger != nil && (len(includeMarks) > 0 || len(excludeMarks) > 0) { + logger.Printf("[DEBUG] marks filter skipped %d of %d resources", filteredByMarks, len(gossConfig.Resources())) + } }() workerCount := runtime.NumCPU() * 5 @@ -193,3 +228,56 @@ func validate(sys *system.System, gossConfig GossConfig, skipList []string, maxC return out } + +// shouldRunByMarks decides whether a resource with the given marks should run +// under the supplied include/exclude filters. +// +// Rules: +// - If includeMarks is non-empty, the resource must have at least one matching +// mark to run. Resources with no marks are excluded when includeMarks is set. +// - If excludeMarks is non-empty, the resource is skipped if it has any +// matching mark. +// - When both are set, inclusion is evaluated first, then exclusion. +// - When both are empty, the resource runs by default. +func shouldRunByMarks(resourceMarks, includeMarks, excludeMarks []string) bool { + if len(includeMarks) > 0 { + if !hasAnyMark(resourceMarks, includeMarks) { + return false + } + } + if len(excludeMarks) > 0 { + if hasAnyMark(resourceMarks, excludeMarks) { + return false + } + } + return true +} + +// logMarkFilterSummary emits a DEBUG line describing the active mark filters. +// It is a no-op when no filters are set or when logger is nil. The message is +// prefixed with [DEBUG] so hashicorp/logutils gates it by log level. +func logMarkFilterSummary(logger util.Logger, gossConfig GossConfig, includeMarks, excludeMarks []string) { + if logger == nil { + return + } + if len(includeMarks) == 0 && len(excludeMarks) == 0 { + return + } + logger.Printf("[DEBUG] mark filters active: include=%v exclude=%v total_resources=%d", + includeMarks, excludeMarks, len(gossConfig.Resources())) +} + +// hasAnyMark returns true if any element of resourceMarks is also in filterMarks. +func hasAnyMark(resourceMarks, filterMarks []string) bool { + if len(resourceMarks) == 0 || len(filterMarks) == 0 { + return false + } + for _, rm := range resourceMarks { + for _, fm := range filterMarks { + if rm == fm { + return true + } + } + } + return false +} diff --git a/validate_marks_test.go b/validate_marks_test.go new file mode 100644 index 00000000..87077449 --- /dev/null +++ b/validate_marks_test.go @@ -0,0 +1,272 @@ +package goss + +import ( + "path/filepath" + "testing" + + "github.com/goss-org/goss/resource" + "github.com/goss-org/goss/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHasAnyMark(t *testing.T) { + t.Parallel() + cases := []struct { + name string + resource []string + filter []string + want bool + }{ + {"both empty", nil, nil, false}, + {"resource empty filter set", nil, []string{"a"}, false}, + {"resource set filter empty", []string{"a"}, nil, false}, + {"single match", []string{"a"}, []string{"a"}, true}, + {"no overlap", []string{"a"}, []string{"b"}, false}, + {"partial overlap", []string{"a", "b"}, []string{"b", "c"}, true}, + {"full overlap", []string{"a", "b"}, []string{"a", "b"}, true}, + {"order independent", []string{"b", "a"}, []string{"c", "a"}, true}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := hasAnyMark(tc.resource, tc.filter); got != tc.want { + t.Errorf("hasAnyMark(%v, %v) = %v, want %v", tc.resource, tc.filter, got, tc.want) + } + }) + } +} + +func TestShouldRunByMarks(t *testing.T) { + t.Parallel() + cases := []struct { + name string + resource []string + include []string + exclude []string + want bool + }{ + // No filters set - everything runs + {"no filters, no resource marks", nil, nil, nil, true}, + {"no filters, with resource marks", []string{"critical"}, nil, nil, true}, + + // Include filter set + {"include set, resource matches", []string{"critical"}, []string{"critical"}, nil, true}, + {"include set, resource matches one of many", []string{"critical", "fast"}, []string{"critical"}, nil, true}, + {"include set, resource does not match", []string{"slow"}, []string{"critical"}, nil, false}, + {"include set, resource has no marks", nil, []string{"critical"}, nil, false}, + {"include set, multiple include marks, partial match", []string{"network"}, []string{"critical", "network"}, nil, true}, + + // Exclude filter set + {"exclude set, resource matches exclusion", []string{"slow"}, nil, []string{"slow"}, false}, + {"exclude set, resource does not match", []string{"fast"}, nil, []string{"slow"}, true}, + {"exclude set, resource has no marks", nil, nil, []string{"slow"}, true}, + {"exclude set, multiple exclude marks, partial match", []string{"flaky"}, nil, []string{"slow", "flaky"}, false}, + + // Both filters set + {"both set, included and not excluded", []string{"critical", "fast"}, []string{"critical"}, []string{"slow"}, true}, + {"both set, included but excluded", []string{"critical", "slow"}, []string{"critical"}, []string{"slow"}, false}, + {"both set, not included", []string{"network"}, []string{"critical"}, []string{"slow"}, false}, + {"both set, not included nor excluded", []string{"random"}, []string{"critical"}, []string{"slow"}, false}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := shouldRunByMarks(tc.resource, tc.include, tc.exclude); got != tc.want { + t.Errorf("shouldRunByMarks(%v, include=%v, exclude=%v) = %v, want %v", + tc.resource, tc.include, tc.exclude, got, tc.want) + } + }) + } +} + +// collectResults drains a TestResult channel and classifies outcomes. Used by +// the end-to-end marks tests below to assert on how many resources were +// executed vs. skipped under various filter combinations. +func collectResults(t *testing.T, ch <-chan []resource.TestResult) (ran, skipped int) { + t.Helper() + for batch := range ch { + for _, r := range batch { + if r.Result == resource.SKIP { + skipped++ + } else { + ran++ + } + } + } + return ran, skipped +} + +// TestValidateResults_WithIncludeMarks verifies the full +// gossfile -> Config -> ValidateResults -> []TestResult path honors +// IncludeMarks. This complements the unit tests on shouldRunByMarks by +// exercising the same integration path the CLI uses. +// +// testdata/marks.goss.yaml contains three commands with marks: +// - "echo critical" marked [critical, fast] -> 2 properties (exit-status, stdout) +// - "echo slow" marked [slow] -> 2 properties +// - "echo unmarked" no marks -> 2 properties +// +// exit-status and stdout are 2 properties each; stderr is empty list so +// no property emitted for it. Total: 6 TestResults per run. +func TestValidateResults_WithIncludeMarks(t *testing.T) { + t.Parallel() + tl := util.NewTestLogger(t) + + cfg, err := util.NewConfig( + util.WithSpecFile(filepath.Join("testdata", "marks.goss.yaml")), + util.WithIncludeMarks("critical"), + util.WithLogger(tl), + ) + require.NoError(t, err) + + results, err := ValidateResults(cfg) + require.NoError(t, err) + + ran, skipped := collectResults(t, results) + // 2 properties for the critical command, 4 for the filtered-out ones. + assert.Equal(t, 2, ran, "only critical command properties should run") + assert.Equal(t, 4, skipped, "slow and unmarked command properties should skip") +} + +// TestValidateResults_WithExcludeMarks verifies ExcludeMarks filters tests. +func TestValidateResults_WithExcludeMarks(t *testing.T) { + t.Parallel() + tl := util.NewTestLogger(t) + + cfg, err := util.NewConfig( + util.WithSpecFile(filepath.Join("testdata", "marks.goss.yaml")), + util.WithExcludeMarks("slow"), + util.WithLogger(tl), + ) + require.NoError(t, err) + + results, err := ValidateResults(cfg) + require.NoError(t, err) + + ran, skipped := collectResults(t, results) + // Critical + unmarked run (4 props), slow skipped (2 props). + assert.Equal(t, 4, ran, "critical and unmarked should run, slow should skip") + assert.Equal(t, 2, skipped, "only the slow command's properties should skip") +} + +// TestValidateResults_IncludeThenExcludeOrder verifies the include-first-then- +// exclude evaluation order. AC-E4 / FR-7. +func TestValidateResults_IncludeThenExcludeOrder(t *testing.T) { + t.Parallel() + tl := util.NewTestLogger(t) + + cfg, err := util.NewConfig( + util.WithSpecFile(filepath.Join("testdata", "marks.goss.yaml")), + util.WithIncludeMarks("critical", "slow"), + util.WithExcludeMarks("slow"), + util.WithLogger(tl), + ) + require.NoError(t, err) + + results, err := ValidateResults(cfg) + require.NoError(t, err) + + ran, skipped := collectResults(t, results) + // Include {critical, slow} then exclude {slow} => only critical runs. + assert.Equal(t, 2, ran, "only critical should survive include-then-exclude") + assert.Equal(t, 4, skipped, "slow (excluded) and unmarked (not included) should skip") +} + +// TestValidateResults_NoFilters verifies the baseline: with no mark filters, +// all tests run. Regression guard for backward compatibility (NFR-2). +func TestValidateResults_NoFilters(t *testing.T) { + t.Parallel() + tl := util.NewTestLogger(t) + + cfg, err := util.NewConfig( + util.WithSpecFile(filepath.Join("testdata", "marks.goss.yaml")), + util.WithLogger(tl), + ) + require.NoError(t, err) + + results, err := ValidateResults(cfg) + require.NoError(t, err) + + ran, skipped := collectResults(t, results) + assert.Equal(t, 6, ran, "with no mark filters, all properties should run") + assert.Equal(t, 0, skipped, "nothing should be skipped") +} + +// TestValidateResults_AllFilteredOut verifies that when a mark filter matches +// no resources, the validator still completes cleanly with every test +// reported as skipped (AC-UW3). +func TestValidateResults_AllFilteredOut(t *testing.T) { + t.Parallel() + tl := util.NewTestLogger(t) + + cfg, err := util.NewConfig( + util.WithSpecFile(filepath.Join("testdata", "marks.goss.yaml")), + util.WithIncludeMarks("nonexistent-mark"), + util.WithLogger(tl), + ) + require.NoError(t, err) + + results, err := ValidateResults(cfg) + require.NoError(t, err) + + ran, skipped := collectResults(t, results) + assert.Equal(t, 0, ran, "no tests should run when include filter matches nothing") + assert.Equal(t, 6, skipped, "all 6 properties should be skipped") +} + +// TestValidate_LogsMarkFilterSummary verifies that when a mark filter is +// active, validate() emits a [DEBUG] summary log describing the filter and +// a post-run count of filtered resources. When no filter is set, the logger +// must remain silent on this topic (regression guard for backward compat). +func TestValidate_LogsMarkFilterSummary(t *testing.T) { + t.Parallel() + + t.Run("filter active emits debug summary and count", func(t *testing.T) { + t.Parallel() + tl := util.NewTestLogger(t) + + cfg, err := util.NewConfig( + util.WithSpecFile(filepath.Join("testdata", "marks.goss.yaml")), + util.WithIncludeMarks("critical"), + util.WithLogger(tl), + ) + require.NoError(t, err) + + results, err := ValidateResults(cfg) + require.NoError(t, err) + // Drain results so the filtering goroutine runs to completion and + // emits its post-loop count line. + _, _ = collectResults(t, results) + + out := tl.String() + assert.Contains(t, out, "[DEBUG] mark filters active", + "should announce which filters are in play") + assert.Contains(t, out, "include=[critical]") + assert.Contains(t, out, "[DEBUG] marks filter skipped", + "should summarize filtered count after the run") + }) + + t.Run("no filter means no mark log output", func(t *testing.T) { + t.Parallel() + tl := util.NewTestLogger(t) + + cfg, err := util.NewConfig( + util.WithSpecFile(filepath.Join("testdata", "marks.goss.yaml")), + util.WithLogger(tl), + ) + require.NoError(t, err) + + results, err := ValidateResults(cfg) + require.NoError(t, err) + _, _ = collectResults(t, results) + + out := tl.String() + assert.NotContains(t, out, "mark filters active", + "must not log filter-active summary when no filters are set") + assert.NotContains(t, out, "marks filter skipped", + "must not log filter-count summary when no filters are set") + }) +}