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
89 changes: 89 additions & 0 deletions cmd/wfctl/iac_loader_gate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package main

import (
"errors"
"fmt"

pb "github.com/GoCodeAlone/workflow/plugin/external/proto"
)

// iacServiceRequired declared in iac_typed_adapter.go (PR #605, merged on
// main). This file uses the canonical const directly — the pre-flight gate
// looks for exactly that string in the plugin's GetContractRegistry response.

// errLegacyIaCPlugin is the typed sentinel returned when a pinned IaC
// plugin does not advertise IaCProviderRequired in its
// ContractRegistry. Wrapped errors that errors.Is on this sentinel
// surface the install-time mitigation step.
//
// The dispatch sites (wfctl deploy, wfctl infra plan/apply) catch this
// and exit with the actionable message documented in
// docs/runbooks/iac-typed-cutover.md.
var errLegacyIaCPlugin = errors.New("iac: plugin uses legacy InvokeService dispatch removed in workflow v1.0.0")

// AssertIaCPluginAdvertisesRequiredService inspects a *pb.ContractRegistry
// (the response from GetContractRegistry) and returns nil iff the plugin
// advertises a CONTRACT_KIND_SERVICE descriptor for
// workflow.plugin.external.iac.IaCProviderRequired.
//
// The error names the offending plugin (pluginName + pluginVersion) and
// points operators at docs/runbooks/iac-typed-cutover.md, plus wraps
// errLegacyIaCPlugin for errors.Is dispatch.
//
// Per Task 18 of the strict-contracts force-cutover plan: workflow
// v1.0.0 refuses to start a deploy if any pinned IaC plugin doesn't
// expose pb.IaCProviderServer registration. The check happens at the
// plugin-loader boundary (after GetContractRegistry succeeds) so the
// failure surfaces with a typed mitigation rather than as a generic
// "method not found" gRPC status at the first IaC RPC.
Comment on lines +35 to +38
//
// pluginName + pluginVersion are forwarded to the error message; pass
// the values from the plugin's manifest (Manifest.Name + Version).
// They are advisory — the function still rejects a registry that
// lacks the typed service even when called with empty strings.
func AssertIaCPluginAdvertisesRequiredService(pluginName, pluginVersion string, registry *pb.ContractRegistry) error {
if !registryAdvertisesIaCRequired(registry) {
name := pluginName
if name == "" {
name = "<unknown>"
}
version := pluginVersion
if version == "" {
version = "<unknown>"
}
return fmt.Errorf(
"plugin %q v%s uses legacy InvokeService dispatch removed in workflow v1.0.0. "+
"Migration: edit .wfctl-lock.yaml to pin v1.0.0+, then re-run "+
"`wfctl plugin install`. See docs/runbooks/iac-typed-cutover.md: %w",
name, version, errLegacyIaCPlugin,
)
}
return nil
}

// registryAdvertisesIaCRequired returns true iff registry contains a
// CONTRACT_KIND_SERVICE descriptor naming
// workflow.plugin.external.iac.IaCProviderRequired. Treats nil
// registry / nil contracts slice as "not advertised."
func registryAdvertisesIaCRequired(registry *pb.ContractRegistry) bool {
if registry == nil {
return false
}
for _, c := range registry.Contracts {
if c == nil {
continue
}
if c.Kind == pb.ContractKind_CONTRACT_KIND_SERVICE && c.ServiceName == iacServiceRequired {
return true
}
}
return false
}

// IsLegacyIaCPluginErr reports whether err signals a failed IaC
// plugin pre-flight gate. Dispatch sites can use this to attach
// runbook-specific exit codes / messages without inspecting the
// error message string.
func IsLegacyIaCPluginErr(err error) bool {
return errors.Is(err, errLegacyIaCPlugin)
}
145 changes: 145 additions & 0 deletions cmd/wfctl/iac_loader_gate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package main

import (
"errors"
"strings"
"testing"

pb "github.com/GoCodeAlone/workflow/plugin/external/proto"
)

// TestAssertIaCPluginAdvertisesRequiredService_TypedRegistryAccepts asserts
// that a ContractRegistry containing a SERVICE-kind descriptor for
// IaCProviderRequired passes the gate silently. Mirrors the
// post-cutover happy path: DO plugin v1.0.0 registers
// IaCProviderRequired via sdk.RegisterAllIaCProviderServices, then
// GetContractRegistry returns the typed-service descriptor that this
// gate looks for.
func TestAssertIaCPluginAdvertisesRequiredService_TypedRegistryAccepts(t *testing.T) {
registry := &pb.ContractRegistry{
Contracts: []*pb.ContractDescriptor{
{
Kind: pb.ContractKind_CONTRACT_KIND_SERVICE,
ServiceName: iacServiceRequired,
Mode: pb.ContractMode_CONTRACT_MODE_STRICT_PROTO,
},
},
}
if err := AssertIaCPluginAdvertisesRequiredService("workflow-plugin-digitalocean", "v1.0.0", registry); err != nil {
t.Fatalf("expected nil for typed registry; got %v", err)
}
}

