fix(wfctl): load plugin state backends#709
Conversation
There was a problem hiding this comment.
Pull request overview
Enables wfctl direct-path infra commands (apply/destroy/status/drift/refresh) to resolve plugin-served iac.state backends (spaces, s3, gcs) instead of returning a hard-coded "use plugin" error. Loads the appropriate external plugin from the configured plugin directory, retrieves the gRPC IaCStateBackend client, and adapts it to the CLI's infraStateStore interface.
Changes:
- Replace the legacy hard-error returns for
spaces/s3/gcsinresolveStateStorewith a newresolvePluginStateStorehelper that loads candidate plugins and configures the backend. - Add a
pluginWfctlStateStoreadapter that bridgesmodule.IaCStateStore(ListStates/SaveState/DeleteState) to the wfctlinfraStateStoreinterface (ListResources/SaveResource/DeleteResource). - Export
module.NewGRPCIaCStateStoreso wfctl can construct a state store around anIaCStateBackendClientwithout building a full engine.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| module/iac_state_grpc_client.go | Adds an exported constructor wrapper around the internal newGRPCIaCStateStore for use by wfctl. |
| cmd/wfctl/infra_state_store.go | Routes spaces/s3/gcs backends through a new plugin loader; adds adapter struct and candidate-discovery helper. |
| cmd/wfctl/infra_state_store_test.go | Adds a test verifying the spaces backend goes through the plugin loader and no longer returns the legacy hard-coded error. |
Comments suppressed due to low confidence (1)
cmd/wfctl/infra_state_store.go:224
- For non-preferred plugin candidates this loop spawns each plugin as a subprocess just to ask whether it advertises the requested iac.state backend. In a typical install with many plugins (auth, http, broker, observability, etc.) this means starting and handshaking with every plugin in
data/pluginson everyresolveStateStorecall — andresolveStateStoreis invoked multiple times per command. Consider consulting each plugin'splugin.jsonmanifest (IaCStateBackends) to filter candidates before invoking LoadPlugin, so we only spawn the plugin(s) that actually declare the backend.
func stateBackendPluginCandidates(backend string, entries []os.DirEntry) []string {
seen := map[string]struct{}{}
var candidates []string
hasDir := func(name string) bool {
for _, entry := range entries {
if entry.IsDir() && entry.Name() == name {
return true
}
}
return false
}
add := func(name string) {
if strings.TrimSpace(name) == "" {
return
}
if _, ok := seen[name]; ok {
return
}
seen[name] = struct{}{}
candidates = append(candidates, name)
}
switch backend {
case "spaces":
if hasDir("digitalocean") {
add("digitalocean")
}
case "s3":
if hasDir("aws") {
add("aws")
}
case "gcs":
if hasDir("gcp") {
add("gcp")
}
}
for _, entry := range entries {
if entry.IsDir() {
add(entry.Name())
}
}
return candidates
}
| for _, pluginName := range stateBackendPluginCandidates(backend, entries) { | ||
| adapter, loadErr := mgr.LoadPlugin(pluginName) | ||
| if loadErr != nil { | ||
| mgr.Shutdown() | ||
| return nil, fmt.Errorf("load plugin %q for iac.state backend %q: %w", pluginName, backend, loadErr) | ||
| } | ||
| clients, clientsErr := adapter.IaCStateBackendClients() | ||
| if clientsErr != nil { | ||
| mgr.Shutdown() | ||
| return nil, fmt.Errorf("plugin %q iac.state backends: %w", pluginName, clientsErr) | ||
| } | ||
| client, ok := clients[backend] | ||
| if !ok { | ||
| continue | ||
| } | ||
| store := module.NewGRPCIaCStateStore(client) | ||
| if err := store.Configure(ctx, backend, cfg); err != nil { | ||
| mgr.Shutdown() | ||
| return nil, fmt.Errorf("configure plugin-served iac.state backend %q via plugin %q: %w", backend, pluginName, err) | ||
| } | ||
| return &pluginWfctlStateStore{inner: store, mgr: mgr}, nil | ||
| } |
| func (s *pluginWfctlStateStore) Close() error { | ||
| if s.mgr == nil { | ||
| return nil | ||
| } | ||
| s.mgr.Shutdown() | ||
| return nil | ||
| } |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
⏱ Benchmark Results✅ No significant performance regressions detected. benchstat comparison (baseline → PR)
|
| func (s *pluginWfctlStateStore) Close() error { | ||
| if s.mgr == nil { | ||
| return nil | ||
| } | ||
| s.mgr.Shutdown() | ||
| return nil | ||
| } |
| for _, pluginName := range stateBackendPluginCandidates(backend, entries) { | ||
| clients, clientsErr := loadPluginStateBackendClients(mgr, pluginName, backend) | ||
| if clientsErr != nil { | ||
| mgr.Shutdown() | ||
| return nil, clientsErr | ||
| } | ||
| client, ok := clients[backend] | ||
| if !ok { | ||
| continue | ||
| } | ||
| store := module.NewGRPCIaCStateStore(client) | ||
| if err := store.Configure(ctx, backend, cfg); err != nil { | ||
| mgr.Shutdown() | ||
| return nil, fmt.Errorf("configure plugin-served iac.state backend %q via plugin %q: %w", backend, pluginName, err) | ||
| } | ||
| return &pluginWfctlStateStore{inner: store, mgr: mgr}, nil | ||
| } |
| mgr := external.NewExternalPluginManager(pluginDir, nil) | ||
| for _, pluginName := range stateBackendPluginCandidates(backend, entries) { | ||
| clients, clientsErr := loadPluginStateBackendClients(mgr, pluginName, backend) | ||
| if clientsErr != nil { | ||
| mgr.Shutdown() | ||
| return nil, clientsErr | ||
| } | ||
| client, ok := clients[backend] | ||
| if !ok { | ||
| continue | ||
| } | ||
| store := module.NewGRPCIaCStateStore(client) | ||
| if err := store.Configure(ctx, backend, cfg); err != nil { | ||
| mgr.Shutdown() | ||
| return nil, fmt.Errorf("configure plugin-served iac.state backend %q via plugin %q: %w", backend, pluginName, err) | ||
| } | ||
| return &pluginWfctlStateStore{inner: store, mgr: mgr}, nil | ||
| } | ||
|
|
||
| mgr.Shutdown() | ||
| return nil, fmt.Errorf("iac.state backend %q is plugin-served but no installed plugin in %s advertises it", backend, pluginDir) | ||
| } |
| case "spaces": | ||
| return nil, fmt.Errorf("iac.state backend %q is now plugin-served by workflow-plugin-digitalocean v1.1.0; "+ | ||
| "install and load the plugin to use the Spaces backend (wfctl direct-path commands no longer support in-tree spaces)", backend) | ||
| return resolvePluginStateStore(context.Background(), backend, cfg) | ||
|
|
||
| case "s3": | ||
| return nil, fmt.Errorf("iac.state backend %q is now plugin-served by workflow-plugin-aws v1.1.0; "+ | ||
| "install and load the plugin to use the S3 backend (wfctl direct-path commands no longer support in-tree s3)", backend) | ||
| return resolvePluginStateStore(context.Background(), backend, cfg) | ||
|
|
||
| case "gcs": | ||
| return nil, fmt.Errorf("iac.state backend %q is now plugin-served by workflow-plugin-gcp v1.1.0; "+ | ||
| "install and load the plugin to use the GCS backend (wfctl direct-path commands no longer support in-tree gcs)", backend) | ||
| return resolvePluginStateStore(context.Background(), backend, cfg) |
| // NewGRPCIaCStateStore wraps an IaCStateBackendClient as an IaCStateStore. | ||
| // It is exported for wfctl direct-path commands, which load plugin-served | ||
| // state backends without constructing a full engine. | ||
| func NewGRPCIaCStateStore(c pb.IaCStateBackendClient) *grpcIaCStateStore { |
Summary
iac.statebackends (spaces,s3,gcs) through installed plugins for wfctl direct infra commandsIaCStateBackendclients to the direct-pathinfraStateStoreinterfaceVerification
Context
BMW deploy proved that Workflow v0.57.2 still hard-errors for
iac.state backend: spaceseven when workflow-plugin-digitalocean v1.1.0 is installed. This patch makes the direct-path infra commands load the plugin state backend instead of returning the legacy in-tree backend error.