1
0
mirror of https://github.com/hashicorp/packer.git synced 2026-02-06 06:45:07 +01:00
Files
packer/packer_test/common/plugin.go
Lucas Bajolet 5ff0f146c6 packer_test: introduce global compilation queue
Compiling plugins was originally intended to be an idempotent operation.
This however starts to change as we introduce build customisations,
which have the unfortunate side-effect of changing the state of the
plugin directory, leading to conflicts between concurrent compilation
jobs.

Therefore to mitigate this problem, this commit changes how compilation
jobs are processed, by introducing a global compilation queue, and
processing plugins' compilation one-by-one from this queue.

This however makes such requests asynchronous, so test suites that
require plugins to be compiled will now have to wait on their completion
before they can start their tests.

To this effect, we introduce one more convenience function that
processes those errors, and automatically fails the test should one
compilation job fail for any reason.
2024-12-17 10:45:33 -05:00

371 lines
12 KiB
Go

package common
import (
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/hashicorp/go-version"
"github.com/hashicorp/packer-plugin-sdk/plugin"
"github.com/hashicorp/packer/packer_test/common/check"
)
// LDFlags compiles the ldflags for the plugin to compile based on the information provided.
func LDFlags(version *version.Version) string {
pluginPackage := "github.com/hashicorp/packer-plugin-tester"
ldflagsArg := fmt.Sprintf("-X %s/version.Version=%s", pluginPackage, version.Core())
if version.Prerelease() != "" {
ldflagsArg = fmt.Sprintf("%s -X %s/version.VersionPrerelease=%s", ldflagsArg, pluginPackage, version.Prerelease())
}
if version.Metadata() != "" {
ldflagsArg = fmt.Sprintf("%s -X %s/version.VersionMetadata=%s", ldflagsArg, pluginPackage, version.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 BinaryName(version *version.Version) string {
retStr := fmt.Sprintf("mini-plugin_%s", version.Core())
if version.Prerelease() != "" {
retStr = fmt.Sprintf("%s-%s", retStr, version.Prerelease())
}
if version.Metadata() != "" {
retStr = fmt.Sprintf("%s+%s", retStr, version.Metadata())
}
return retStr
}
// ExpectedInstalledName is the expected full name of the plugin once installed.
func ExpectedInstalledName(versionStr string) string {
version.Must(version.NewVersion(versionStr))
versionStr = strings.ReplaceAll(versionStr, "v", "")
ext := ""
if runtime.GOOS == "windows" {
ext = ".exe"
}
return fmt.Sprintf("packer-plugin-tester_v%s_x%s.%s_%s_%s%s",
versionStr,
plugin.APIVersionMajor,
plugin.APIVersionMinor,
runtime.GOOS, runtime.GOARCH, ext)
}
// BuildCustomisation is a function that allows you to change things on a plugin's
// local files, with a way to rollback those changes after the fact.
//
// The function is meant to take a path parameter to the directory for the plugin,
// and returns a function that unravels those changes once the build process is done.
type BuildCustomisation func(string) (error, func())
const SDKModule = "github.com/hashicorp/packer-plugin-sdk"
// UseDependency invokes go get and go mod tidy to update a package required
// by the plugin, and use it to build the plugin with that change.
func UseDependency(remoteModule, ref string) BuildCustomisation {
return func(path string) (error, func()) {
modPath := filepath.Join(path, "go.mod")
stat, err := os.Stat(modPath)
if err != nil {
return fmt.Errorf("cannot stat mod file %q: %s", modPath, err), nil
}
// Save old go.mod file from dir
oldGoMod, err := os.ReadFile(modPath)
if err != nil {
return fmt.Errorf("failed to read current mod file %q: %s", modPath, err), nil
}
modSpec := fmt.Sprintf("%s@%s", remoteModule, ref)
cmd := exec.Command("go", "get", modSpec)
cmd.Dir = path
err = cmd.Run()
if err != nil {
return fmt.Errorf("failed to run go get %s: %s", modSpec, err), nil
}
cmd = exec.Command("go", "mod", "tidy")
cmd.Dir = path
err = cmd.Run()
if err != nil {
return fmt.Errorf("failed to run go mod tidy: %s", err), nil
}
return nil, func() {
err = os.WriteFile(modPath, oldGoMod, stat.Mode())
if err != nil {
fmt.Fprintf(os.Stderr, "failed to reset modfile %q: %s; manual cleanup may be needed", modPath, err)
}
cmd := exec.Command("go", "mod", "tidy")
cmd.Dir = path
_ = cmd.Run()
}
}
}
// GetPluginPath gets the path for a pre-compiled plugin in the current test suite.
//
// The version only is needed, as the path to a compiled version of the tester
// plugin will be returned, so it can be installed after the fact.
//
// If the version requested does not exist, the function will panic.
func (ts *PackerTestSuite) GetPluginPath(t *testing.T, version string) string {
path, ok := ts.compiledPlugins.Load(version)
if !ok {
t.Fatalf("tester plugin in version %q was not built, either build it during suite init, or with BuildTestPlugin", version)
}
return path.(string)
}
type CompilationResult struct {
Error error
Version string
}
// Ready processes a series of CompilationResults, as returned by CompilePlugin
//
// If any of the jobs requested failed, the test will fail also.
func Ready(t *testing.T, results []chan CompilationResult) {
for _, res := range results {
jobErr := <-res
empty := CompilationResult{}
if jobErr != empty {
t.Errorf("failed to compile plugin at version %s: %s", jobErr.Version, jobErr.Error)
}
}
if t.Failed() {
t.Fatalf("some plugins failed to be compiled, see logs for more info")
}
}
type compilationJob struct {
versionString string
suite *PackerTestSuite
done bool
resultCh chan CompilationResult
customisations []BuildCustomisation
}
// CompilationJobs keeps a queue of compilation jobs for plugins
//
// This approach allows us to avoid conflicts between compilation jobs.
// Typically building the plugin with different ldflags is safe to perform
// in parallel on the same file set, however customisations tend to be more
// conflictual, as two concurrent compilation jobs may end-up compiling the
// wrong plugin, which may cause some tests to misbehave, or even compilation
// jobs to fail.
//
// The solution to this approach is to have a global queue for every plugin
// compilation to be performed safely.
var CompilationJobs = make(chan compilationJob, 10)
// CompilePlugin builds a tester plugin with the specified version.
//
// The plugin's code is contained in a subdirectory of this file, 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.
//
// Note: each tester plugin may only be compiled once for a specific version in
// a test suite. The version may include core (mandatory), pre-release and
// metadata. Unlike Packer core, metadata does matter for the version being built.
//
// Note: the compilation will process asynchronously, and should be waited upon
// before tests that use this plugin may proceed. Refer to the `Ready` function
// for doing that.
func (ts *PackerTestSuite) CompilePlugin(versionString string, customisations ...BuildCustomisation) chan CompilationResult {
resultCh := make(chan CompilationResult)
CompilationJobs <- compilationJob{
versionString: versionString,
suite: ts,
customisations: customisations,
done: false,
resultCh: resultCh,
}
return resultCh
}
func init() {
// Run a processor coroutine for the duration of the test.
//
// It's simpler to have this occurring on the side at all times, without
// trying to manage its lifecycle based on the current amount of queued
// tasks, since this is linked to the test lifecycle, and as it's a single
// coroutine, we can leave it run until the process exits.
go func() {
for job := range CompilationJobs {
log.Printf("compiling plugin on version %s", job.versionString)
err := compilePlugin(job.suite, job.versionString, job.customisations...)
if err != nil {
job.resultCh <- CompilationResult{
Error: err,
Version: job.versionString,
}
}
close(job.resultCh)
}
}()
}
// compilePlugin performs the actual compilation procedure for the plugin, and
// registers it to the test suite instance passed as a parameter.
func compilePlugin(ts *PackerTestSuite, versionString string, customisations ...BuildCustomisation) error {
// Fail to build plugin if already built.
//
// Especially with customisations being a thing, relying on cache to get and
// build a plugin at once means that the function is not idempotent anymore,
// and therefore we cannot rely on it being called twice and producing the
// same result, so we forbid it.
if _, ok := ts.compiledPlugins.Load(versionString); ok {
return fmt.Errorf("plugin version %q was already built, use GetTestPlugin instead", versionString)
}
v := version.Must(version.NewSemver(versionString))
testDir, err := currentDir()
if err != nil {
return fmt.Errorf("failed to compile plugin binary: %s", err)
}
testerPluginDir := filepath.Join(testDir, "plugin_tester")
for _, custom := range customisations {
err, cleanup := custom(testerPluginDir)
if err != nil {
return fmt.Errorf("failed to prepare plugin workdir: %s", err)
}
defer cleanup()
}
outBin := filepath.Join(ts.pluginsDirectory, BinaryName(v))
compileCommand := exec.Command("go", "build", "-C", testerPluginDir, "-o", outBin, "-ldflags", LDFlags(v), ".")
logs, err := compileCommand.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to compile plugin binary: %s\ncompiler logs: %s", err, logs)
}
ts.compiledPlugins.Store(v.String(), outBin)
return nil
}
type PluginDirSpec struct {
dirPath string
suite *PackerTestSuite
}
// 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.
//
// Note: all of the plugin versions specified to be installed in this plugin directory
// must have been compiled beforehand.
func (ts *PackerTestSuite) MakePluginDir() *PluginDirSpec {
var err error
pluginTempDir, err := os.MkdirTemp("", "packer-plugin-dir-temp-")
if err != nil {
return nil
}
return &PluginDirSpec{
dirPath: pluginTempDir,
suite: ts,
}
}
// InstallPluginVersions installs several versions of the tester plugin under
// github.com/hashicorp/tester.
//
// Each version of the plugin needs to have been pre-compiled.
//
// If a plugin is missing, the temporary directory will be removed.
func (ps *PluginDirSpec) InstallPluginVersions(pluginVersions ...string) *PluginDirSpec {
t := ps.suite.T()
var err error
defer func() {
if err != nil || t.Failed() {
rmErr := os.RemoveAll(ps.Dir())
if rmErr != nil {
t.Logf("failed to remove temporary plugin directory %q: %s. This may need manual intervention.", ps.Dir(), err)
}
t.Fatalf("failed to install plugins to temporary plugin directory %q: %s", ps.Dir(), err)
}
}()
for _, pluginVersion := range pluginVersions {
path := ps.suite.GetPluginPath(t, pluginVersion)
cmd := ps.suite.PackerCommand().SetArgs("plugins", "install", "--path", path, "github.com/hashicorp/tester").AddEnv("PACKER_PLUGIN_PATH", ps.Dir())
cmd.Assert(check.MustSucceed())
out, stderr, cmdErr := cmd.run()
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 ps
}
// Dir returns the temporary plugin dir for use in other functions
func (ps PluginDirSpec) Dir() string {
return ps.dirPath
}
func (ps *PluginDirSpec) Cleanup() {
pluginDir := ps.Dir()
if pluginDir == "" {
return
}
err := os.RemoveAll(pluginDir)
if err != nil {
ps.suite.T().Logf("failed to remove temporary plugin directory %q: %s. This may need manual intervention.", pluginDir, err)
}
}
// ManualPluginInstall emulates how Packer installs plugins with `packer plugins install`
//
// This is used for some tests if we want to install a plugin that cannot be installed
// through the normal commands (typically because Packer rejects it).
func ManualPluginInstall(t *testing.T, dest, srcPlugin, versionStr string) {
err := os.MkdirAll(dest, 0755)
if err != nil {
t.Fatalf("failed to create destination directories %q: %s", dest, err)
}
pluginName := ExpectedInstalledName(versionStr)
destPath := filepath.Join(dest, pluginName)
CopyFile(t, destPath, srcPlugin)
shaPath := fmt.Sprintf("%s_SHA256SUM", destPath)
WriteFile(t, shaPath, SHA256Sum(t, destPath))
}