From 4504f1fad540310aef710253a769b51a33fb702d Mon Sep 17 00:00:00 2001 From: javierfreire Date: Wed, 22 Jan 2025 17:18:01 +0100 Subject: [PATCH 1/2] Command optional exec style Based in https://github.com/goss-org/goss/pull/871 --- resource/command.go | 76 ++++++++++++++++++++++++++------------- resource/resource_list.go | 4 +-- system/command.go | 36 +++++++++++++++---- system/system.go | 2 +- util/command.go | 40 +++++++++++++++++++++ 5 files changed, 125 insertions(+), 33 deletions(-) diff --git a/resource/command.go b/resource/command.go index 365867d24..d8ddca92b 100644 --- a/resource/command.go +++ b/resource/command.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "io" + "log" "strings" "time" @@ -13,15 +14,15 @@ 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"` + id string `json:"-" yaml:"-"` + Exec util.ExecCommand `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 ( @@ -41,11 +42,14 @@ 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) GetExec() string { - if c.Exec != "" { - return c.Exec +func (c *Command) GetExec() any { + if c.Exec.CmdStr != "" { + return c.Exec.CmdStr + } else if len(c.Exec.CmdSlice) > 0 { + return c.Exec.CmdSlice + } else { + return c.id } - return c.id } func (c *Command) Validate(sys *system.System) []TestResult { @@ -57,24 +61,48 @@ func (c *Command) Validate(sys *system.System) []TestResult { } var results []TestResult - sysCommand := sys.NewCommand(ctx, c.GetExec(), sys, util.Config{Timeout: time.Duration(c.Timeout) * time.Millisecond}) - - cExitStatus := deprecateAtoI(c.ExitStatus, fmt.Sprintf("%s: command.exit-status", c.ID())) - results = append(results, ValidateValue(c, "exit-status", cExitStatus, sysCommand.ExitStatus, skip)) - if isSet(c.Stdout) { - results = append(results, ValidateValue(c, "stdout", c.Stdout, sysCommand.Stdout, skip)) - } - if isSet(c.Stderr) { - results = append(results, ValidateValue(c, "stderr", c.Stderr, sysCommand.Stderr, skip)) + sysCommand, err := sys.NewCommand(ctx, c.GetExec(), sys, util.Config{Timeout: time.Duration(c.Timeout) * time.Millisecond}) + if err != nil { + log.Printf("[ERROR] Could not create new command: %v", err) + startTime := time.Now() + results = append( + results, + TestResult{ + Result: FAIL, + ResourceType: "Command", + ResourceId: c.id, + Title: c.Title, + Meta: c.Meta, + Property: "type", + Err: toValidateError(err), + StartTime: startTime, + EndTime: startTime, + Duration: startTime.Sub(startTime), + }, + ) + } else { + cExitStatus := deprecateAtoI(c.ExitStatus, fmt.Sprintf("%s: command.exit-status", c.ID())) + results = append(results, ValidateValue(c, "exit-status", cExitStatus, sysCommand.ExitStatus, skip)) + if isSet(c.Stdout) { + results = append(results, ValidateValue(c, "stdout", c.Stdout, sysCommand.Stdout, skip)) + } + if isSet(c.Stderr) { + results = append(results, ValidateValue(c, "stderr", c.Stderr, sysCommand.Stderr, skip)) + } } return results } func NewCommand(sysCommand system.Command, config util.Config) (*Command, error) { - command := sysCommand.Command() + var id string + if sysCommand.Command().CmdStr != "" { + id = sysCommand.Command().CmdStr + } else { + id = sysCommand.Command().CmdSlice[0] + } exitStatus, err := sysCommand.ExitStatus() c := &Command{ - id: command, + id: id, ExitStatus: exitStatus, Stdout: "", Stderr: "", diff --git a/resource/resource_list.go b/resource/resource_list.go index 61b381596..efdce97cc 100644 --- a/resource/resource_list.go +++ b/resource/resource_list.go @@ -120,7 +120,7 @@ type CommandMap map[string]*Command func (r CommandMap) AppendSysResource(sr string, sys *system.System, config util.Config) (*Command, error) { ctx := context.WithValue(context.Background(), idKey{}, sr) - sysres := sys.NewCommand(ctx, sr, sys, config) + sysres, _ := sys.NewCommand(ctx, sr, sys, config) res, err := NewCommand(sysres, config) if err != nil { return nil, err @@ -135,7 +135,7 @@ func (r CommandMap) AppendSysResource(sr string, sys *system.System, config util func (r CommandMap) AppendSysResourceIfExists(sr string, sys *system.System) (*Command, system.Command, bool, error) { ctx := context.WithValue(context.Background(), idKey{}, sr) - sysres := sys.NewCommand(ctx, sr, sys, util.Config{}) + sysres, _ := sys.NewCommand(ctx, sr, sys, util.Config{}) res, err := NewCommand(sysres, util.Config{}) if err != nil { return nil, nil, false, err diff --git a/system/command.go b/system/command.go index aea7ade9f..635324127 100644 --- a/system/command.go +++ b/system/command.go @@ -12,7 +12,7 @@ import ( ) type Command interface { - Command() string + Command() util.ExecCommand Exists() (bool, error) ExitStatus() (int, error) Stdout() (io.Reader, error) @@ -21,7 +21,7 @@ type Command interface { type DefCommand struct { Ctx context.Context - command string + command util.ExecCommand exitStatus int stdout io.Reader stderr io.Reader @@ -30,10 +30,29 @@ type DefCommand struct { err error } -func NewDefCommand(ctx context.Context, command string, system *System, config util.Config) Command { +func NewDefCommand(ctx context.Context, command any, system *System, config util.Config) (Command, error) { + switch cmd := command.(type) { + case string: + return newDefCommand(ctx, cmd, config), nil + case []string: + return newDefExecCommand(ctx, cmd, config), nil + default: + return nil, fmt.Errorf("command type must be either string or []string") + } +} + +func newDefCommand(ctx context.Context, command string, config util.Config) Command { + return &DefCommand{ + Ctx: ctx, + command: util.ExecCommand{CmdStr: command}, + Timeout: config.TimeOutMilliSeconds(), + } +} + +func newDefExecCommand(ctx context.Context, command []string, config util.Config) Command { return &DefCommand{ Ctx: ctx, - command: command, + command: util.ExecCommand{CmdSlice: command}, Timeout: config.TimeOutMilliSeconds(), } } @@ -44,7 +63,12 @@ func (c *DefCommand) setup() error { } c.loaded = true - cmd := commandWrapper(c.command) + var cmd *util.Command + if c.command.CmdStr != "" { + cmd = commandWrapper(c.command.CmdStr) + } else { + cmd = util.NewCommand(c.command.CmdSlice[0], c.command.CmdSlice[1:]...) + } err := runCommand(cmd, c.Timeout) // We don't care about ExitError since it's covered by status @@ -63,7 +87,7 @@ func (c *DefCommand) setup() error { return c.err } -func (c *DefCommand) Command() string { +func (c *DefCommand) Command() util.ExecCommand { return c.command } diff --git a/system/system.go b/system/system.go index 6c6083558..54f075fc2 100644 --- a/system/system.go +++ b/system/system.go @@ -27,7 +27,7 @@ type System struct { NewService func(context.Context, string, *System, util2.Config) Service NewUser func(context.Context, string, *System, util2.Config) User NewGroup func(context.Context, string, *System, util2.Config) Group - NewCommand func(context.Context, string, *System, util2.Config) Command + NewCommand func(context.Context, any, *System, util2.Config) (Command, error) NewDNS func(context.Context, string, *System, util2.Config) DNS NewProcess func(context.Context, string, *System, util2.Config) Process NewGossfile func(context.Context, string, *System, util2.Config) Gossfile diff --git a/util/command.go b/util/command.go index 460020356..1597acad3 100644 --- a/util/command.go +++ b/util/command.go @@ -2,12 +2,52 @@ package util import ( "bytes" + "encoding/json" //"fmt" "os/exec" "syscall" ) +// Allows passing a shell style command string +// or an exec style slice of strings. +type ExecCommand struct { + CmdStr string + CmdSlice []string +} + +func (e *ExecCommand) UnmarshalJSON(data []byte) error { + // Try to unmarshal as a string + if err := json.Unmarshal(data, &e.CmdStr); err != nil { + // If string unmarshalling fails, try as a slice + return json.Unmarshal(data, &e.CmdSlice) + } + return nil +} + +func (e *ExecCommand) UnmarshalYAML(unmarshal func(any) error) error { + // Try to unmarshal as a string + if err := unmarshal(&e.CmdStr); err != nil { + // If string unmarshalling fails, try as a slice + return unmarshal(&e.CmdSlice) + } + return nil +} + +func (e ExecCommand) MarshalJSON() ([]byte, error) { + if e.CmdStr != "" { + return json.Marshal(e.CmdStr) + } + return json.Marshal(e.CmdSlice) +} + +func (e ExecCommand) MarshalYAML() (any, error) { + if e.CmdStr != "" { + return e.CmdStr, nil + } + return e.CmdSlice, nil +} + type Command struct { name string Cmd *exec.Cmd From 448b68d8b2d79699a64ad1ae6670194972ca1f50 Mon Sep 17 00:00:00 2001 From: javierfreire Date: Fri, 24 Jan 2025 15:42:52 +0100 Subject: [PATCH 2/2] Update schema and documentation --- docs/gossfile.md | 14 ++++++++++++-- docs/schema.yaml | 10 +++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/gossfile.md b/docs/gossfile.md index a463c2ceb..2ef9941de 100644 --- a/docs/gossfile.md +++ b/docs/gossfile.md @@ -173,8 +173,18 @@ command: `stdout` and `stderr` can be a string or [pattern](#patterns) -The `exec` attribute is the command to run; this defaults to the name of -the hash for backwards compatibility +The `exec` attribute specifies the command to run. It can be: + +* A string, which will be executed through the shell (e.g., /bin/sh -c on Unix systems). +* An array of strings, where each element represents an argument. In this case, the command is invoked directly without a shell. This is + particularly useful in environments like scratch containers, which do not include a shell. + ```yaml + ... + exec: ["/someBinary", "argument1"] + ... + ``` + +If exec is not provided, it defaults to the name of the hash for backward compatibility. ### dns diff --git a/docs/schema.yaml b/docs/schema.yaml index 661ae527d..7f1eee218 100644 --- a/docs/schema.yaml +++ b/docs/schema.yaml @@ -34,7 +34,7 @@ definitions: default: 500 examples: - 500 - # optional attributes + # optional attributesf local-address: type: string default: 127.0.0.1 @@ -47,8 +47,12 @@ definitions: type: integer description: "Validates the exit-status and output of a command" exec: - description: "command to execute, defaults to the hash key" - type: string + description: "command to execute, defaults to the hash key, see https://goss.rocks/gossfile#command" + oneOf: + - type: string + - type: array + items: + type: string stdout: type: array description: "can be a string or pattern, see https://goss.rocks/gossfile#patterns"