diff --git a/cmd/wfctl/deploy_providers.go b/cmd/wfctl/deploy_providers.go index 4408bffa..d6d50148 100644 --- a/cmd/wfctl/deploy_providers.go +++ b/cmd/wfctl/deploy_providers.go @@ -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. @@ -160,12 +158,18 @@ 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) @@ -173,6 +177,142 @@ func discoverAndLoadIaCProvider(ctx context.Context, providerName string, cfg ma 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") +} + +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") +} +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 diff --git a/cmd/wfctl/deploy_remote_provider_test.go b/cmd/wfctl/deploy_remote_provider_test.go new file mode 100644 index 00000000..19f40595 --- /dev/null +++ b/cmd/wfctl/deploy_remote_provider_test.go @@ -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 +} + +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) +}