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
150 changes: 145 additions & 5 deletions cmd/wfctl/deploy_providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,7 @@ func newDeployProvider(provider string, wfCfg *config.WorkflowConfig) (DeployPro
// the provider and an io.Closer that shuts down any background subprocess.
// Tests override this var to inject fakes without touching the filesystem;
// they may return nil for the closer.
var resolveIaCProvider = func(ctx context.Context, providerName string, cfg map[string]any) (interfaces.IaCProvider, io.Closer, error) {
return discoverAndLoadIaCProvider(ctx, providerName, cfg)
}
var resolveIaCProvider = discoverAndLoadIaCProvider

// iacPluginManifest is the minimal shape needed to read capabilities.iacProvider.name
// from a plugin.json without relying on the full PluginCapabilities struct.
Expand Down Expand Up @@ -160,19 +158,161 @@ func discoverAndLoadIaCProvider(ctx context.Context, providerName string, cfg ma
return nil, nil, fmt.Errorf("plugin %q iac.provider factory returned nil", pluginName)
}

iacProvider, ok := mod.(interfaces.IaCProvider)
// RemoteModule does not directly implement interfaces.IaCProvider; instead it
// exposes InvokeService for cross-process method dispatch. Wrap it in a
// remoteIaCProvider that routes each IaCProvider call through InvokeService.
invoker, ok := mod.(remoteServiceInvoker)
if !ok {
mgr.Shutdown()
return nil, nil, fmt.Errorf("plugin %q iac.provider module (%T) does not implement interfaces.IaCProvider — upgrade with: wfctl plugin update %s", pluginName, mod, pluginName)
return nil, nil, fmt.Errorf("plugin %q iac.provider module (%T) does not support service invocation — upgrade with: wfctl plugin update %s", pluginName, mod, pluginName)
}

iacProvider := &remoteIaCProvider{invoker: invoker}
// Notify the plugin that Initialize has been called (the plugin may treat
// this as a no-op if it already ran Initialize inside CreateModule).
if initErr := iacProvider.Initialize(ctx, cfg); initErr != nil {
mgr.Shutdown()
return nil, nil, fmt.Errorf("initialize provider %q: %w", providerName, initErr)
}
return iacProvider, closer, nil
}

// remoteServiceInvoker is satisfied by *external.RemoteModule, which provides
// InvokeService for cross-process method dispatch.
type remoteServiceInvoker interface {
InvokeService(method string, args map[string]any) (map[string]any, error)
}

// remoteIaCProvider implements interfaces.IaCProvider by routing every method
// through InvokeService to the plugin subprocess. Only the methods needed by
// wfctl ci run deploy are fully implemented; the rest return a clear error.
type remoteIaCProvider struct {
invoker remoteServiceInvoker
}

func (r *remoteIaCProvider) Name() string {
res, err := r.invoker.InvokeService("IaCProvider.Name", nil)
if err != nil {
return ""
}
name, _ := res["name"].(string)
return name
}

func (r *remoteIaCProvider) Version() string {
res, err := r.invoker.InvokeService("IaCProvider.Version", nil)
if err != nil {
return ""
}
v, _ := res["version"].(string)
return v
}

func (r *remoteIaCProvider) Initialize(_ context.Context, cfg map[string]any) error {
_, err := r.invoker.InvokeService("IaCProvider.Initialize", cfg)
return err
}

func (r *remoteIaCProvider) Capabilities() []interfaces.IaCCapabilityDeclaration { return nil }

func (r *remoteIaCProvider) Plan(_ context.Context, _ []interfaces.ResourceSpec, _ []interfaces.ResourceState) (*interfaces.IaCPlan, error) {
return nil, fmt.Errorf("IaCProvider.Plan not supported via remote deploy — use wfctl infra apply")
}

func (r *remoteIaCProvider) Apply(_ context.Context, _ *interfaces.IaCPlan) (*interfaces.ApplyResult, error) {
return nil, fmt.Errorf("IaCProvider.Apply not supported via remote deploy — use wfctl infra apply")
}

func (r *remoteIaCProvider) Destroy(_ context.Context, _ []interfaces.ResourceRef) (*interfaces.DestroyResult, error) {
return nil, fmt.Errorf("IaCProvider.Destroy not supported via remote deploy — use wfctl infra apply")
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IaCProvider.Destroy returns an error that tells users to run wfctl infra apply, but there is a dedicated wfctl infra destroy command. This message is likely to misdirect users trying to tear down infra; update it to point at the destroy flow (or otherwise clarify the correct command).

Suggested change
return nil, fmt.Errorf("IaCProvider.Destroy not supported via remote deploy — use wfctl infra apply")
return nil, fmt.Errorf("IaCProvider.Destroy not supported via remote deploy — use wfctl infra destroy")

Copilot uses AI. Check for mistakes.
}

