Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/gossfile.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,23 @@ command:
retry_delay: 500 # Delay in milliseconds before each retry; duration strings like 500ms or 2s also work
```

The `exec` attribute specifies the command to run.
It defaults to the name of the hash key and can be given in two forms:

* A **string**, which is executed through the shell (e.g. `/bin/sh -c "<string>"` on Unix).
* An **array of strings**, where the first element is the program and the rest are its arguments.
The command is invoked directly, without a shell.
This is useful in environments that have no shell, such as `scratch`/distroless containers.
It also allows arguments containing spaces or special characters to be passed verbatim.

```yaml
command:
figlet:
exit-status: 0
# exec style: run /figlet directly with argument "test"
exec: ["/figlet", "test"]
```

`stdout` and `stderr` can be a string or [pattern](#patterns)

!!! warning "An empty list asserts nothing"
Expand Down
1 change: 1 addition & 0 deletions docs/platforms.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ This matrix attempts to track parity across platforms.
| | local-address | {{ fully_supported }} | {{ no_data }} | {{ work_partially }} |
| | timeout | {{ fully_supported }} | {{ not_automated }} | {{ not_automated }} |
| **command** | | {{ fully_supported }} | {{ work_partially }} | {{ work_partially }} |
| | exec | {{ fully_supported }} | {{ fully_supported }} | {{ fully_supported }} |
| | exit-status | {{ fully_supported }} | {{ work_partially }} | {{ work_partially }} |
| | stdout | {{ fully_supported }} | {{ work_partially }} | {{ work_partially }} |
| | stderr | {{ fully_supported }} | {{ not_automated }} | {{ not_automated }} |
Expand Down
8 changes: 6 additions & 2 deletions docs/schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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. A string is run through the shell; a list of strings is executed directly (no shell), useful when no shell exists (e.g. scratch/distroless images)"
oneOf:
- type: string
- type: array
items:
type: string
stdout:
type: array
description: "can be a string or pattern, see https://goss.rocks/gossfile#patterns"
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ require (
github.com/stretchr/testify v1.11.1
github.com/tidwall/gjson v1.19.0
github.com/urfave/cli/v3 v3.10.1
go.yaml.in/yaml/v3 v3.0.4
golang.org/x/sys v0.47.0
gopkg.in/yaml.v3 v3.0.1
gotest.tools/v3 v3.5.1
Expand All @@ -52,7 +53,6 @@ require (
github.com/spf13/cast v1.10.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/net v0.57.0 // indirect
Expand Down
10 changes: 10 additions & 0 deletions integration-tests/goss/goss-shared.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ command:
exec: false
exit-status: 0
skip: true
command-exec-style:
exec: [uname]
exit-status: 0
stdout:
- /Linux/
command-exec-style-args:
exec: ["uname", "-s"]
exit-status: 0
stdout:
- /Linux/
file:
{{range mkSlice "/etc/PAsswD" "/etc/group"}}
{{. | toLower}}:
Expand Down
4 changes: 2 additions & 2 deletions integration-tests/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ out=$(docker_exec "/goss/$os/goss-linux-$arch" --vars "/goss/vars.yaml" --vars-i
echo "$out"

if [[ $os == "arch" ]]; then
egrep -q 'Count: 108, Failed: 0, Skipped: 3' <<<"$out"
egrep -q 'Count: 112, Failed: 0, Skipped: 3' <<<"$out"
else
egrep -q 'Count: 129, Failed: 0, Skipped: 5' <<<"$out"
egrep -q 'Count: 133, Failed: 0, Skipped: 5' <<<"$out"
fi

if [[ ! $os == "arch" ]]; then
Expand Down
44 changes: 28 additions & 16 deletions resource/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,17 @@ 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"`
RetryCount int `json:"retry_count,omitempty" yaml:"retry_count,omitempty"`
RetryDelay RetryDelay `json:"retry_delay,omitempty" yaml:"retry_delay,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"`
RetryCount int `json:"retry_count,omitempty" yaml:"retry_count,omitempty"`
RetryDelay RetryDelay `json:"retry_delay,omitempty" yaml:"retry_delay,omitempty"`
}

