diff --git a/.github/workflows/iac-host-conformance.yml b/.github/workflows/iac-host-conformance.yml new file mode 100644 index 0000000..cf00401 --- /dev/null +++ b/.github/workflows/iac-host-conformance.yml @@ -0,0 +1,82 @@ +name: IaC Host Conformance +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + GOPRIVATE: github.com/GoCodeAlone/* + +jobs: + legacy-module-engine-range: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Configure Git for private repos + env: + RELEASES_TOKEN: ${{ secrets.RELEASES_TOKEN }} + run: | + if [ -n "${RELEASES_TOKEN}" ]; then + git config --global url."https://x-access-token:${RELEASES_TOKEN}@github.com/".insteadOf "https://github.com/" + fi + + - name: Determine Workflow engine versions + id: versions + env: + GH_TOKEN: ${{ github.token }} + WORKFLOW_CURRENT_VERSION: ${{ vars.WORKFLOW_CURRENT_VERSION }} + run: | + set -euo pipefail + min="$(jq -r '.minEngineVersion // empty' plugin.json)" + if [ -z "${min}" ]; then + echo "::error::plugin.json must declare minEngineVersion" + exit 1 + fi + case "${min}" in + v*) min_version="${min}" ;; + *) min_version="v${min}" ;; + esac + current="${WORKFLOW_CURRENT_VERSION}" + if [ -z "${current}" ]; then + current="$(gh release view --repo GoCodeAlone/workflow --json tagName --jq '.tagName')" + fi + if [ -z "${current}" ]; then + echo "::error::could not determine current Workflow engine release" + exit 1 + fi + first="$(printf '%s\n%s\n' "${current}" "${min_version}" | sort -V | head -n1)" + if [ "${first}" = "${current}" ] && [ "${current}" != "${min_version}" ]; then + echo "::notice::current Workflow release ${current} is older than declared minimum ${min_version}; testing minimum as current" + current="${min_version}" + fi + echo "min=${min_version}" >> "${GITHUB_OUTPUT}" + echo "current=${current}" >> "${GITHUB_OUTPUT}" + echo "Declared min engine: ${min_version}" + echo "Current engine release: ${current}" + + # Current-release probing is intentional: this plugin should keep loading + # on newly released Workflow hosts. Pin WORKFLOW_CURRENT_VERSION at the + # repo/org level when a release is under investigation. + - name: Conformance against declared minimum engine + run: ./scripts/workflow-iac-host-conformance.sh "${{ steps.versions.outputs.min }}" min + + - name: Conformance against current engine release + if: steps.versions.outputs.current != steps.versions.outputs.min + run: ./scripts/workflow-iac-host-conformance.sh "${{ steps.versions.outputs.current }}" current + + - name: Remove private repo Git credential rewrite + if: always() + env: + RELEASES_TOKEN: ${{ secrets.RELEASES_TOKEN }} + run: | + if [ -n "${RELEASES_TOKEN}" ]; then + git config --global --unset-all url."https://x-access-token:${RELEASES_TOKEN}@github.com/".insteadOf || true + fi diff --git a/internal/host_conformance_test.go b/internal/host_conformance_test.go new file mode 100644 index 0000000..381bebb --- /dev/null +++ b/internal/host_conformance_test.go @@ -0,0 +1,118 @@ +package internal + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + + "github.com/GoCodeAlone/workflow/plugin/external" + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" +) + +func TestWorkflowHostConformance_LoadsLegacyIaCModulePlugin(t *testing.T) { + if os.Getenv("WORKFLOW_IAC_HOST_CONFORMANCE") != "1" { + t.Skip("set WORKFLOW_IAC_HOST_CONFORMANCE=1 to run host compatibility smoke") + } + + // AWS is still a legacy sdk.Serve module plugin, not a strict-cutover + // sdk.ServeIaCPlugin provider. This gate validates the host/plugin boundary + // it actually ships today: external plugin load, iac.provider discovery, and + // strict module contract registry exposure across engine versions. + repoRoot := hostConformanceRepoRoot(t) + pluginName := hostConformancePluginName(t, filepath.Join(repoRoot, "plugin.json")) + + pluginsDir := filepath.Join(t.TempDir(), "data", "plugins") + pluginDir := filepath.Join(pluginsDir, pluginName) + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + t.Fatalf("mkdir plugin dir: %v", err) + } + hostConformanceCopyFile(t, filepath.Join(repoRoot, "plugin.json"), filepath.Join(pluginDir, "plugin.json")) + hostConformanceCopyFile(t, filepath.Join(repoRoot, "plugin.contracts.json"), filepath.Join(pluginDir, "plugin.contracts.json")) + + build := exec.Command("go", "build", "-o", filepath.Join(pluginDir, pluginName), "./cmd/workflow-plugin-aws") + build.Dir = repoRoot + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build plugin binary: %v\n%s", err, out) + } + + mgr := external.NewExternalPluginManager(pluginsDir, nil) + t.Cleanup(mgr.Shutdown) + + adapter, err := mgr.LoadPlugin(pluginName) + if err != nil { + t.Fatalf("load plugin through Workflow external host: %v", err) + } + if adapter.Name() != pluginName { + t.Fatalf("host adapter name = %q, want %q", adapter.Name(), pluginName) + } + if !hasString(adapter.EngineManifest().ModuleTypes, moduleTypeIaCProvider) { + t.Fatalf("host adapter module types = %v, want %q", adapter.EngineManifest().ModuleTypes, moduleTypeIaCProvider) + } + + registry := adapter.ContractRegistry() + if registry == nil { + t.Fatal("contract registry is nil") + } + if !registryHasModule(registry, moduleTypeIaCProvider) { + t.Fatalf("contract registry missing module %q: %v", moduleTypeIaCProvider, registry.GetContracts()) + } +} + +func hostConformanceRepoRoot(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + return filepath.Clean(filepath.Join(filepath.Dir(file), "..")) +} + +func hostConformancePluginName(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read plugin manifest: %v", err) + } + var manifest struct { + Name string `json:"name"` + } + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatalf("parse plugin manifest: %v", err) + } + if manifest.Name == "" { + t.Fatal("plugin manifest missing name") + } + return manifest.Name +} + +func hostConformanceCopyFile(t *testing.T, src, dst string) { + t.Helper() + data, err := os.ReadFile(src) + if err != nil { + t.Fatalf("read %s: %v", src, err) + } + if err := os.WriteFile(dst, data, 0o644); err != nil { + t.Fatalf("write %s: %v", dst, err) + } +} + +func registryHasModule(registry *pb.ContractRegistry, moduleType string) bool { + for _, contract := range registry.GetContracts() { + if contract.GetKind() == pb.ContractKind_CONTRACT_KIND_MODULE && contract.GetModuleType() == moduleType { + return true + } + } + return false +} + +func hasString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} diff --git a/scripts/workflow-iac-host-conformance.sh b/scripts/workflow-iac-host-conformance.sh new file mode 100755 index 0000000..8cfc5b5 --- /dev/null +++ b/scripts/workflow-iac-host-conformance.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +engine_version="${1:?usage: workflow-iac-host-conformance.sh [label]}" +label="${2:-${engine_version}}" + +case "${engine_version}" in + v*) ;; + *) engine_version="v${engine_version}" ;; +esac + +repo_root="$(git rev-parse --show-toplevel)" +tmp_dir="$(mktemp -d)" +trap 'rm -rf "${tmp_dir}"' EXIT + +work_dir="${tmp_dir}/repo" +mkdir -p "${work_dir}" + +rsync -a \ + --exclude '.git' \ + --exclude '.worktrees' \ + --exclude '_worktrees' \ + --exclude 'data' \ + "${repo_root}/" "${work_dir}/" + +cd "${work_dir}" + +echo "==> workflow IaC host conformance (${label}): github.com/GoCodeAlone/workflow@${engine_version}" +go mod edit -require "github.com/GoCodeAlone/workflow@${engine_version}" +GOWORK=off go mod tidy +WORKFLOW_IAC_HOST_CONFORMANCE=1 GOWORK=off go test ./internal -run TestWorkflowHostConformance_LoadsLegacyIaCModulePlugin -count=1 -v