func (r *remoteIaCProvider) Status(_ context.Context, _ []interfaces.ResourceRef) ([]interfaces.ResourceStatus, error) {
return nil, fmt.Errorf("IaCProvider.Status not supported via remote deploy")
}

func (r *remoteIaCProvider) DetectDrift(_ context.Context, _ []interfaces.ResourceRef) ([]interfaces.DriftResult, error) {
return nil, fmt.Errorf("IaCProvider.DetectDrift not supported via remote deploy")
}

func (r *remoteIaCProvider) Import(_ context.Context, _ string, _ string) (*interfaces.ResourceState, error) {
return nil, fmt.Errorf("IaCProvider.Import not supported via remote deploy")
}

func (r *remoteIaCProvider) ResolveSizing(_ string, _ interfaces.Size, _ *interfaces.ResourceHints) (*interfaces.ProviderSizing, error) {
return nil, fmt.Errorf("IaCProvider.ResolveSizing not supported via remote deploy")
}

func (r *remoteIaCProvider) ResourceDriver(resourceType string) (interfaces.ResourceDriver, error) {
return &remoteResourceDriver{invoker: r.invoker, resourceType: resourceType}, nil
}

func (r *remoteIaCProvider) Close() error { return nil }

// remoteResourceDriver routes ResourceDriver calls to the plugin via InvokeService.
type remoteResourceDriver struct {
invoker remoteServiceInvoker
resourceType string
}

func (d *remoteResourceDriver) Update(_ context.Context, ref interfaces.ResourceRef, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) {
res, err := d.invoker.InvokeService("ResourceDriver.Update", map[string]any{
"resource_type": d.resourceType,
"ref_name": ref.Name,
"ref_type": ref.Type,
"ref_provider_id": ref.ProviderID,
"spec_name": spec.Name,
"spec_type": spec.Type,
"spec_config": spec.Config,
})
if err != nil {
return nil, err
}
return &interfaces.ResourceOutput{
ProviderID: stringFromMap(res, "provider_id"),
Name: stringFromMap(res, "name"),
Type: stringFromMap(res, "type"),
Status: stringFromMap(res, "status"),
}, nil
}

func (d *remoteResourceDriver) HealthCheck(_ context.Context, ref interfaces.ResourceRef) (*interfaces.HealthResult, error) {
res, err := d.invoker.InvokeService("ResourceDriver.HealthCheck", map[string]any{
"resource_type": d.resourceType,
"ref_name": ref.Name,
"ref_type": ref.Type,
"ref_provider_id": ref.ProviderID,
})
if err != nil {
return nil, err
}
healthy, _ := res["healthy"].(bool)
message, _ := res["message"].(string)
return &interfaces.HealthResult{Healthy: healthy, Message: message}, nil
}

func (d *remoteResourceDriver) Create(_ context.Context, _ interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) {
return nil, fmt.Errorf("ResourceDriver.Create not yet supported via remote deploy — use wfctl infra apply")
}
func (d *remoteResourceDriver) Read(_ context.Context, _ interfaces.ResourceRef) (*interfaces.ResourceOutput, error) {
return nil, fmt.Errorf("ResourceDriver.Read not yet supported via remote deploy — use wfctl infra apply")
}
func (d *remoteResourceDriver) Delete(_ context.Context, _ interfaces.ResourceRef) error {
return fmt.Errorf("ResourceDriver.Delete not yet supported via remote deploy — use wfctl infra apply")
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ResourceDriver.Delete errors with "use wfctl infra apply", but deletions/teardown are typically handled by wfctl infra destroy in this CLI. Consider adjusting the guidance (or making it explicit why apply is the right next step) so users aren’t sent to the wrong command.

Suggested change
return fmt.Errorf("ResourceDriver.Delete not yet supported via remote deploy — use wfctl infra apply")
return fmt.Errorf("ResourceDriver.Delete not yet supported via remote deploy — use wfctl infra destroy")

Copilot uses AI. Check for mistakes.
}
func (d *remoteResourceDriver) Diff(_ context.Context, _ interfaces.ResourceSpec, _ *interfaces.ResourceOutput) (*interfaces.DiffResult, error) {
return nil, fmt.Errorf("ResourceDriver.Diff not yet supported via remote deploy")
}
func (d *remoteResourceDriver) Scale(_ context.Context, _ interfaces.ResourceRef, _ int) (*interfaces.ResourceOutput, error) {
return nil, fmt.Errorf("ResourceDriver.Scale not yet supported via remote deploy")
}
func (d *remoteResourceDriver) SensitiveKeys() []string { return nil }