const (
Expand All @@ -43,9 +43,17 @@ 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

// GetExec returns the command to run: shell style as a string, exec style as a
// []string, or the resource id when no exec was specified.
func (c *Command) GetExec() any {
if c.Exec != nil {
if c.Exec.CmdStr != "" {
return c.Exec.CmdStr
}
if len(c.Exec.CmdSlice) > 0 {
return c.Exec.CmdSlice
}
}
return c.id
}
Expand Down Expand Up @@ -91,10 +99,14 @@ func allTestsPassed(results []TestResult) bool {
}

func NewCommand(sysCommand system.Command, config util.Config) (*Command, error) {
command := sysCommand.Command()
exec := sysCommand.Command()
id := exec.CmdStr
if id == "" && len(exec.CmdSlice) > 0 {
id = exec.CmdSlice[0]
}
exitStatus, err := sysCommand.ExitStatus()
c := &Command{
id: command,
id: id,
ExitStatus: exitStatus,
Stdout: "",
Stderr: "",
Expand Down
62 changes: 62 additions & 0 deletions resource/command_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package resource

import (
"reflect"
"testing"

"go.yaml.in/yaml/v3"
)

func TestCommandGetExec(t *testing.T) {
tests := []struct {
name string
input string
want any
}{
{
name: "no exec falls back to the id",
input: "foo:\n exit-status: 0\n",
want: "foo",
},
{
name: "string exec is shell style",
input: "foo:\n exit-status: 0\n exec: echo hi\n",
want: "echo hi",
},
{
name: "list exec is exec style",
input: "foo:\n exit-status: 0\n exec: [/bin/echo, hello world]\n",
want: []string{"/bin/echo", "hello world"},
},
{
name: "bool scalar exec keeps legacy string behavior",
input: "foo:\n exit-status: 0\n exec: true\n",
want: "true",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var m CommandMap
if err := yaml.Unmarshal([]byte(tt.input), &m); err != nil {
t.Fatalf("unexpected error: %v", err)
}
cmd, ok := m["foo"]
if !ok {
t.Fatalf("expected foo resource, got %+v", m)
}
got := cmd.GetExec()
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("GetExec() = %#v, want %#v", got, tt.want)
}
})
}
}

func TestCommandInvalidExec(t *testing.T) {
input := "foo:\n exit-status: 0\n exec: {a: b}\n"
var m CommandMap
if err := yaml.Unmarshal([]byte(input), &m); err == nil {
t.Fatalf("expected error for mapping exec, got %+v", m)
}
}
17 changes: 12 additions & 5 deletions resource/retry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,13 @@ func (f *fakeDNS) Resolvable() (bool, error) { return f.resolvableFn() }
func (f *fakeDNS) Addrs() ([]string, error) { return f.addrsFn() }

type fakeCommand struct {
command string
command util.ExecCommand
exitStatusFn func() (int, error)
stdoutFn func() (io.Reader, error)
stderrFn func() (io.Reader, error)
}

func (f *fakeCommand) Command() string { return f.command }
func (f *fakeCommand) Command() util.ExecCommand { return f.command }
func (f *fakeCommand) Exists() (bool, error) { return true, nil }
func (f *fakeCommand) ExitStatus() (int, error) { return f.exitStatusFn() }
func (f *fakeCommand) Stdout() (io.Reader, error) { return f.stdoutFn() }
Expand Down Expand Up @@ -267,11 +267,18 @@ func TestDNSValidateRetries(t *testing.T) {
func TestCommandValidateRetries(t *testing.T) {
commandCalls := 0
sys := &system.System{
NewCommand: func(_ context.Context, command string, _ *system.System, _ util.Config) system.Command {
NewCommand: func(_ context.Context, command any, _ *system.System, _ util.Config) system.Command {
commandCalls++
attempt := commandCalls
var exec util.ExecCommand
switch c := command.(type) {
case string:
exec = util.ExecCommand{CmdStr: c}
case []string:
exec = util.ExecCommand{CmdSlice: c}
}
return &fakeCommand{
command: command,
command: exec,
exitStatusFn: func() (int, error) {
if attempt == 1 {
return 1, nil
Expand All @@ -290,7 +297,7 @@ func TestCommandValidateRetries(t *testing.T) {

cmd := &Command{
id: "echo ok",
Exec: "echo ok",
Exec: &util.ExecCommand{CmdStr: "echo ok"},
ExitStatus: 0,
Stdout: []any{"ok"},
RetryCount: 1,
Expand Down
43 changes: 36 additions & 7 deletions system/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package system
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os/exec"
Expand All @@ -17,8 +18,12 @@ type ContextKey struct{}
// CommandIDKey is the only instance that must be used everywhere.
var CommandIDKey = ContextKey{}

// errEmptyCommand is returned when a command has neither a shell string nor an
// exec-style argument list.
var errEmptyCommand = errors.New("empty command")

type Command interface {
Command() string
Command() util.ExecCommand
Exists() (bool, error)
ExitStatus() (int, error)
Stdout() (io.Reader, error)
Expand All @@ -27,7 +32,7 @@ type Command interface {

type DefCommand struct {
Ctx context.Context
command string
command util.ExecCommand
exitStatus int
stdout io.Reader
stderr io.Reader
Expand All @@ -36,12 +41,27 @@ type DefCommand struct {
err error
}

func NewDefCommand(ctx context.Context, command string, system *System, config util.Config) Command {
return &DefCommand{
// NewDefCommand accepts a command specified either shell style (string) or exec
// style ([]string). The concrete type is validated here; an unexpected type is
// recorded as an error on the returned Command so it surfaces as a failing test
// result rather than a panic. Values loaded from a gossfile are already
// validated by util.ExecCommand's unmarshalers, so this only guards against
// programming errors.
func NewDefCommand(ctx context.Context, command any, system *System, config util.Config) Command {
c := &DefCommand{
Ctx: ctx,
command: command,
Timeout: config.TimeOutMilliSeconds(),
}
switch cmd := command.(type) {
case string:
c.command = util.ExecCommand{CmdStr: cmd}
case []string:
c.command = util.ExecCommand{CmdSlice: cmd}
default:
c.err = fmt.Errorf("command type must be either a string or a list of strings, got %T", command)
c.loaded = true
}
return c
}

func (c *DefCommand) setup() error {
Expand All @@ -50,7 +70,16 @@ func (c *DefCommand) setup() error {
}
c.loaded = true

cmd := commandWrapper(c.command)
var cmd *util.Command
switch {
case c.command.CmdStr != "":
cmd = commandWrapper(c.command.CmdStr)
case len(c.command.CmdSlice) > 0:
cmd = util.NewCommand(c.command.CmdSlice[0], c.command.CmdSlice[1:]...)
default:
c.err = errEmptyCommand
return c.err
}
err := runCommand(cmd, c.Timeout)

// We don't care about ExitError since it's covered by status
Expand All @@ -70,7 +99,7 @@ func (c *DefCommand) setup() error {
return c.err
}

func (c *DefCommand) Command() string {
func (c *DefCommand) Command() util.ExecCommand {
return c.command
}

Expand Down
2 changes: 1 addition & 1 deletion system/system.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,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
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
Expand Down
Loading