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
4 changes: 2 additions & 2 deletions cmd/wfctl/deploy_providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,8 @@ var resolveIaCProvider = discoverAndLoadIaCProvider
// double parse — and either may be empty without affecting the
// other.
type iacPluginManifest struct {
Name string `json:"name"`
Version string `json:"version"`
Name string `json:"name"`
Version string `json:"version"`
Capabilities struct {
IaCProvider struct {
Name string `json:"name"`
Expand Down
21 changes: 20 additions & 1 deletion cmd/wfctl/plugin_compat_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,21 @@ import (
var errInvalidRegistrySHA256 = errors.New("invalid sha256")

const (
// PluginCompatibilityModeTypedIaC is the conformance mode that proves a
// plugin registers pb.IaCProviderRequired and passes the Workflow go-plugin
// handshake. This is the only mode that satisfies typed-IaC registry
// readiness for manifests advertising iacProvider capability.
PluginCompatibilityModeTypedIaC = "typed-iac"

// PluginCompatibilityModeLegacyHostLoad is the advisory-only mode for
// smoke evidence produced by legacy host-load checks (e.g. sdk.Serve
// module plugins that can load and expose metadata/contracts but have not
// migrated to sdk.ServeIaCPlugin / pb.IaCProviderRequired). This mode is
// NEVER sufficient to satisfy typed-IaC registry readiness; it must not be
// used to gate install/update decisions for plugins that advertise
// iacProvider capability.
PluginCompatibilityModeLegacyHostLoad = "legacy-host-load"

PluginCompatibilityStatusPass = "pass"
PluginCompatibilityStatusFail = "fail"

Expand Down Expand Up @@ -234,7 +247,13 @@ func ValidateCompatibilityEvidence(ev PluginCompatibilityEvidence) (PluginCompat
ev.WfctlVersion = canonical
}
}
if ev.Mode != PluginCompatibilityModeTypedIaC {
switch ev.Mode {
case PluginCompatibilityModeTypedIaC:
// typed-iac is the only mode that satisfies IaC registry readiness.
case PluginCompatibilityModeLegacyHostLoad:
// legacy-host-load is advisory only; it must not gate IaC installs.
// See manifestAdvertisesIaCProvider / updateRegistryCompatibilityIndex.
default:
return ev, fmt.Errorf("unsupported compatibility mode %q", ev.Mode)
}
if ev.Status != PluginCompatibilityStatusPass && ev.Status != PluginCompatibilityStatusFail {
Expand Down
48 changes: 48 additions & 0 deletions cmd/wfctl/plugin_compat_model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,3 +218,51 @@ func TestPluginCompatEvidenceValidation(t *testing.T) {
t.Fatalf("marshal normalized evidence: %v", err)
}
}

// TestPluginCompatLegacyHostLoadModeIsValidButAdvisory verifies that evidence
// with mode=legacy-host-load is accepted by ValidateCompatibilityEvidence
// (so it can be stored/named) but that its mode constant is distinct from
// typed-iac. The resolver must never select legacy-host-load evidence for IaC
// readiness checks; this test confirms it round-trips correctly.
func TestPluginCompatLegacyHostLoadModeIsValidButAdvisory(t *testing.T) {
ev := PluginCompatibilityEvidence{
Plugin: "workflow-plugin-test",
Version: "v0.1.0",
EngineVersion: "v0.51.2",
Mode: PluginCompatibilityModeLegacyHostLoad,
Status: PluginCompatibilityStatusPass,
OS: "linux",
Arch: "amd64",
}
got, err := ValidateCompatibilityEvidence(ev)
if err != nil {
t.Fatalf("ValidateCompatibilityEvidence(legacy-host-load): %v", err)
}
if got.Mode != PluginCompatibilityModeLegacyHostLoad {
t.Fatalf("mode = %q, want legacy-host-load", got.Mode)
}
if got.EvidenceDigest == "" {
t.Fatalf("legacy-host-load evidence missing digest: %#v", got)
}
// Confirm legacy-host-load and typed-iac are distinct constants.
if PluginCompatibilityModeLegacyHostLoad == PluginCompatibilityModeTypedIaC {
t.Fatal("legacy-host-load and typed-iac must be distinct mode constants")
}
}

// TestPluginCompatLegacyHostLoadRejectsUnknownMode verifies that unknown
// mode strings are still rejected.
func TestPluginCompatLegacyHostLoadRejectsUnknownMode(t *testing.T) {
ev := PluginCompatibilityEvidence{
Plugin: "workflow-plugin-test",
Version: "v0.1.0",
EngineVersion: "v0.51.2",
Mode: "host-smoke",
Status: PluginCompatibilityStatusPass,
OS: "linux",
Arch: "amd64",
}
if _, err := ValidateCompatibilityEvidence(ev); err == nil {
t.Fatal("ValidateCompatibilityEvidence(unknown mode) succeeded, want error")
}
}
3 changes: 3 additions & 0 deletions cmd/wfctl/plugin_compat_resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,9 @@ func findCompatibilityEvidence(evidence []PluginCompatibilityEvidence, engine st
var rangeMatch *PluginCompatibilityEvidence
for i := range evidence {
ev := evidence[i]
// Only typed-iac evidence satisfies registry readiness checks.
// legacy-host-load evidence is advisory only and is intentionally
// excluded here — it must never satisfy IaC compatibility decisions.
if ev.Mode != PluginCompatibilityModeTypedIaC || ev.OS != goos || ev.Arch != goarch {
continue
}
Expand Down
31 changes: 31 additions & 0 deletions cmd/wfctl/plugin_compat_resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,37 @@ func TestPluginCompatResolverPseudoLocalVersionIsAdvisory(t *testing.T) {
}
}

func TestPluginCompatResolverLegacyHostLoadEvidenceNeverSatisfiesIaC(t *testing.T) {
// An index containing only legacy-host-load evidence must behave as if no
// compatible evidence exists. The resolver already filters by typed-iac mode
// inside findCompatibilityEvidence; this test confirms legacy-host-load does
// not sneak through for a first-party registry with RequiredFromEngine set.
legacyEv, err := ValidateCompatibilityEvidence(PluginCompatibilityEvidence{
Plugin: "workflow-plugin-test",
Version: "v0.2.0",
EngineVersion: "v0.51.2",
Mode: PluginCompatibilityModeLegacyHostLoad,
Status: PluginCompatibilityStatusPass,
OS: "darwin",
Arch: "arm64",
ArchiveSHA256: testArchiveSHA256,
})
if err != nil {
t.Fatalf("ValidateCompatibilityEvidence(legacy-host-load): %v", err)
}

idx := resolverIndex(resolverRecord("v0.2.0", legacyEv))
idx.EvidencePolicy.RequiredFromEngine = "v0.51.0"

_, resolveErr := ResolvePluginCompatibility(idx, nil, resolverOptions())
if resolveErr == nil {
t.Fatal("expected missing required evidence error when only legacy-host-load evidence is present")
}
if !strings.Contains(resolveErr.Error(), "missing required compatibility evidence") {
t.Fatalf("error = %v, want missing evidence context", resolveErr)
}
}

func resolverOptions() PluginCompatResolverOptions {
return PluginCompatResolverOptions{
EngineVersion: "v0.51.2",
Expand Down
24 changes: 24 additions & 0 deletions cmd/wfctl/registry_compatibility.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,19 @@ func updateRegistryCompatibilityIndex(opts registryCompatibilityUpdateOptions) e
if ev.Version != version {
return fmt.Errorf("evidence version %s does not match --version %s", ev.Version, version)
}
// IaC provider manifests require typed-iac conformance evidence only.
// legacy-host-load evidence is advisory/legacy and cannot satisfy
// typed-IaC registry readiness; reject it at index-update time so that
// the registry index never contains evidence that looks valid but would
// be silently ignored by the resolver.
if manifestAdvertisesIaCProvider(manifest) && ev.Mode != PluginCompatibilityModeTypedIaC {
return fmt.Errorf(
"plugin %q advertises iacProvider capability: only typed-iac conformance evidence satisfies IaC registry readiness; "+
"evidence %q has mode=%q (advisory/legacy only). "+
"Run: wfctl plugin conformance --mode typed-iac --artifact <archive> to generate valid evidence",
pluginName, path, ev.Mode,
)
}
if err := validateEvidenceArchiveMatchesDownload(ev, manifest); err != nil {
return err
}
Expand Down Expand Up @@ -378,6 +391,17 @@ func compatibilityIndexIsStale(index *PluginVersionIndex, latestEngine string) b
return newest == "" || semver.Compare(newest, latestEngine) < 0
}

// manifestAdvertisesIaCProvider returns true when a registry manifest declares
// an iacProvider capability with a non-empty provider name. These plugins must
// supply typed-iac conformance evidence; legacy-host-load evidence is rejected
// at index-update time for such manifests.
func manifestAdvertisesIaCProvider(m *RegistryManifest) bool {
return m != nil &&
m.Capabilities != nil &&
m.Capabilities.IaCProvider != nil &&
m.Capabilities.IaCProvider.Name != ""
}

func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o750); err != nil {
Expand Down
176 changes: 176 additions & 0 deletions cmd/wfctl/registry_compatibility_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,13 +289,189 @@ func TestRegistryCompatibilityUpdateDerivesPassRange(t *testing.T) {
}
}

func TestRegistryCompatibilityUpdateRejectsLegacyHostLoadForIaCManifest(t *testing.T) {
// An IaC provider manifest must reject legacy-host-load evidence at index
// update time because legacy-host-load is advisory only and cannot satisfy
// typed-IaC registry readiness.
registryDir := prepareIaCCompatibilityRegistry(t, "workflow-plugin-testcloud", "v0.1.0", testArchiveSHA256)
evPath := writeCompatibilityEvidence(t, registryDir, PluginCompatibilityEvidence{
Plugin: "workflow-plugin-testcloud",
Version: "v0.1.0",
EngineVersion: "v0.51.2",
Mode: PluginCompatibilityModeLegacyHostLoad,
Status: PluginCompatibilityStatusPass,
OS: "darwin",
Arch: "arm64",
ArchiveSHA256: testArchiveSHA256,
})

err := runPluginRegistry([]string{
"compatibility", "update",
"--registry-dir", registryDir,
"--plugin", "workflow-plugin-testcloud",
"--version", "v0.1.0",
"--evidence", evPath,
})
if err == nil {
t.Fatal("expected legacy-host-load rejection for IaC manifest")
}
if !strings.Contains(err.Error(), "iacProvider") {
t.Fatalf("error = %v, want iacProvider context", err)
}
if !strings.Contains(err.Error(), "typed-iac") {
t.Fatalf("error = %v, want typed-iac context", err)
}
if !strings.Contains(err.Error(), "legacy-host-load") || !strings.Contains(err.Error(), "advisory") {
t.Fatalf("error = %v, want legacy-host-load advisory context", err)
}
}

func TestRegistryCompatibilityUpdateAcceptsTypedIaCForIaCManifest(t *testing.T) {
// An IaC provider manifest must accept typed-iac evidence — the only mode
// that satisfies IaC registry readiness.
registryDir := prepareIaCCompatibilityRegistry(t, "workflow-plugin-testcloud", "v0.1.0", testArchiveSHA256)
evPath := writeCompatibilityEvidence(t, registryDir, PluginCompatibilityEvidence{
Plugin: "workflow-plugin-testcloud",
Version: "v0.1.0",
EngineVersion: "v0.51.2",
Mode: PluginCompatibilityModeTypedIaC,
Status: PluginCompatibilityStatusPass,
OS: "darwin",
Arch: "arm64",
ArchiveSHA256: testArchiveSHA256,
})

if err := runPluginRegistry([]string{
"compatibility", "update",
"--registry-dir", registryDir,
"--plugin", "workflow-plugin-testcloud",
"--version", "v0.1.0",
"--evidence", evPath,
}); err != nil {
t.Fatalf("compatibility update with typed-iac for IaC manifest: %v", err)
}
idx := readCompatibilityIndex(t, registryDir, "workflow-plugin-testcloud")
if len(idx.Versions) != 1 || len(idx.Versions[0].Compatibility) != 1 {
t.Fatalf("unexpected index: %#v", idx)
}
if idx.Versions[0].Compatibility[0].Mode != PluginCompatibilityModeTypedIaC {
t.Fatalf("evidence mode = %q, want typed-iac", idx.Versions[0].Compatibility[0].Mode)
}
}

func TestRegistryCompatibilityUpdateAcceptsLegacyHostLoadForNonIaCManifest(t *testing.T) {
// A non-IaC manifest (no iacProvider capability) must accept legacy-host-load
// evidence — this mode is valid for advisory/legacy checks on module plugins.
registryDir := prepareCompatibilityRegistry(t, "workflow-plugin-test", "v0.1.0", testArchiveSHA256)
evPath := writeCompatibilityEvidence(t, registryDir, PluginCompatibilityEvidence{
Plugin: "workflow-plugin-test",
Version: "v0.1.0",
EngineVersion: "v0.51.2",
Mode: PluginCompatibilityModeLegacyHostLoad,
Status: PluginCompatibilityStatusPass,
OS: "darwin",
Arch: "arm64",
ArchiveSHA256: testArchiveSHA256,
})

if err := runPluginRegistry([]string{
"compatibility", "update",
"--registry-dir", registryDir,
"--plugin", "workflow-plugin-test",
"--version", "v0.1.0",
"--evidence", evPath,
}); err != nil {
t.Fatalf("compatibility update with legacy-host-load for non-IaC manifest: %v", err)
}
idx := readCompatibilityIndex(t, registryDir, "workflow-plugin-test")
if len(idx.Versions) != 1 || len(idx.Versions[0].Compatibility) != 1 {
t.Fatalf("unexpected index: %#v", idx)
}
if idx.Versions[0].Compatibility[0].Mode != PluginCompatibilityModeLegacyHostLoad {
t.Fatalf("evidence mode = %q, want legacy-host-load", idx.Versions[0].Compatibility[0].Mode)
}
}

func TestManifestAdvertisesIaCProvider(t *testing.T) {
cases := []struct {
name string
m *RegistryManifest
want bool
}{
{name: "nil", m: nil, want: false},
{name: "no capabilities", m: &RegistryManifest{}, want: false},
{name: "nil iacProvider", m: &RegistryManifest{Capabilities: &RegistryCapabilities{}}, want: false},
{name: "empty iacProvider name", m: &RegistryManifest{Capabilities: &RegistryCapabilities{IaCProvider: &RegistryIaCProvider{}}}, want: false},
{name: "with iacProvider name", m: &RegistryManifest{Capabilities: &RegistryCapabilities{IaCProvider: &RegistryIaCProvider{Name: "testcloud"}}}, want: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := manifestAdvertisesIaCProvider(tc.m)
if got != tc.want {
t.Fatalf("manifestAdvertisesIaCProvider = %v, want %v", got, tc.want)
}
})
}
}

func prepareCompatibilityRegistry(t *testing.T, plugin, version, archiveSHA string) string {
t.Helper()
dir := t.TempDir()
writeManifest(t, dir, plugin, version, archiveSHA)
return dir
}

// prepareIaCCompatibilityRegistry creates a registry directory with a manifest
// that advertises iacProvider capability. Used to test the enforcement that
// only typed-iac evidence is accepted for IaC provider plugins.
func prepareIaCCompatibilityRegistry(t *testing.T, plugin, version, archiveSHA string) string {
t.Helper()
dir := t.TempDir()
writeIaCManifest(t, dir, plugin, version, archiveSHA)
return dir
}

func writeIaCManifest(t *testing.T, registryDir, plugin, version, archiveSHA string) {
t.Helper()
manifest := RegistryManifest{
Name: plugin,
Version: version,
Author: "workflow",
Description: "test IaC provider plugin",
Type: "external",
Tier: "first_party",
MinEngineVersion: "v0.50.0",
Downloads: []PluginDownload{{
OS: "darwin",
Arch: "arm64",
URL: "https://example.invalid/plugin.tar.gz",
SHA256: archiveSHA,
}, {
OS: "linux",
Arch: "amd64",
URL: "https://example.invalid/plugin-linux.tar.gz",
SHA256: archiveSHA,
}},
Capabilities: &RegistryCapabilities{
IaCProvider: &RegistryIaCProvider{
Name: "testcloud",
ResourceTypes: []string{"testcloud.instance"},
},
},
}
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
t.Fatalf("marshal IaC manifest: %v", err)
}
path := filepath.Join(registryDir, "plugins", plugin, "manifest.json")
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
t.Fatalf("mkdir manifest dir: %v", err)
}
if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil {
t.Fatalf("write IaC manifest: %v", err)
}
}

func writeManifest(t *testing.T, registryDir, plugin, version, archiveSHA string) {
t.Helper()
writeManifestWithDownloads(t, registryDir, plugin, version, []PluginDownload{{
Expand Down
Loading
Loading