func stringFromMap(m map[string]any, key string) string {
v, _ := m[key].(string)
return v
}

// closerFunc adapts a func() error to io.Closer.
type closerFunc func() error

Expand Down
173 changes: 173 additions & 0 deletions cmd/wfctl/deploy_remote_provider_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package main

import (
"context"
"strings"
"testing"

"github.com/GoCodeAlone/workflow/interfaces"
)

// fakeRemoteInvoker implements remoteServiceInvoker using an in-memory dispatch
// table, so tests exercise remoteIaCProvider without a live plugin subprocess.
type fakeRemoteInvoker struct {
methods map[string]map[string]any // method → result
errors map[string]string // method → error string
}

func (f *fakeRemoteInvoker) InvokeService(method string, _ map[string]any) (map[string]any, error) {
if errStr, ok := f.errors[method]; ok {
return nil, errString(errStr)
}
if res, ok := f.methods[method]; ok {
return res, nil
}
return map[string]any{}, nil
}
Comment on lines +18 to +26
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fake invoker ignores the args passed to InvokeService, so the tests don’t actually verify the argument contract (resource_type, ref_name, spec_config, etc.) that the adapter is responsible for. Capture the incoming args per method (e.g., store lastArgs[method]) and assert the expected keys/values in the Update/HealthCheck/Initialize tests.

Copilot generated this review using guidance from organization custom instructions.

type errString string

func (e errString) Error() string { return string(e) }

func newFakeInvoker() *fakeRemoteInvoker {
return &fakeRemoteInvoker{
methods: map[string]map[string]any{
"IaCProvider.Name": {"name": "test-provider"},
"IaCProvider.Version": {"version": "1.0.0"},
"IaCProvider.Initialize": {},
"ResourceDriver.Update": {
"provider_id": "app-123",
"status": "running",
},
"ResourceDriver.HealthCheck": {
"healthy": true,
"message": "",
},
},
errors: map[string]string{},
}
}

// ── remoteIaCProvider ─────────────────────────────────────────────────────────

func TestRemoteIaCProvider_Name(t *testing.T) {
p := &remoteIaCProvider{invoker: newFakeInvoker()}
if got := p.Name(); got != "test-provider" {
t.Errorf("Name() = %q, want %q", got, "test-provider")
}
}

func TestRemoteIaCProvider_Initialize_RoutesViaInvoker(t *testing.T) {
inv := newFakeInvoker()
p := &remoteIaCProvider{invoker: inv}
if err := p.Initialize(context.Background(), map[string]any{"token": "x"}); err != nil {
t.Fatalf("Initialize: %v", err)
}
}

func TestRemoteIaCProvider_Initialize_PropagatesError(t *testing.T) {
inv := newFakeInvoker()
inv.errors["IaCProvider.Initialize"] = "invalid token"
p := &remoteIaCProvider{invoker: inv}
err := p.Initialize(context.Background(), nil)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "invalid token") {
t.Errorf("expected 'invalid token' in error, got: %v", err)
}
}

func TestRemoteIaCProvider_ResourceDriver_ReturnsRemoteDriver(t *testing.T) {
p := &remoteIaCProvider{invoker: newFakeInvoker()}
drv, err := p.ResourceDriver("infra.container_service")
if err != nil {
t.Fatalf("ResourceDriver: %v", err)
}
if _, ok := drv.(*remoteResourceDriver); !ok {
t.Fatalf("expected *remoteResourceDriver, got %T", drv)
}
}

// ── remoteResourceDriver ──────────────────────────────────────────────────────

