test: add base suite for core acceptance testing

Acceptance testing, i.e. running Packer core commands in a controlled
environment and ensuring the behaviour is consistent to what we
expect/document, is not something we have a robust and usable framework
for at the moment.

This commit is a proposal for a base testing framework of the sort, that
is meant to be shipped with packer core, and which will eventually host
most of the tests we currently do in command where we mock an
environment.
pull/12983/head
Lucas Bajolet 2 years ago
parent 1f4f2aebca
commit ef50474a9f

@ -0,0 +1,32 @@
package test
import (
"fmt"
"math/rand"
"os"
"os/exec"
"path/filepath"
"testing"
)
// BuildTestPacker builds a new Packer binary based on the current state of the repository.
//
// If for some reason the binary cannot be built, we will immediately exit with an error.
func BuildTestPacker(t *testing.T) (string, error) {
testDir, err := currentDir()
if err != nil {
return "", fmt.Errorf("failed to compile packer binary: %s", err)
}
packerCoreDir := filepath.Dir(testDir)
outBin := filepath.Join(os.TempDir(), fmt.Sprintf("packer_core-%d", rand.Int()))
compileCommand := exec.Command("go", "build", "-C", packerCoreDir, "-o", outBin)
logs, err := compileCommand.CombinedOutput()
if err != nil {
t.Fatalf("failed to compile Packer core: %s\ncompilation logs: %s", err, logs)
}
return outBin, nil
}

@ -0,0 +1,81 @@
package test
import (
"fmt"
"os/exec"
"strings"
"sync"
"testing"
)
type packerCommand struct {
once sync.Once
packerPath string
args []string
env map[string]string
stderr *strings.Builder
stdout *strings.Builder
err error
}
// PackerCommand creates a skeleton of packer command with the ability to execute gadgets on the outputs of the command.
func (ts *PackerTestSuite) PackerCommand() *packerCommand {
stderr := &strings.Builder{}
stdout := &strings.Builder{}
return &packerCommand{
packerPath: ts.packerPath,
env: map[string]string{
"PACKER_LOG": "1",
},
stderr: stderr,
stdout: stdout,
}
}
func (pc *packerCommand) SetArgs(args ...string) *packerCommand {
pc.args = args
return pc
}
func (pc *packerCommand) AddEnv(key, val string) *packerCommand {
pc.env[key] = val
return pc
}
// Run executes the packer command with the args/env requested and returns the
// output streams (stdout, stderr)
//
// Note: "Run" will only execute the command once, and return the streams and
// error from the only execution for every subsequent call
func (pc *packerCommand) Run(t *testing.T) (string, string, error) {
pc.once.Do(pc.doRun)
if strings.Contains(pc.stdout.String(), "PACKER CRASH") || strings.Contains(pc.stderr.String(), "PACKER CRASH") {
t.Fatalf("Packer has crashed while running the following command: packer %#v", pc.args)
}
return pc.stdout.String(), pc.stderr.String(), pc.err
}
func (pc *packerCommand) doRun() {
cmd := exec.Command("packer", pc.args...)
for key, val := range pc.env {
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", key, val))
}
cmd.Stdout = pc.stdout
cmd.Stderr = pc.stderr
pc.err = cmd.Run()
}
func (pc *packerCommand) Assert(t *testing.T, checks ...Checker) {
stdout, stderr, err := pc.Run(t)
for _, check := range checks {
checkErr := check.Check(stdout, stderr, err)
if checkErr != nil {
t.Errorf("check %q failed: %s", check.Name(), checkErr)
}
}
}

@ -0,0 +1,99 @@
package test
import (
"fmt"
"regexp"
"testing"
)
type Stream int
const (
// BothStreams will use both stdout and stderr for performing a check
BothStreams Stream = iota
// OnlyStdout will only use stdout for performing a check
OnlyStdout
// OnlySterr will only use stderr for performing a check
OnlyStderr
)
func (s Stream) String() string {
switch s {
case BothStreams:
return "Both streams"
case OnlyStdout:
return "Stdout"
case OnlyStderr:
return "Stderr"
}
panic(fmt.Sprintf("Unknown stream value: %d", s))
}
type Checker interface {
Check(stdout, stderr string, err error) error
Name() string
}
type MustSucceed struct{}
func (_ MustSucceed) Check(stdout, stderr string, err error) error {
return err
}
func (_ MustSucceed) Name() string {
return "Must succeed"
}
// Grep is essentially the equivalent to a normal grep -E on the command line.
//
// The `expect` string is meant to be a regexp, which will be compiled on-demand,
// and will panic if it isn't a valid POSIX extended regexp.
type Grep struct {
streams Stream
expect string
}
func (g Grep) Check(stdout, stderr string, err error) error {
re := regexp.MustCompilePOSIX(g.expect)
streams := []string{}
switch g.streams {
case BothStreams:
streams = append(streams, stdout, stderr)
case OnlyStdout:
streams = append(streams, stdout)
case OnlyStderr:
streams = append(streams, stderr)
}
var found bool
for _, stream := range streams {
found = found || re.MatchString(stream)
}
if !found {
return fmt.Errorf("streams %q did not match the expected regexp %q", g.streams, g.expect)
}
return nil
}
func (g Grep) Name() string {
return fmt.Sprintf("command (%s) | grep -E %q", g.streams, g.expect)
}
type Dump struct {
t *testing.T
}
func (d Dump) Check(stdout, stderr string, err error) error {
d.t.Logf("Dumping command result.")
d.t.Logf("Stdout: %s", stdout)
d.t.Logf("stderr: %s", stderr)
return nil
}
func (_ Dump) Name() string {
return "dump"
}

