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
226 changes: 226 additions & 0 deletions cmd/wfctl/deploy_providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,184 @@ func (r *remoteIaCProvider) ResolveSizing(resourceType string, size interfaces.S
return &sizing, nil
}

// isPluginMethodUnimplemented returns true when err indicates the remote
// plugin's InvokeMethod / InvokeMethodContext dispatcher does not recognise
// the requested method name. This translates the gRPC codes.Unimplemented
// status (canonical signal) AND a small set of message-string fallbacks
// emitted by older plugins that did not adopt the gRPC status convention.
//
// Why string fallbacks: per workspace memory feedback_workflow_plugin_structpb_boundary,
// errors crossing the gRPC plugin boundary lose sentinel identity (structpb
// roundtrip), so message-string matching is the cross-process robust check.
//
// Matched message strings (verified against plugin/external/sdk/grpc_server.go
// at grpcServer.InvokeService):
//
// - "unimplemented" — substring match for gRPC codes.Unimplemented status
// messages whose .Error() lowercases to include the literal.
// - "not implemented" — generic catch for older plugin sdks.
// - "does not implement serviceinvoker" — emitted at line 527 when a module
// handle dispatches an untyped call but does not satisfy ServiceInvoker
// after the ServiceContextInvoker assertion also failed.
// - "does not implement servicecontextinvoker" — defensive forward-compat
// match. The current grpc_server.go does NOT emit this literal (its
// untyped path silently falls through from the optional
// ServiceContextInvoker check at line 509 to the ServiceInvoker check at
// line 524), so this arm is never hit in production today. Retained so
// a future grpc_server change that surfaces an explicit
// ServiceContextInvoker-missing error continues to translate cleanly.
//
// Not matched (intentional):
//
// - "does not implement typedserviceinvoker" (grpc_server line 498) —
// emitted only on the typed-input path. IaCProvider RPCs use the
// untyped args path, so this literal is unreachable for the proxy
// this helper serves. Keeping the matcher narrow avoids accidentally
// marking unrelated typed-call failures as Unimplemented.
// - "no service handler" — never produced by any current plugin sdk path.
//
// Strict-mode role (v0.27.1): this helper exists ONLY to translate transport
// errors into interfaces.ErrProviderMethodUnimplemented at the proxy
// boundary so callers can errors.Is on a stable sentinel. Per the v0.27.1
// user mandate ("remove the fallback and force strict mode"), every call
// site that previously swallowed the sentinel into (nil, nil) has been
// changed to propagate loudly. Do NOT introduce new swallowing call sites.
func isPluginMethodUnimplemented(err error) bool {
if err == nil {
return false
}
if status.Code(err) == codes.Unimplemented {
return true
}
msg := strings.ToLower(err.Error())
switch {
case strings.Contains(msg, "unimplemented"):
return true
case strings.Contains(msg, "not implemented"):
return true
case strings.Contains(msg, "does not implement serviceinvoker"):
return true
case strings.Contains(msg, "does not implement servicecontextinvoker"):
return true
}
return false
}