// TestAssertIaCPluginAdvertisesRequiredService_LegacyRegistryRejects asserts
// the gate fires for a legacy plugin whose ContractRegistry advertises
// only Module/Step/Trigger contracts (no IaCProviderRequired service).
// The error MUST: name the plugin + version, include the migration
// instructions, point at the runbook, and wrap errLegacyIaCPlugin.
func TestAssertIaCPluginAdvertisesRequiredService_LegacyRegistryRejects(t *testing.T) {
registry := &pb.ContractRegistry{
Contracts: []*pb.ContractDescriptor{
{
Kind: pb.ContractKind_CONTRACT_KIND_MODULE,
ModuleType: "do.spaces_bucket",
Mode: pb.ContractMode_CONTRACT_MODE_STRICT_PROTO,
},
},
}
err := AssertIaCPluginAdvertisesRequiredService("workflow-plugin-digitalocean", "v0.14.2", registry)
if err == nil {
t.Fatalf("expected legacy-plugin error; got nil")
}
if !IsLegacyIaCPluginErr(err) {
t.Fatalf("error must wrap errLegacyIaCPlugin; got %v", err)
}
for _, want := range []string{
"workflow-plugin-digitalocean",
"v0.14.2",
".wfctl-lock.yaml",
"docs/runbooks/iac-typed-cutover.md",
} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error missing %q; got %q", want, err.Error())
}
}
}

// TestAssertIaCPluginAdvertisesRequiredService_NilRegistryRejects asserts
// the gate treats a nil registry as legacy (defensive — a plugin that
// fails to return a registry at all is also pre-cutover).
func TestAssertIaCPluginAdvertisesRequiredService_NilRegistryRejects(t *testing.T) {
err := AssertIaCPluginAdvertisesRequiredService("plugin-x", "v0.0.1", nil)
if err == nil {
t.Fatalf("expected error for nil registry")
}
if !IsLegacyIaCPluginErr(err) {
t.Errorf("nil-registry error must wrap errLegacyIaCPlugin")
}
}

// TestAssertIaCPluginAdvertisesRequiredService_EmptyContractsRejects
// asserts a registry with no contracts is treated as legacy. Catches
// the post-RPC path where GetContractRegistry returns successfully but
// empty (e.g., a plugin that built against typed proto but forgot to
// wire BuildContractRegistry into its ContractProvider hook).
func TestAssertIaCPluginAdvertisesRequiredService_EmptyContractsRejects(t *testing.T) {
registry := &pb.ContractRegistry{}
err := AssertIaCPluginAdvertisesRequiredService("plugin-y", "v1.0.0-rc0", registry)
if err == nil {
t.Fatalf("expected error for empty contracts slice")
}
if !IsLegacyIaCPluginErr(err) {
t.Errorf("empty-contracts error must wrap errLegacyIaCPlugin")
}
}

// TestAssertIaCPluginAdvertisesRequiredService_WrongKindRejects asserts
// that a descriptor naming IaCProviderRequired but with the wrong
// CONTRACT_KIND (e.g., MODULE instead of SERVICE) does NOT satisfy the
// gate. Guards against a plugin author copy-pasting a service name
// into the wrong descriptor kind.
func TestAssertIaCPluginAdvertisesRequiredService_WrongKindRejects(t *testing.T) {
registry := &pb.ContractRegistry{
Contracts: []*pb.ContractDescriptor{
{
Kind: pb.ContractKind_CONTRACT_KIND_MODULE,
ServiceName: iacServiceRequired, // wrong kind
Mode: pb.ContractMode_CONTRACT_MODE_STRICT_PROTO,
},
},
}
err := AssertIaCPluginAdvertisesRequiredService("plugin-z", "v0.5.0", registry)
if err == nil {
t.Fatalf("expected error: SERVICE-kind required (CONTRACT_KIND_MODULE seen)")
}
}

// TestAssertIaCPluginAdvertisesRequiredService_EmptyMetadataDefaults
// asserts the error formats unknown plugin metadata gracefully when
// the loader didn't populate name/version (defensive — the gate
// should still surface a reasonable message).
func TestAssertIaCPluginAdvertisesRequiredService_EmptyMetadataDefaults(t *testing.T) {
err := AssertIaCPluginAdvertisesRequiredService("", "", nil)
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "<unknown>") {
t.Errorf("expected <unknown> placeholder in error; got %q", err.Error())
}
}

// TestIsLegacyIaCPluginErr_NoFalsePositives asserts the sentinel does
// not match unrelated errors. Critical because dispatch sites use this
// to decide between "exit-with-runbook-message" and "exit with the
// generic plugin-load failure path."
func TestIsLegacyIaCPluginErr_NoFalsePositives(t *testing.T) {
if IsLegacyIaCPluginErr(nil) {
t.Errorf("nil should not match")
}
if IsLegacyIaCPluginErr(errors.New("some other error")) {
t.Errorf("unrelated error should not match")
}
if IsLegacyIaCPluginErr(errors.New("plugin uses legacy something")) {
t.Errorf("string-similar error should not match (we wrap a typed sentinel)")
}
}
Loading
Loading