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
61 changes: 58 additions & 3 deletions cmd/wfctl/plugin_conformance.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ func runPluginConformance(args []string) error {
fs := flag.NewFlagSet("plugin conformance", flag.ContinueOnError)
mode := fs.String("mode", PluginCompatibilityModeTypedIaC, "Conformance mode (typed-iac)")
artifact := fs.String("artifact", "", "Release artifact tar.gz to test")
buildPackage := fs.String("build-package", ".", "Go package to build when testing a source directory")
engineVersion := fs.String("engine-version", "", "Workflow engine version for evidence metadata")
format := fs.String("format", "text", "Output format: text or json")
output := fs.String("output", "", "Write JSON evidence to this path")
Expand All @@ -35,15 +36,29 @@ func runPluginConformance(args []string) error {
if err := fs.Parse(args); err != nil {
return err
}
buildPackageSet := false
fs.Visit(func(f *flag.Flag) {
if f.Name == "build-package" {
buildPackageSet = true
}
})
if *mode != PluginCompatibilityModeTypedIaC {
return fmt.Errorf("unsupported conformance mode %q", *mode)
}
if *format != "text" && *format != "json" {
return fmt.Errorf("--format must be text or json")
}
normalizedBuildPackage, err := normalizeConformanceBuildPackage(*buildPackage)
if err != nil {
return err
}
*buildPackage = normalizedBuildPackage
if *artifact != "" && fs.NArg() > 0 {
return fmt.Errorf("specify exactly one of <plugin-dir> or --artifact")
}
if *artifact != "" && buildPackageSet {
return fmt.Errorf("--build-package is only supported with <plugin-dir>")
}
if *artifact == "" && fs.NArg() != 1 {
fs.Usage()
return fmt.Errorf("specify exactly one of <plugin-dir> or --artifact")
Expand All @@ -62,6 +77,7 @@ func runPluginConformance(args []string) error {
Mode: *mode,
SourceDir: source,
ArtifactPath: *artifact,
BuildPackage: *buildPackage,
EngineVersion: *engineVersion,
Timeout: *timeout,
})
Expand Down Expand Up @@ -97,7 +113,7 @@ func runPluginConformance(args []string) error {
}

func printPluginConformanceUsage(w io.Writer, fs *flag.FlagSet) {
fmt.Fprintf(w, "Usage: wfctl plugin conformance [options] <plugin-dir>\n wfctl plugin conformance --artifact <tar.gz> [options]\n\nRun executable plugin/host conformance checks. This executes plugin code; run only on trusted local sources or CI artifacts.\n\nFlags: --artifact --mode --engine-version --format --output --timeout\n\nOptions:\n")
fmt.Fprintf(w, "Usage: wfctl plugin conformance [options] <plugin-dir>\n wfctl plugin conformance --artifact <tar.gz> [options]\n\nRun executable plugin/host conformance checks. This executes plugin code; run only on trusted local sources or CI artifacts.\n\nFlags: --artifact --build-package --mode --engine-version --format --output --timeout\n\nOptions:\n")
fs.PrintDefaults()
}

Expand All @@ -113,10 +129,45 @@ func resolveConformanceEngineVersion() string {
return "v0.0.0"
}

func normalizeConformanceBuildPackage(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", fmt.Errorf("--build-package must not be empty")
}
if strings.HasPrefix(value, "-") {
return "", fmt.Errorf("--build-package must be a package path, not a go build flag")
}
if filepath.IsAbs(value) {
return "", fmt.Errorf("--build-package must stay inside the plugin directory")
}
if strings.Contains(value, "...") {
return "", fmt.Errorf("--build-package must name one package, not a package pattern")
}
if strings.Contains(value, "\\") {
return "", fmt.Errorf("--build-package must use slash-separated Go package paths")
}
if value == "." {
return ".", nil
}
if !strings.HasPrefix(value, "./") {
return "", fmt.Errorf("--build-package must be . or a ./ path inside the plugin directory")
}
rel := strings.TrimPrefix(value, "./")
if rel == "" {
return "", fmt.Errorf("--build-package must name a package")
}
clean := filepath.Clean(rel)
if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(os.PathSeparator)) {
return "", fmt.Errorf("--build-package must stay inside the plugin directory")
}
return "./" + filepath.ToSlash(clean), nil
}