// EnumerateAll implements interfaces.EnumeratorAll for the gRPC-loaded IaC
// provider. Dispatches via InvokeServiceContext when the underlying invoker
// supports it; falls back to InvokeService for legacy invokers.
//
// The plugin-side dispatcher (e.g. DO v0.14.0's DOProvider.InvokeMethod) routes
Comment thread
intel352 marked this conversation as resolved.
// the "IaCProvider.EnumerateAll" method string to the provider's typed
// EnumerateAll(ctx, resourceType) implementation and returns []*ResourceOutput
// under the "outputs" key.
//
// Unimplemented translation
// ─────────────────────────
// Because every gRPC-loaded provider now satisfies interfaces.EnumeratorAll
// (compile-time bridge), dispatch sites (cmd/wfctl/infra_audit_keys.go,
// infra_prune.go, infra_rotate_and_prune.go) need a runtime way to detect
// "this remote plugin does not actually support EnumerateAll". This proxy
// translates gRPC codes.Unimplemented and equivalent message-matched errors
// from the plugin's InvokeMethod dispatcher into interfaces.ErrProviderMethodUnimplemented,
// which dispatch sites errors.Is on to preserve the pre-v0.27.1
// iterate-and-skip semantics.
//
// This bridge closes the v0.27.0 gap surfaced by `wfctl infra audit-keys`: the
// audit-keys command type-asserted the loaded provider against EnumeratorAll,
// but remoteIaCProvider did not implement EnumerateAll, so the assertion
// always failed even when the plugin process itself implemented it.
func (r *remoteIaCProvider) EnumerateAll(ctx context.Context, resourceType string) ([]*interfaces.ResourceOutput, error) {
args := map[string]any{
"resource_type": resourceType,
}
var (
res map[string]any
err error
)
if invoker, ok := r.invoker.(remoteServiceContextInvoker); ok {
res, err = invoker.InvokeServiceContext(ctx, "IaCProvider.EnumerateAll", args)
} else {
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
res, err = r.invoker.InvokeService("IaCProvider.EnumerateAll", args)
}
if err != nil {
if isPluginMethodUnimplemented(err) {
return nil, fmt.Errorf("IaCProvider.EnumerateAll: %w: %v",
interfaces.ErrProviderMethodUnimplemented, err)
}
return nil, fmt.Errorf("IaCProvider.EnumerateAll: %w", err)
}
if res == nil {
return nil, nil
}
raw, ok := res["outputs"]
if !ok {
return nil, nil
}
// Round-trip through any to avoid carrying structpb-only types into the
// typed slice. anyToStruct wraps json.Marshal → json.Unmarshal.
var outs []*interfaces.ResourceOutput
if err := anyToStruct(raw, &outs); err != nil {
return nil, fmt.Errorf("IaCProvider.EnumerateAll: decode result: %w", err)
}
return outs, nil
}

// EnumerateByTag implements interfaces.Enumerator for the gRPC-loaded IaC
// provider. Dispatches via InvokeServiceContext when available; falls back
// to InvokeService for legacy invokers.
//
// The plugin-side dispatcher routes "IaCProvider.EnumerateByTag" to the
// provider's typed EnumerateByTag(ctx, tag) implementation and returns
// []ResourceRef under the "refs" key. Returns Name + Type + ProviderID per
// the contract documented in interfaces/iac_provider.go.
//
// Bridged in v0.27.1 alongside EnumerateAll; both optional interfaces had
// the same root cause (no compile-time enforcement of proxy coverage for
// optional IaCProvider sub-interfaces). Same Unimplemented-translation
// applies so infra_cleanup.go's "skipped <provider>: does not implement
// Enumerator" log line is preserved for plugins that don't support tag
// queries (e.g. AppPlatform-only plugins).
func (r *remoteIaCProvider) EnumerateByTag(ctx context.Context, tag string) ([]interfaces.ResourceRef, error) {
args := map[string]any{
"tag": tag,
}
var (
res map[string]any
err error
)
if invoker, ok := r.invoker.(remoteServiceContextInvoker); ok {
res, err = invoker.InvokeServiceContext(ctx, "IaCProvider.EnumerateByTag", args)
} else {
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
res, err = r.invoker.InvokeService("IaCProvider.EnumerateByTag", args)
}
if err != nil {
if isPluginMethodUnimplemented(err) {
return nil, fmt.Errorf("IaCProvider.EnumerateByTag: %w: %v",
interfaces.ErrProviderMethodUnimplemented, err)
}
return nil, fmt.Errorf("IaCProvider.EnumerateByTag: %w", err)
}
if res == nil {
return nil, nil
}
raw, ok := res["refs"]
if !ok {
return nil, nil
}
var refs []interfaces.ResourceRef
if err := anyToStruct(raw, &refs); err != nil {
return nil, fmt.Errorf("IaCProvider.EnumerateByTag: decode result: %w", err)
}
return refs, nil
}
Comment thread
intel352 marked this conversation as resolved.