func TestRemoteResourceDriver_Update_RoutesViaInvoker(t *testing.T) {
drv := &remoteResourceDriver{
invoker: newFakeInvoker(),
resourceType: "infra.container_service",
}
ref := interfaces.ResourceRef{Name: "bmw-app", Type: "infra.container_service"}
spec := interfaces.ResourceSpec{
Name: "bmw-app",
Type: "infra.container_service",
Config: map[string]any{"image": "registry.example.com/bmw:v2"},
}
out, err := drv.Update(context.Background(), ref, spec)
if err != nil {
t.Fatalf("Update: %v", err)
}
if out.ProviderID != "app-123" {
t.Errorf("ProviderID = %q, want %q", out.ProviderID, "app-123")
}
}

func TestRemoteResourceDriver_HealthCheck_Healthy(t *testing.T) {
drv := &remoteResourceDriver{
invoker: newFakeInvoker(),
resourceType: "infra.container_service",
}
ref := interfaces.ResourceRef{Name: "bmw-app", Type: "infra.container_service"}
result, err := drv.HealthCheck(context.Background(), ref)
if err != nil {
t.Fatalf("HealthCheck: %v", err)
}
if !result.Healthy {
t.Error("expected Healthy=true")
}
}

func TestRemoteResourceDriver_HealthCheck_Unhealthy(t *testing.T) {
inv := newFakeInvoker()
inv.methods["ResourceDriver.HealthCheck"] = map[string]any{
"healthy": false,
"message": "app is degraded",
}
drv := &remoteResourceDriver{
invoker: inv,
resourceType: "infra.container_service",
}
ref := interfaces.ResourceRef{Name: "bmw-app", Type: "infra.container_service"}
result, err := drv.HealthCheck(context.Background(), ref)
if err != nil {
t.Fatalf("HealthCheck: %v", err)
}
if result.Healthy {
t.Error("expected Healthy=false")
}
if result.Message != "app is degraded" {
t.Errorf("Message = %q, want %q", result.Message, "app is degraded")
}
}

func TestRemoteResourceDriver_Update_PropagatesError(t *testing.T) {
inv := newFakeInvoker()
inv.errors["ResourceDriver.Update"] = "deployment failed"
drv := &remoteResourceDriver{
invoker: inv,
resourceType: "infra.container_service",
}
_, err := drv.Update(context.Background(), interfaces.ResourceRef{}, interfaces.ResourceSpec{})
if err == nil || !strings.Contains(err.Error(), "deployment failed") {
t.Errorf("expected 'deployment failed' error, got: %v", err)
}
}

// TestDiscoverAndLoadIaCProvider_WrapsModuleAsRemoteIaCProvider verifies that
// when a plugin's iac.provider module does NOT directly implement IaCProvider
// (the normal case for gRPC plugins), discoverAndLoadIaCProvider wraps it in
// remoteIaCProvider instead of failing the type assertion.
func TestDiscoverAndLoadIaCProvider_WrapsModuleAsRemoteIaCProvider(t *testing.T) {
// This is covered end-to-end by the plugin tests; here we just confirm that
// remoteIaCProvider satisfies interfaces.IaCProvider at compile time.
var _ interfaces.IaCProvider = (*remoteIaCProvider)(nil)
}
Comment on lines +165 to +173
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TestDiscoverAndLoadIaCProvider_WrapsModuleAsRemoteIaCProvider claims to verify wrapping behavior, but it only contains a compile-time interface assertion, so it will pass regardless of discoverAndLoadIaCProvider logic. Either rename it to reflect what it actually checks (e.g., interface satisfaction) and/or move the compile-time assertion to package scope, or add a real behavioral test that exercises discoverAndLoadIaCProvider with a fake module implementing remoteServiceInvoker.

Suggested change
// TestDiscoverAndLoadIaCProvider_WrapsModuleAsRemoteIaCProvider verifies that
// when a plugin's iac.provider module does NOT directly implement IaCProvider
// (the normal case for gRPC plugins), discoverAndLoadIaCProvider wraps it in
// remoteIaCProvider instead of failing the type assertion.
func TestDiscoverAndLoadIaCProvider_WrapsModuleAsRemoteIaCProvider(t *testing.T) {
// This is covered end-to-end by the plugin tests; here we just confirm that
// remoteIaCProvider satisfies interfaces.IaCProvider at compile time.
var _ interfaces.IaCProvider = (*remoteIaCProvider)(nil)
}
// Ensure remoteIaCProvider continues to satisfy interfaces.IaCProvider.
// Wrapping behavior for discoverAndLoadIaCProvider is covered by higher-level
// plugin tests; this is only a compile-time interface assertion.
var _ interfaces.IaCProvider = (*remoteIaCProvider)(nil)

Copilot uses AI. Check for mistakes.
Loading