diff --git a/cmd/wfctl/plugin_audit.go b/cmd/wfctl/plugin_audit.go index 6aea359c..ea28c00a 100644 --- a/cmd/wfctl/plugin_audit.go +++ b/cmd/wfctl/plugin_audit.go @@ -396,10 +396,60 @@ func advertisedPluginContracts(manifest map[string]any) pluginAdvertisedContract advertised.Modules = uniqueSortedStrings(advertised.Modules) advertised.Steps = uniqueSortedStrings(advertised.Steps) advertised.Triggers = uniqueSortedStrings(advertised.Triggers) - advertised.ServiceMethods = uniqueSortedStrings(advertised.ServiceMethods) + advertised.ServiceMethods = filterOutIaCServiceMethods(uniqueSortedStrings(advertised.ServiceMethods)) return advertised } +// filterOutIaCServiceMethods removes IaCProvider.* and ResourceDriver.* +// entries (plus their typed-proto package-qualified equivalents) from +// the advertised service-method list. Per Task 19 of the strict- +// contracts force-cutover plan: those interfaces are now compile-time +// enforced via Go interface satisfaction in +// sdk.RegisterAllIaCProviderServices; the manifest-side strict- +// contract advertisement is redundant for IaC, so the audit MUST NOT +// flag missing descriptors for them. +// +// The filter is intentionally narrow — Module / Step / Trigger / +// non-IaC service methods (SecurityScanner, ad-hoc plugin services) +// remain subject to the strict-contract coverage requirement so the +// 14-plugin Module/Step/Trigger migration tracker is unaffected. +func filterOutIaCServiceMethods(in []string) []string { + out := make([]string, 0, len(in)) + for _, m := range in { + if isIaCServiceMethod(m) { + continue + } + out = append(out, m) + } + return out +} + +// isIaCServiceMethod reports whether m names an IaCProvider or +// ResourceDriver method. Matches both the legacy InvokeService +// dispatch shape (e.g., "IaCProvider.EnumerateAll", +// "ResourceDriver.Create") and the typed-proto package-qualified +// shape emitted by iac.proto's go_package option (e.g., +// "workflow.plugin.external.iac.IaCProviderRequired/Plan", +// "workflow.plugin.external.iac.ResourceDriver/Create"). +// +// New optional services added to iac.proto match automatically as +// long as the typed package prefix is present. +func isIaCServiceMethod(m string) bool { + if m == "" { + return false + } + if strings.HasPrefix(m, "IaCProvider.") { + return true + } + if strings.HasPrefix(m, "ResourceDriver.") { + return true + } + if strings.HasPrefix(m, "workflow.plugin.external.iac.") { + return true + } + return false +} + func strictContractFindingLevel(opts pluginAuditOptions) string { if opts.StrictContracts { return "ERROR" diff --git a/cmd/wfctl/plugin_audit_iac_test.go b/cmd/wfctl/plugin_audit_iac_test.go new file mode 100644 index 00000000..1b2e08b3 --- /dev/null +++ b/cmd/wfctl/plugin_audit_iac_test.go @@ -0,0 +1,126 @@ +package main + +import ( + "strings" + "testing" +) + +// TestAuditPluginStrictContracts_IaCServiceMethodsAreNotRequired asserts +// that after the strict-contracts force-cutover, plugin manifests that +// list IaC service methods (e.g., "IaCProvider.EnumerateAll", +// "ResourceDriver.Create") are NOT flagged for missing strict-contract +// descriptors when audit runs with StrictContracts:true. Per Task 19 of +// the strict-contracts force-cutover plan: IaC interfaces are now +// compile-time enforced via Go interface satisfaction +// (sdk.RegisterAllIaCProviderServices) — the manifest-side strict- +// contract advertisement is redundant for those methods. +// +// The audit MUST continue to flag non-IaC service methods that lack a +// descriptor (e.g., "StrictService/Call") so the Module/Step/Trigger +// migration tracker is unaffected. +func TestAuditPluginStrictContracts_IaCServiceMethodsAreNotRequired(t *testing.T) { + dir := writePluginAuditRepo(t, "workflow-plugin-iac-only", `{ + "name": "workflow-plugin-iac-only", + "version": "1.0.0", + "capabilities": { + "serviceMethods": [ + "IaCProvider.Initialize", + "IaCProvider.Plan", + "IaCProvider.Apply", + "IaCProvider.EnumerateAll", + "ResourceDriver.Create", + "ResourceDriver.Read" + ] + } +}`) + + result := auditPluginRepoWithOptions(dir, pluginAuditOptions{StrictContracts: true}) + for _, finding := range result.Findings { + if finding.Code == "missing_service_method_contract_descriptor" { + t.Errorf("audit must NOT flag IaC service methods as needing "+ + "strict-contract descriptors (now compile-time enforced); "+ + "got finding: %+v", finding) + } + } + // ContractCoverage.ServiceMethods.Total must be 0 (every advertised + // method was IaC, so they all got filtered). + if result.ContractCoverage.ServiceMethods.Total != 0 { + t.Errorf("expected ServiceMethods.Total=0 after IaC filter; got %+v", + result.ContractCoverage.ServiceMethods) + } +} + +// TestAuditPluginStrictContracts_NonIaCServiceMethodsStillRequire asserts +// the IaC filter is narrow: a non-IaC service method (Module/Step/ +// Trigger / SecurityScanner / ad-hoc) still gets the missing-descriptor +// finding when no contract is advertised. Guards against the filter +// silently dropping every service method. +func TestAuditPluginStrictContracts_NonIaCServiceMethodsStillRequire(t *testing.T) { + dir := writePluginAuditRepo(t, "workflow-plugin-mixed", `{ + "name": "workflow-plugin-mixed", + "version": "1.0.0", + "capabilities": { + "serviceMethods": [ + "IaCProvider.Plan", + "SecurityScanner/Scan" + ] + } +}`) + + result := auditPluginRepoWithOptions(dir, pluginAuditOptions{StrictContracts: true}) + var nonIaCFinding bool + for _, finding := range result.Findings { + if finding.Code != "missing_service_method_contract_descriptor" { + continue + } + if !strings.Contains(finding.Message, "SecurityScanner/Scan") { + t.Errorf("non-IaC missing-descriptor finding must name the offending method; got %q", + finding.Message) + } + if strings.Contains(finding.Message, "IaCProvider.Plan") { + t.Errorf("audit must NOT flag IaCProvider.Plan; got %q", finding.Message) + } + nonIaCFinding = true + } + if !nonIaCFinding { + t.Errorf("expected missing_service_method_contract_descriptor for "+ + "SecurityScanner/Scan; findings=%v", result.Findings) + } + if result.ContractCoverage.ServiceMethods.Total != 1 { + t.Errorf("expected ServiceMethods.Total=1 (IaC filtered, SecurityScanner/Scan kept); got %+v", + result.ContractCoverage.ServiceMethods) + } +} + +// TestIsIaCServiceMethod_Cases asserts the classifier accepts every +// IaCProvider.* and ResourceDriver.* shape (with or without trailing +// method names; canonical pkg-prefixed and bare). New IaC service +// methods added in iac.proto should be covered by this matcher. +func TestIsIaCServiceMethod_Cases(t *testing.T) { + cases := map[string]bool{ + // IaCProvider methods (legacy InvokeService dispatch shape). + "IaCProvider.Initialize": true, + "IaCProvider.Plan": true, + "IaCProvider.Apply": true, + "IaCProvider.EnumerateAll": true, + "IaCProvider.RepairDirtyMigration": true, + // ResourceDriver methods (legacy InvokeService dispatch shape). + "ResourceDriver.Create": true, + "ResourceDriver.SensitiveKeys": true, + "ResourceDriver.Troubleshoot": true, + // Typed-proto package-qualified service names (post-cutover). + "workflow.plugin.external.iac.IaCProviderRequired/Plan": true, + "workflow.plugin.external.iac.IaCProviderEnumerator/EnumerateAll": true, + "workflow.plugin.external.iac.ResourceDriver/Create": true, + // Non-IaC service methods that must NOT match. + "SecurityScanner/Scan": false, + "StrictService/Call": false, + "PluginService/GetManifest": false, + "": false, + } + for in, want := range cases { + if got := isIaCServiceMethod(in); got != want { + t.Errorf("isIaCServiceMethod(%q) = %v; want %v", in, got, want) + } + } +}