@ -0,0 +1,40 @@
package test
import (
"os"
"testing"
)
func (ts *PackerTestSuite) TestLoadingOrder() {
t := ts.T()
pluginDir := ts.MakePluginDir(t, "1.0.9", "1.0.10")
defer func() {
err := os.RemoveAll(pluginDir)
if err != nil {
t.Logf("failed to remove temporary plugin directory %q: %s. This may need manual intervention.", pluginDir, err)
}
}()
tests := []struct {
name string
templatePath string
}{
{
"HCL2 - No required_plugins, 1.0.10 is the most recent and should load",
"./templates/simple.pkr.hcl",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ts.PackerCommand().
SetArgs("build", tt.templatePath).
AddEnv("PACKER_PLUGIN_PATH", pluginDir).
Assert(t, MustSucceed{}, Grep{
streams: BothStreams,
expect: "packer-plugin-tester_v1\\.0\\.10[^\n]+ plugin:",
})
})
}
}

@ -0,0 +1,192 @@
package test
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"sync"
"testing"
"github.com/hashicorp/go-version"
)
var compiledPlugins = struct {
pluginVersions map[string]string
RWMutex sync.RWMutex
}{
pluginVersions: map[string]string{},
}
func StorePluginVersion(pluginVersion, path string) {
compiledPlugins.RWMutex.Lock()
defer compiledPlugins.RWMutex.Unlock()
compiledPlugins.pluginVersions[pluginVersion] = path
}
func LoadPluginVersion(pluginVersion string) (string, bool) {
compiledPlugins.RWMutex.RLock()
defer compiledPlugins.RWMutex.RUnlock()
path, ok := compiledPlugins.pluginVersions[pluginVersion]
return path, ok
}
var tempPluginBinaryPath = struct {
path string
once sync.Once
}{}
// PluginBinaryDir returns the path to the directory where temporary binaries will be compiled
func PluginBinaryDir() string {
tempPluginBinaryPath.once.Do(func() {
tempDir, err := os.MkdirTemp("", "packer-core-acc-test-")
if err != nil {
panic(fmt.Sprintf("failed to create temporary directory for compiled plugins: %s", err))
}
tempPluginBinaryPath.path = tempDir
})
return tempPluginBinaryPath.path
}
type PluginBuildConfig struct {
version *version.Version
}
func NewPluginBuildConfig(versionStr string) *PluginBuildConfig {
return &PluginBuildConfig{
version.Must(version.NewVersion(versionStr)),
}
}
// Version is the core version string of the test plugin.
//
// If the version isn't set, it'll default to 1.0.0
func (pc PluginBuildConfig) Version() string {
return pc.version.Core().String()
}
func (pc PluginBuildConfig) PreRelease() string {
return pc.version.Prerelease()
}
func (pc PluginBuildConfig) Metadata() string {
return pc.version.Metadata()
}
// LDFlags compiles the ldflags for the plugin to compile based on the information provided.
func (pc PluginBuildConfig) LDFlags() string {
pluginPackage := "github.com/hashicorp/packer-plugin-tester"
ldflagsArg := fmt.Sprintf("-X %s/version.Version=%s", pluginPackage, pc.Version())
if pc.PreRelease() != "" {
ldflagsArg = fmt.Sprintf("%s -X %s/version.VersionPrerelease=%s", ldflagsArg, pluginPackage, pc.PreRelease())
}
if pc.Metadata() != "" {
ldflagsArg = fmt.Sprintf("%s -X %s/version.VersionMetadata=%s", ldflagsArg, pluginPackage, pc.Metadata())
}
return ldflagsArg
}
// BinaryName is the raw name of the plugin binary to produce
//
// It's expected to be in the "mini-plugin_<version>[-<prerelease>][+<metadata>]" format
func (pc PluginBuildConfig) BinaryName() string {
retStr := fmt.Sprintf("mini-plugin_%s", pc.Version())
if pc.PreRelease() != "" {
retStr = fmt.Sprintf("%s-%s", retStr, pc.PreRelease())
}
if pc.Metadata() != "" {
retStr = fmt.Sprintf("%s+%s", retStr, pc.Metadata())
}
return retStr
}
// BuildSimplePlugin creates a plugin that essentially does nothing.
//
// The plugin's code is contained in a subdirectory of this, and lets us
// change the attributes of the plugin binary itself, like the SDK version,
// the plugin's version, etc.
//
// The plugin is functional, and can be used to run builds with.
// There won't be anything substantial created though, its goal is only
// to validate the core functionality of Packer.
//
// The path to the plugin is returned, it won't be removed automatically
// though, deletion is the caller's responsibility.
func BuildSimplePlugin(config *PluginBuildConfig, t *testing.T) {
t.Logf("Building plugin in version %v", config.version)
testDir, err := currentDir()
if err != nil {
t.Fatalf("failed to compile plugin binary: %s", err)
}
miniPluginDir := filepath.Join(testDir, "mini_plugin")
outBin := filepath.Join(PluginBinaryDir(), config.BinaryName())
compileCommand := exec.Command("go", "build", "-C", miniPluginDir, "-o", outBin, "-ldflags", config.LDFlags(), ".")
logs, err := compileCommand.CombinedOutput()
if err != nil {
t.Fatalf("failed to compile plugin binary: %s\ncompiler logs: %s", err, logs)
}
StorePluginVersion(config.version.String(), outBin)
}
// currentDir returns the directory in which the current file is located.
//
// Since we're in tests it's reliable as they're supposed to run on the same
// machine the binary's compiled from, but goes to say it's not meant for use
// in distributed binaries.
func currentDir() (string, error) {
// pc uintptr, file string, line int, ok bool
_, testDir, _, ok := runtime.Caller(0)
if !ok {
return "", fmt.Errorf("couldn't get the location of the test suite file")
}
return filepath.Dir(testDir), nil
}
// MakePluginDir installs a list of plugins into a temporary directory and returns its path
//
// This can be set in the environment for a test through a function like t.SetEnv(), so
// packer will be able to use that directory for running its functions.
//
// Deletion of the directory is the caller's responsibility.
func (ts *PackerTestSuite) MakePluginDir(t *testing.T, pluginVersions ...string) (pluginTempDir string) {
var err error
defer func() {
if err != nil {
if pluginTempDir != "" {
os.RemoveAll(pluginTempDir)
}
t.Fatalf("failed to prepare temporary plugin directory %q: %s", pluginTempDir, err)
}
}()
pluginTempDir, err = os.MkdirTemp("", "packer-plugin-dir-temp-")
if err != nil {
return
}
for _, pluginVersion := range pluginVersions {
path, _ := LoadPluginVersion(pluginVersion)
cmd := ts.PackerCommand().SetArgs("plugins", "install", "--path", path, "github.com/hashicorp/tester").AddEnv("PACKER_PLUGIN_PATH", pluginTempDir)
cmd.Assert(t, MustSucceed{})
out, stderr, cmdErr := cmd.Run(t)
if cmdErr != nil {
err = fmt.Errorf("failed to install tester plugin version %q: %s\nCommand stdout: %s\nCommand stderr: %s", pluginVersion, err, out, stderr)
return
}
}
return pluginTempDir
}