type pluginConformanceOptions struct {
Mode string
SourceDir string
ArtifactPath string
BuildPackage string
EngineVersion string
Timeout time.Duration
}
Expand Down Expand Up @@ -175,12 +226,16 @@ func runPluginConformanceCheck(opts pluginConformanceOptions) (PluginCompatibili
return PluginCompatibilityEvidence{}, err
}
binaryPath := filepath.Join(installDir, installName)
if info, statErr := os.Stat(filepath.Join(sourceDir, installName)); statErr == nil && !info.IsDir() && info.Mode()&0o111 != 0 {
if info, statErr := os.Stat(filepath.Join(sourceDir, installName)); opts.ArtifactPath != "" && statErr == nil && !info.IsDir() && info.Mode()&0o111 != 0 {
if err := copyFile(filepath.Join(sourceDir, installName), binaryPath, info.Mode()); err != nil {
return PluginCompatibilityEvidence{}, err
}
} else {
cmd := exec.Command("go", "build", "-mod=mod", "-o", binaryPath, ".") //nolint:gosec // command args are fixed; dir is staged source.
buildPackage := opts.BuildPackage
if buildPackage == "" {
buildPackage = "."
}
cmd := exec.Command("go", "build", "-mod=mod", "-o", binaryPath, buildPackage) //nolint:gosec // command args are fixed; dir is staged source.
Comment thread
intel352 marked this conversation as resolved.
cmd.Dir = sourceDir
cmd.Env = append(os.Environ(), "GOWORK=off")
out, err := cmd.CombinedOutput()
Expand Down
139 changes: 138 additions & 1 deletion cmd/wfctl/plugin_conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"io"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
Expand All @@ -30,7 +31,7 @@ func TestPluginConformanceHelpListsFlags(t *testing.T) {
if !errors.Is(err, flag.ErrHelp) {
t.Fatalf("runPluginConformance --help error = %v, want flag.ErrHelp", err)
}
for _, want := range []string{"--artifact", "--mode", "--engine-version", "--timeout", "executes plugin code"} {
for _, want := range []string{"--artifact", "--build-package", "--mode", "--engine-version", "--timeout", "executes plugin code"} {
if !strings.Contains(output, want) {
t.Fatalf("help output missing %q:\n%s", want, output)
}
Expand Down Expand Up @@ -90,6 +91,142 @@ func TestPluginConformanceLocalJSONPass(t *testing.T) {
}
}

func TestPluginConformanceBuildsRequestedPackage(t *testing.T) {
fixture := prepareIACPassFixture(t)
cmdDir := filepath.Join(fixture, "cmd", "plugin")
if err := os.MkdirAll(cmdDir, 0o750); err != nil {
t.Fatalf("mkdir cmd/plugin: %v", err)
}
if err := os.Rename(filepath.Join(fixture, "main.go"), filepath.Join(cmdDir, "main.go")); err != nil {
t.Fatalf("move main.go: %v", err)
}
out := filepath.Join(t.TempDir(), "evidence.json")
if err := runPluginConformance([]string{
"--mode", "typed-iac",
"--engine-version", "v0.51.2",
"--build-package", "./cmd/plugin",
"--format", "json",
"--output", out,
fixture,
}); err != nil {
t.Fatalf("runPluginConformance with build package: %v", err)
}
ev := readEvidence(t, out)
if ev.Status != PluginCompatibilityStatusPass {
t.Fatalf("status = %q, want pass", ev.Status)
}
}

func TestPluginConformanceSourceBuildPackageIgnoresRootBinary(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell executable fixture is Unix-specific")
}
fixture := prepareIACPassFixture(t)
cmdDir := filepath.Join(fixture, "cmd", "plugin")
if err := os.MkdirAll(cmdDir, 0o750); err != nil {
t.Fatalf("mkdir cmd/plugin: %v", err)
}
if err := os.Rename(filepath.Join(fixture, "main.go"), filepath.Join(cmdDir, "main.go")); err != nil {
t.Fatalf("move main.go: %v", err)
}
staleBinary := filepath.Join(fixture, "iac-pass")
if err := os.WriteFile(staleBinary, []byte("#!/bin/sh\necho stale-root-binary >&2\nexit 9\n"), 0o755); err != nil {
t.Fatalf("write stale root binary: %v", err)
}
out := filepath.Join(t.TempDir(), "evidence.json")
if err := runPluginConformance([]string{
"--mode", "typed-iac",
"--engine-version", "v0.51.2",
"--build-package", "./cmd/plugin",
"--format", "json",
"--output", out,
fixture,
}); err != nil {
t.Fatalf("runPluginConformance with stale root binary: %v", err)
}
ev := readEvidence(t, out)
if strings.Contains(ev.StderrTail, "stale-root-binary") {
t.Fatalf("source conformance used stale root binary: %#v", ev)
}
}

func TestPluginConformanceRejectsUnsafeBuildPackage(t *testing.T) {
fixture := prepareIACPassFixture(t)
for _, buildPackage := range []string{
"-toolexec=/tmp/evil",
"example.com/other/plugin",
"../other",
"./../other",
"/tmp/plugin",
"./...",
"./cmd/...",
} {
t.Run(buildPackage, func(t *testing.T) {
err := runPluginConformance([]string{
"--mode", "typed-iac",
"--engine-version", "v0.51.2",
"--build-package", buildPackage,
fixture,
})
if err == nil {
t.Fatal("expected unsafe build package to be rejected")
}
if !strings.Contains(err.Error(), "build-package") {
t.Fatalf("error = %v, want build-package context", err)
}
})
}
}

func TestPluginConformanceRejectsExplicitBuildPackageWithArtifact(t *testing.T) {
fixture := prepareIACPassFixture(t)
archive := filepath.Join(t.TempDir(), "iac-pass.tar.gz")
writeTarGzFromDir(t, fixture, archive)
err := runPluginConformance([]string{
"--mode", "typed-iac",
"--artifact", archive,
"--build-package", ".",
})
if err == nil {
t.Fatal("expected explicit build package with artifact to fail")
}
if !strings.Contains(err.Error(), "--build-package") {
t.Fatalf("error = %v, want --build-package context", err)
}
}

func TestNormalizeConformanceBuildPackage(t *testing.T) {
for _, tc := range []struct {
name string
in string
want string
wantErr bool
}{
{name: "root", in: ".", want: "."},
{name: "trim", in: " ./cmd/plugin ", want: "./cmd/plugin"},
{name: "clean", in: "./cmd/../plugin", want: "./plugin"},
{name: "empty", in: "", wantErr: true},
{name: "slash root", in: "./", wantErr: true},
{name: "backslash", in: `.\cmd\plugin`, wantErr: true},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := normalizeConformanceBuildPackage(tc.in)
if tc.wantErr {
if err == nil {
t.Fatalf("normalizeConformanceBuildPackage(%q) succeeded, want error", tc.in)
}
return
}
if err != nil {
t.Fatalf("normalizeConformanceBuildPackage(%q): %v", tc.in, err)
}
if got != tc.want {
t.Fatalf("normalizeConformanceBuildPackage(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}

func TestPluginConformanceDefaultEngineVersionIsStrictSemver(t *testing.T) {
t.Setenv("WFCTL_ENGINE_VERSION", "")
got := resolveConformanceEngineVersion()
Expand Down
2 changes: 2 additions & 0 deletions docs/WFCTL.md
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,7 @@ wfctl plugin conformance --artifact <tar.gz> [options]
|------|---------|-------------|
| `--mode` | `typed-iac` | Conformance mode. Currently checks strict typed IaC plugin launch/contract compatibility |
| `--artifact` | _(none)_ | Release artifact tar.gz to test instead of a local plugin directory |
| `--build-package` | `.` | Go package to build when testing a source directory, for example `./cmd/plugin` |
| `--engine-version` | build version or `WFCTL_ENGINE_VERSION` | Workflow engine version recorded in evidence |
| `--format` | `text` | Output format: `text` or `json` |
| `--output` | _(none)_ | Write JSON evidence to a file |
Expand All @@ -567,6 +568,7 @@ Local directory evidence is useful during development. Registry enforcement shou

```bash
wfctl plugin conformance --mode typed-iac --format json ./workflow-plugin-digitalocean
wfctl plugin conformance --mode typed-iac --build-package ./cmd/plugin --format json ./workflow-plugin-digitalocean
wfctl plugin conformance --artifact dist/workflow-plugin-digitalocean.tar.gz --engine-version v0.51.2 --output evidence.json
```

Expand Down
Loading