Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
c1a1c4d
docs(plan): verify-capabilities contract-diff extension design (workf…
intel352 May 24, 2026
227e83e
docs(plan): #767 design cycle 2 (fix wrong namespace + cite existing …
intel352 May 24, 2026
b05713d
docs(plan): #767 design cycle 3 (fold cycle-2 IMPORTANT amendments)
intel352 May 24, 2026
1202184
docs(plan): #767 contract-diff implementation plan
intel352 May 24, 2026
cff8903
docs(plan): #767 design cycle 4 (replan against actual shipped #765 i…
intel352 May 24, 2026
6a94640
docs(plan): #767 design cycle 4 inline amendments (Option A per revie…
intel352 May 24, 2026
67c6b38
docs(plan): #767 implementation plan (post-#765-shipped, direct pbCli…
intel352 May 24, 2026
c00929a
docs(plan): #767 plan cycle 2 — fix 2 critical + 2 important + 3 mino…
intel352 May 24, 2026
98a4356
docs(plan): #767 plan cycle 3 — fix 2 critical from adversarial cycle 2
intel352 May 24, 2026
62877bf
docs(adr): 0042 — verify-capabilities IaC namespace derivation; cite …
intel352 May 24, 2026
9487378
chore: lock scope for #767 contract-diff (alignment passed)
intel352 May 24, 2026
7dfbaec
feat(plugin): add IaCServices manifest field with nested-promotion (w…
intel352 May 24, 2026
a14de6a
feat(sdk): BuildContractRegistryForPlugin namespace-filtering helper …
intel352 May 24, 2026
5d87fba
feat(sdk): IaC bridge GetContractRegistry filters infra services (wor…
intel352 May 24, 2026
6031a29
feat(wfctl): verify-capabilities contract-diff (directional FAIL/WARN…
intel352 May 24, 2026
ac48371
test(wfctl): 3 IaC integration fixture scenarios (workflow#767 Task 5)
intel352 May 24, 2026
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
88 changes: 88 additions & 0 deletions cmd/wfctl/plugin_verify_capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"

Expand All @@ -23,6 +24,8 @@ import (
external "github.com/GoCodeAlone/workflow/plugin/external"
pb "github.com/GoCodeAlone/workflow/plugin/external/proto"
hclog "github.com/hashicorp/go-hclog"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
)

Expand Down Expand Up @@ -141,6 +144,34 @@ Options:
if pass, reason := diffVersion(declared.Version, runtime.GetVersion()); !pass {
failures = append(failures, "version: "+reason)
}

// Contract-diff (workflow#767). One new RPC after GetManifest.
contractReg, regErr := pbClient.GetContractRegistry(ctx, &emptypb.Empty{})
switch {
case regErr != nil && status.Code(regErr) == codes.Unimplemented:
// Empty registry semantics — skip-if-LHS-empty handles non-IaC plugins;
// non-empty plugin.json.iacServices → directional diff FAILs every
// declared service (correct: plugin advertises nothing).
contractReg = nil
case regErr != nil:
return fmt.Errorf("GetContractRegistry RPC: %w (stderr: %s)", regErr, stderr.String())
}
// Defense-in-depth: client-side namespace filter per ADR 0042
// (decisions/0042-verify-capabilities-iac-namespace.md) and design §2.
// Old-SDK plugin binaries (pre-Task-3 bridge) return ALL gRPC services
// including PluginService + health — without this filter, every infra
// service would WARN-spam as "extra in plugin.json" for unrebased plugins.
iacPrefix := strings.TrimSuffix(pb.IaCProviderRequired_ServiceDesc.ServiceName, ".IaCProviderRequired") + "."
advertisedServices := serviceNamesFromRegistry(contractReg, iacPrefix)
missingSvc, extraSvc := diffIaCServices(declared.IaCServices, advertisedServices)
for _, s := range missingSvc {
failures = append(failures, fmt.Sprintf("iacServices: plugin.json declares %q but binary does not advertise it", s))
}
for _, s := range extraSvc {
// WARN, not FAIL — directional diff per design §3.
fmt.Fprintf(os.Stderr, "WARN %s: binary advertises %q not in plugin.json.iacServices (additive — consider updating plugin.json)\n", declared.Name, s)
}

if len(failures) > 0 {
fmt.Fprintf(os.Stderr, "FAIL %s (plugin.json)\nerror: %d mismatch(es)\n", declared.Name, len(failures))
for _, f := range failures {
Expand Down Expand Up @@ -178,6 +209,63 @@ func preflightBinary(path string) error {
return nil
}

// diffIaCServices computes directional set-difference of declared
// (plugin.json.iacServices) vs advertised (binary's filtered ContractRegistry).
// Returns (missing, extra) where:
// - missing: declared but not advertised → caller emits FAIL (truth-loop bug).
// - extra: advertised but not declared → caller emits WARN (additive doc-lag).
//
// Empty declared returns (nil, nil) → caller must skip the diff entirely.
func diffIaCServices(declared, advertised []string) (missing, extra []string) {
if len(declared) == 0 {
return nil, nil
}
declSet := make(map[string]bool, len(declared))
for _, s := range declared {
declSet[s] = true
}
advSet := make(map[string]bool, len(advertised))
for _, s := range advertised {
advSet[s] = true
}
for _, s := range declared {
if !advSet[s] {
missing = append(missing, s)
}
}
for _, s := range advertised {
if !declSet[s] {
extra = append(extra, s)
}
}
sort.Strings(missing)
sort.Strings(extra)
return missing, extra
}

// serviceNamesFromRegistry returns SERVICE-kind contract names from reg
// whose ServiceName starts with namespacePrefix. Defense-in-depth: the SDK
// bridge (Task 3) also filters, but old-SDK plugins skip that filter — this
// client-side check prevents WARN-spam for unrebased plugin binaries.
// Returns nil for nil reg. Sorted for stable diff output.
func serviceNamesFromRegistry(reg *pb.ContractRegistry, namespacePrefix string) []string {
if reg == nil {
return nil
}
names := make([]string, 0, len(reg.Contracts))
for _, c := range reg.Contracts {
if c.GetKind() != pb.ContractKind_CONTRACT_KIND_SERVICE {
continue
}
if !strings.HasPrefix(c.GetServiceName(), namespacePrefix) {
continue
}
names = append(names, c.GetServiceName())
}
sort.Strings(names)
return names
}

// isSentinel returns true when v is one of the SDK's dev-sentinel forms
// OR the on-disk plugin.json sentinel "0.0.0".
//
Expand Down
75 changes: 75 additions & 0 deletions cmd/wfctl/plugin_verify_capabilities_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,3 +215,78 @@ func TestVerifyCapabilities_NameDrift(t *testing.T) {
t.Errorf("want name-mismatch error, got: %v", err)
}
}

func TestDiffIaCServices_Match(t *testing.T) {
missing, extra := diffIaCServices(
[]string{"workflow.plugin.external.iac.IaCProviderRequired"},
[]string{"workflow.plugin.external.iac.IaCProviderRequired"})
if len(missing) != 0 || len(extra) != 0 {
t.Errorf("missing=%v extra=%v", missing, extra)
}
}

func TestDiffIaCServices_MissingFromBinary(t *testing.T) {
declared := []string{
"workflow.plugin.external.iac.IaCProviderRequired",
"workflow.plugin.external.iac.IaCProviderFinalizer",
}
advertised := []string{"workflow.plugin.external.iac.IaCProviderRequired"}
missing, extra := diffIaCServices(declared, advertised)
if len(missing) != 1 || missing[0] != "workflow.plugin.external.iac.IaCProviderFinalizer" {
t.Errorf("want Finalizer missing; got %v", missing)
}
if len(extra) != 0 {
t.Errorf("want no extras; got %v", extra)
}
}

func TestDiffIaCServices_ExtraInBinary(t *testing.T) {
missing, extra := diffIaCServices(
[]string{"workflow.plugin.external.iac.IaCProviderRequired"},
[]string{
"workflow.plugin.external.iac.IaCProviderRequired",
"workflow.plugin.external.iac.IaCProviderFinalizer",
})
if len(missing) != 0 {
t.Errorf("missing=%v", missing)
}
if len(extra) != 1 || extra[0] != "workflow.plugin.external.iac.IaCProviderFinalizer" {
t.Errorf("want Finalizer extra; got %v", extra)
}
}

func TestDiffIaCServices_EmptyDeclared_SkipsDiff(t *testing.T) {
missing, extra := diffIaCServices(nil, []string{"workflow.plugin.external.iac.IaCProviderRequired"})
if missing != nil || extra != nil {
t.Errorf("empty LHS should skip; got missing=%v extra=%v", missing, extra)
}
}

func TestVerifyCapabilities_IaCGood(t *testing.T) {
bin := buildFixtureBinaryForVerify(t, "iac-good", "v0.1.0")
if err := runPluginVerifyCapabilities([]string{"--binary", bin, "testdata/verify_capabilities/iac-good"}); err != nil {
t.Fatalf("want PASS, got: %v", err)
}
}

func TestVerifyCapabilities_IaCMissingService(t *testing.T) {
bin := buildFixtureBinaryForVerify(t, "iac-missing-service", "v0.1.0")
err := runPluginVerifyCapabilities([]string{"--binary", bin, "testdata/verify_capabilities/iac-missing-service"})
if err == nil {
t.Fatal("want FAIL on missing Finalizer, got nil")
}
if !strings.Contains(err.Error(), "iacServices:") {
t.Errorf("want iacServices: error, got: %v", err)
}
if !strings.Contains(err.Error(), "IaCProviderFinalizer") {
t.Errorf("want Finalizer-specific error, got: %v", err)
}
}

func TestVerifyCapabilities_IaCExtraService(t *testing.T) {
bin := buildFixtureBinaryForVerify(t, "iac-extra-service", "v0.1.0")
// Extra services produce WARN (stderr) but exit 0 per design §3.
if err := runPluginVerifyCapabilities([]string{"--binary", bin, "testdata/verify_capabilities/iac-extra-service"}); err != nil {
t.Fatalf("want PASS (extra=WARN, not FAIL); got: %v", err)
}
}
152 changes: 152 additions & 0 deletions cmd/wfctl/testdata/verify_capabilities/iac-extra-service/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
module github.com/test/iac-extra-service

go 1.26.0

require github.com/GoCodeAlone/workflow v0.63.2

require (
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/DataDog/datadog-go/v5 v5.8.3 // indirect
github.com/GoCodeAlone/go-plugin v1.7.0 // indirect
github.com/GoCodeAlone/modular v1.13.0 // indirect
github.com/GoCodeAlone/modular/modules/auth v1.15.0 // indirect
github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.8.0 // indirect
github.com/GoCodeAlone/yaegi v0.17.2 // indirect
github.com/IBM/sarama v1.47.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.6 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.16 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.15 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect
github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.4 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect
github.com/aws/smithy-go v1.25.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudevents/sdk-go/v2 v2.16.2 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker v28.5.2+incompatible // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/eapache/go-resiliency v1.7.0 // indirect
github.com/eapache/queue v1.1.0 // indirect
github.com/expr-lang/expr v1.17.8 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golobby/cast v1.3.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-hclog v1.6.3 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
github.com/hashicorp/vault/api v1.23.0 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
github.com/itchyny/gojq v0.12.18 // indirect
github.com/itchyny/timefmt-go v0.1.7 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.1 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
github.com/jcmturner/gofork v1.7.6 // indirect
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.52.0 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/oklog/run v1.2.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pierrec/lz4/v4 v4.1.26 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect
github.com/redis/go-redis/v9 v9.19.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/zalando/go-keyring v0.2.8 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.28.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect
google.golang.org/grpc v1.81.1 // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.47.0 // indirect
)

replace github.com/GoCodeAlone/workflow => ../../../../..
Loading
Loading