func (r *remoteIaCProvider) RepairDirtyMigration(ctx context.Context, req interfaces.MigrationRepairRequest) (*interfaces.MigrationRepairResult, error) {
reqAny, err := jsonToAny(req)
if err != nil {
Expand Down Expand Up @@ -617,6 +795,54 @@ func (r *remoteIaCProvider) SupportedCanonicalKeys() []string {
return interfaces.CanonicalKeys()
}

// ValidatePlan implements interfaces.ProviderValidator for the gRPC-loaded
// IaC provider. Dispatches "IaCProvider.ValidatePlan" via InvokeService and
// decodes the result["diagnostics"] entry as []PlanDiagnostic.
//
// Contract preservation
// ─────────────────────
// interfaces.ProviderValidator.ValidatePlan returns ONLY a []PlanDiagnostic
// (no error). To preserve the pre-v0.27.1 R-A10 semantics where plugins that
// don't implement ValidatePlan are silently skipped (the type-assert at
// cmd/wfctl/infra_align_rules.go:777 fails and the loop continues), this
// proxy returns nil (which []PlanDiagnostic permits) on transport errors,
// interfaces.ErrProviderMethodUnimplemented, and decode failures. Plugins
// that genuinely error during ValidatePlan (e.g. malformed plan input) will
// appear silent under R-A10 in this PR; emitting a warning at that boundary
// is left to a follow-up PR that extends the ProviderValidator contract to
// return an error.
//
// Bridged in v0.27.1 to close the strict-bridge-coverage gate; without
// this method *remoteIaCProvider would not satisfy interfaces.ProviderValidator
// and R-A10 would silently skip every gRPC-loaded provider.
func (r *remoteIaCProvider) ValidatePlan(plan *interfaces.IaCPlan) []interfaces.PlanDiagnostic {
planAny, err := jsonToAny(plan)
if err != nil {
return nil
}
args := map[string]any{"plan": planAny}
res, err := r.invoker.InvokeService("IaCProvider.ValidatePlan", args)
if err != nil {
// All errors (Unimplemented + transport + plugin) are silenced to
// preserve the pre-v0.27.1 type-assert-skip behavior. The contract
// has no error channel; surfacing failures requires an upstream
// interface change.
return nil
}
if res == nil {
return nil
Comment thread
intel352 marked this conversation as resolved.
}
raw, ok := res["diagnostics"]
if !ok {
return nil
}
var diags []interfaces.PlanDiagnostic
if err := anyToStruct(raw, &diags); err != nil {
return nil
}
return diags
}

// RevokeProviderCredential implements interfaces.ProviderCredentialRevoker.
// It dispatches through InvokeService to the plugin subprocess, which calls
// the provider's upstream DELETE endpoint (e.g. DO SpacesKeys.Delete).
Expand Down
28 changes: 28 additions & 0 deletions cmd/wfctl/deploy_providers_dispatch_matrix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,34 @@ func TestDispatchMatrix_RemoteIaCProvider(t *testing.T) {
return err
},
},
{
// v0.27.1 — bridges interfaces.EnumeratorAll. The dispatcher in
// runInfraAuditKeysCmd type-asserts the loaded provider against
// EnumeratorAll, so this row pins the wire shape.
name: "EnumerateAll",
wantMethod: "IaCProvider.EnumerateAll",
requiredKeys: []string{"resource_type"},
invoke: func(p *remoteIaCProvider, ri *recordingInvoker) error {
ri.resp = map[string]any{}
_, err := p.EnumerateAll(ctx, "infra.spaces_key")
return err
},
},
{
// v0.27.1 — bridges interfaces.Enumerator. wfctl infra cleanup
// type-asserts against this and skips providers that don't
// implement; the proxy now satisfies the assertion for all
// gRPC-loaded plugins, with the actual provider-side support
// surfaced as a normal RPC error.
name: "EnumerateByTag",
wantMethod: "IaCProvider.EnumerateByTag",
requiredKeys: []string{"tag"},
invoke: func(p *remoteIaCProvider, ri *recordingInvoker) error {
ri.resp = map[string]any{}
_, err := p.EnumerateByTag(ctx, "wfctl-managed")
return err
},
},
{
// BootstrapStateBackend sends cfg directly as the args map (no wrapper key).
// When cfg is nil/empty InvokeService must still be called (args may be nil).
Expand Down
Loading
Loading