tests.go (217 lines of code) (raw):
package dalec
import (
goerrors "errors"
"fmt"
"io/fs"
"regexp"
"strings"
"github.com/moby/buildkit/frontend/dockerfile/shell"
"github.com/pkg/errors"
)
// TestSpec is used to execute tests against a container with the package installed in it.
type TestSpec struct {
// Name is the name of the test
// This will be used to output the test results
Name string `yaml:"name" json:"name" jsonschema:"required"`
// Dir is the working directory to run the command in.
Dir string `yaml:"dir,omitempty" json:"dir,omitempty"`
// Mounts is the list of sources to mount into the build steps.
Mounts []SourceMount `yaml:"mounts,omitempty" json:"mounts,omitempty"`
// List of CacheDirs which will be used across all Steps
CacheDirs map[string]CacheDirConfig `yaml:"cache_dirs,omitempty" json:"cache_dirs,omitempty"`
// Env is the list of environment variables to set for all commands in this step group.
Env map[string]string `yaml:"env,omitempty" json:"env,omitempty"`
// Steps is the list of commands to run to test the package.
Steps []TestStep `yaml:"steps,omitempty" json:"steps,omitempty"`
// Files is the list of files to check after running the steps.
Files map[string]FileCheckOutput `yaml:"files,omitempty" json:"files,omitempty"`
}
// TestStep is a wrapper for [BuildStep] to include checks on stdio streams
type TestStep struct {
// Command is the command to run to build the artifact(s).
// This will always be wrapped as /bin/sh -c "<command>", or whatever the equivalent is for the target distro.
Command string `yaml:"command" json:"command" jsonschema:"required"`
// Env is the list of environment variables to set for the command.
Env map[string]string `yaml:"env,omitempty" json:"env,omitempty"`
// Stdout is the expected output on stdout
Stdout CheckOutput `yaml:"stdout,omitempty" json:"stdout,omitempty"`
// Stderr is the expected output on stderr
Stderr CheckOutput `yaml:"stderr,omitempty" json:"stderr,omitempty"`
// Stdin is the input to pass to stdin for the command
Stdin string `yaml:"stdin,omitempty" json:"stdin,omitempty"`
}
// CheckOutput is used to specify the expected output of a check, such as stdout/stderr or a file.
// All non-empty fields will be checked.
type CheckOutput struct {
// Equals is the exact string to compare the output to.
Equals string `yaml:"equals,omitempty" json:"equals,omitempty"`
// Contains is the list of strings to check if they are contained in the output.
Contains []string `yaml:"contains,omitempty" json:"contains,omitempty"`
// Matches is the list of regular expressions to match the output against.
Matches []string `yaml:"matches,omitempty" json:"matches,omitempty"`
// StartsWith is the string to check if the output starts with.
StartsWith string `yaml:"starts_with,omitempty" json:"starts_with,omitempty"`
// EndsWith is the string to check if the output ends with.
EndsWith string `yaml:"ends_with,omitempty" json:"ends_with,omitempty"`
// Empty is used to check if the output is empty.
Empty bool `yaml:"empty,omitempty" json:"empty,omitempty"`
}
// FileCheckOutput is used to specify the expected output of a file.
type FileCheckOutput struct {
CheckOutput `yaml:",inline"`
// Permissions is the expected permissions of the file.
Permissions fs.FileMode `yaml:"permissions,omitempty" json:"permissions,omitempty"`
// IsDir is used to set the expected file mode to a directory.
IsDir bool `yaml:"is_dir,omitempty" json:"is_dir,omitempty"`
// NotExist is used to check that the file does not exist.
NotExist bool `yaml:"not_exist,omitempty" json:"not_exist,omitempty"`
// TODO: Support checking symlinks
// This is not currently possible with buildkit as it does not expose information about the symlink
}
// CheckOutputError is used to build an error message for a failed output check for a test case.
type CheckOutputError struct {
Kind string
Expected string
Actual string
Path string
}
func (c *CheckOutputError) Error() string {
return fmt.Sprintf("expected %q %s %q, got %q", c.Path, c.Kind, c.Expected, c.Actual)
}
// IsEmpty is used to determine if there are any checks to perform.
func (c CheckOutput) IsEmpty() bool {
return c.Equals == "" && len(c.Contains) == 0 && len(c.Matches) == 0 && c.StartsWith == "" && c.EndsWith == "" && !c.Empty
}
func (t *TestSpec) validate() error {
var errs []error
for _, m := range t.Mounts {
if err := m.validate("/"); err != nil {
errs = append(errs, errors.Wrapf(err, "mount %s", m.Dest))
}
}
for p, cfg := range t.CacheDirs {
if _, err := sharingMode(cfg.Mode); err != nil {
errs = append(errs, errors.Wrapf(err, "invalid sharing mode for test %q with cache mount at path %q", t.Name, p))
}
}
return goerrors.Join(errs...)
}
func (c *CheckOutput) processBuildArgs(lex *shell.Lex, args map[string]string, allowArg func(string) bool) error {
for i, contains := range c.Contains {
updated, err := expandArgs(lex, contains, args, allowArg)
if err != nil {
return fmt.Errorf("%w: contains at list index %d", err, i)
}
c.Contains[i] = updated
}
updated, err := expandArgs(lex, c.EndsWith, args, allowArg)
if err != nil {
return fmt.Errorf("%w: endsWith", err)
}
c.EndsWith = updated
for i, matches := range c.Matches {
updated, err = expandArgs(lex, matches, args, allowArg)
if err != nil {
return fmt.Errorf("%w: matches at list index %d", err, i)
}
c.Matches[i] = updated
}
updated, err = expandArgs(lex, c.Equals, args, allowArg)
if err != nil {
return fmt.Errorf("%w: equals", err)
}
c.Equals = updated
updated, err = expandArgs(lex, c.StartsWith, args, allowArg)
if err != nil {
return fmt.Errorf("%w: startsWith", err)
}
c.StartsWith = updated
return nil
}
func (s *TestStep) processBuildArgs(lex *shell.Lex, args map[string]string, allowArg func(string) bool) error {
var errs []error
appendErr := func(err error) {
errs = append(errs, err)
}
for k, v := range s.Env {
updated, err := expandArgs(lex, v, args, allowArg)
if err != nil {
appendErr(errors.Wrapf(err, "env %s=%s", k, v))
continue
}
s.Env[k] = updated
}
updated, err := expandArgs(lex, s.Stdin, args, allowArg)
if err != nil {
appendErr(errors.Wrap(err, "stdin"))
}
if updated != s.Stdin {
s.Stdin = updated
}
stdout := s.Stdout
if err := stdout.processBuildArgs(lex, args, allowArg); err != nil {
appendErr(errors.Wrap(err, "stdout"))
}
s.Stdout = stdout
stderr := s.Stderr
if err := stderr.processBuildArgs(lex, args, allowArg); err != nil {
appendErr(errors.Wrap(err, "stderr"))
}
s.Stderr = stderr
return goerrors.Join(errs...)
}
func (c *TestSpec) processBuildArgs(lex *shell.Lex, args map[string]string, allowArg func(string) bool) error {
var errs []error
appendErr := func(err error) {
errs = append(errs, err)
}
for i, s := range c.Mounts {
if err := s.processBuildArgs(lex, args, allowArg); err != nil {
appendErr(err)
continue
}
c.Mounts[i] = s
}
for k, v := range c.Env {
updated, err := expandArgs(lex, v, args, allowArg)
if err != nil {
appendErr(errors.Wrapf(err, "%s=%s", k, v))
continue
}
c.Env[k] = updated
}
for i, step := range c.Steps {
if err := step.processBuildArgs(lex, args, allowArg); err != nil {
appendErr(errors.Wrapf(err, "step index %d", i))
continue
}
c.Steps[i] = step
}
for name, f := range c.Files {
if err := f.processBuildArgs(lex, args, allowArg); err != nil {
appendErr(fmt.Errorf("error performing shell expansion to check output of file %s: %w", name, err))
}
c.Files[name] = f
}
return errors.Wrap(goerrors.Join(errs...), c.Name)
}
func (c *FileCheckOutput) processBuildArgs(lex *shell.Lex, args map[string]string, allowArg func(string) bool) error {
check := c.CheckOutput
if err := check.processBuildArgs(lex, args, allowArg); err != nil {
return err
}
c.CheckOutput = check
return nil
}
// Check is used to check the output stream.
func (c CheckOutput) Check(dt string, p string) (retErr error) {
if c.Empty {
if dt != "" {
return &CheckOutputError{Kind: "empty", Expected: "", Actual: dt, Path: p}
}
// Anything else would be nonsensical and it would make sense to return early...
// But we'll check it anyway and it should fail since this would be an invalid CheckOutput
}
if c.Equals != "" && c.Equals != dt {
return &CheckOutputError{Expected: c.Equals, Actual: dt, Path: p}
}
for _, contains := range c.Contains {
if contains != "" && !strings.Contains(dt, contains) {
return &CheckOutputError{Kind: "contains", Expected: contains, Actual: dt, Path: p}
}
}
for _, matches := range c.Matches {
regexp, err := regexp.Compile(matches)
if err != nil {
return err
}
if !regexp.Match([]byte(dt)) {
return &CheckOutputError{Kind: "matches", Expected: matches, Actual: dt, Path: p}
}
}
if c.StartsWith != "" && !strings.HasPrefix(dt, c.StartsWith) {
return &CheckOutputError{Kind: "starts_with", Expected: c.StartsWith, Actual: dt, Path: p}
}
if c.EndsWith != "" && !strings.HasSuffix(dt, c.EndsWith) {
return &CheckOutputError{Kind: "ends_with", Expected: c.EndsWith, Actual: dt, Path: p}
}
return nil
}
// Check is used to check the output file.
func (c FileCheckOutput) Check(dt string, mode fs.FileMode, isDir bool, p string) error {
if c.IsDir && !isDir {
return &CheckOutputError{Kind: "mode", Expected: "ModeDir", Actual: "ModeFile", Path: p}
}
if !c.IsDir && isDir {
return &CheckOutputError{Kind: "mode", Expected: "ModeFile", Actual: "ModeDir", Path: p}
}
perm := mode.Perm()
if c.Permissions != 0 && c.Permissions != perm {
return &CheckOutputError{Kind: "permissions", Expected: c.Permissions.String(), Actual: perm.String(), Path: p}
}
return c.CheckOutput.Check(dt, p)
}