@ -0,0 +1,62 @@
package test
import (
"os"
"testing"
"github.com/stretchr/testify/suite"
)
type PackerTestSuite struct {
suite.Suite
// pluginsDirectory is the directory in which plugins are compiled.
//
// Those binaries are not necessarily meant to be used as-is, but
// instead should be used for composing plugin installation directories.
pluginsDirectory string
// packerPath is the location in which the Packer executable is compiled
//
// Since we don't necessarily want to manually compile Packer beforehand,
// we compile it on demand, and use this executable for the tests.
packerPath string
}
func (ts *PackerTestSuite) buildPluginBinaries(t *testing.T) {
BuildSimplePlugin(NewPluginBuildConfig("1.0.0"), t)
BuildSimplePlugin(NewPluginBuildConfig("1.0.1-dev"), t)
BuildSimplePlugin(NewPluginBuildConfig("1.0.1"), t)
BuildSimplePlugin(NewPluginBuildConfig("1.0.9"), t)
BuildSimplePlugin(NewPluginBuildConfig("1.0.10"), t)
}
func Test_PackerCoreSuite(t *testing.T) {
ts := &PackerTestSuite{}
pluginsDirectory := PluginBinaryDir()
defer func() {
err := os.RemoveAll(pluginsDirectory)
if err != nil {
t.Logf("failed to cleanup directory %q: %s. This will need manual action", pluginsDirectory, err)
}
}()
ts.pluginsDirectory = pluginsDirectory
ts.buildPluginBinaries(t)
t.Logf("Building test packer binary...")
packerPath, err := BuildTestPacker(t)
if err != nil {
t.Fatalf("failed to build Packer binary: %s", err)
}
ts.packerPath = packerPath
t.Logf("Done")
defer func() {
err := os.Remove(ts.packerPath)
if err != nil {
t.Logf("failed to cleanup compiled packer binary %q: %s. This will need manual aciton", packerPath, err)
}
}()
suite.Run(t, ts)
}

@ -0,0 +1,14 @@
packer {
required_plugins {
tester = {
source = "github.com/hashicorp/tester"
version = ">= 1.0.0"
}
}
}
source "tester-dynamic" "test" {}
build {
sources = ["tester-dynamic.test"]
}
Loading…
Cancel
Save