From 80483f31b232360fe883f8bb51cc3d777b8f8030 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 21 Apr 2026 09:05:33 -0400 Subject: [PATCH] feat(plugin): full IaCProvider bulk-method dispatch in InvokeMethod Add dispatch cases for Plan, Apply, Destroy, Status, DetectDrift, Import, and ResolveSizing in doModuleInstance.InvokeMethod. Changes: - Change doModuleInstance.provider from *DOProvider to interfaces.IaCProvider so tests can inject a fake without a real godo client - Add invokeProvider{Plan,Apply,Destroy,Status,DetectDrift,Import,ResolveSizing} helpers; each uses JSON round-trip (decodeJSONField / structToMap) to cross the map[string]any boundary cleanly - Add refsFromArgs, decodeJSONField, decodeJSONValue, structToMap helpers Arg conventions (matching host side): - Plan: desired ([]spec maps), current ([]state maps) - Apply: plan (plan map) - Destroy/Status/DetectDrift: refs ([]ref maps) - Import: resource_type + provider_id - ResolveSizing: resource_type + size (string) + hints (optional map) Tests use a fakeIaCProvider that captures calls and returns canned results; all 8 new dispatch paths verified plus existing Update/HealthCheck pass. Co-Authored-By: Claude Sonnet 4.6 --- internal/module_instance.go | 182 +++++++++++++++++++++- internal/module_instance_test.go | 259 +++++++++++++++++++++++++++++++ 2 files changed, 439 insertions(+), 2 deletions(-) diff --git a/internal/module_instance.go b/internal/module_instance.go index 1f2ade7..3d97c0f 100644 --- a/internal/module_instance.go +++ b/internal/module_instance.go @@ -2,16 +2,17 @@ package internal import ( "context" + "encoding/json" "fmt" "github.com/GoCodeAlone/workflow/interfaces" ) -// doModuleInstance wraps a DOProvider as an sdk.ModuleInstance + sdk.ServiceInvoker. +// doModuleInstance wraps an IaCProvider as an sdk.ModuleInstance + sdk.ServiceInvoker. // The host calls InvokeMethod to route IaCProvider and ResourceDriver operations // across the gRPC plugin boundary. type doModuleInstance struct { - provider *DOProvider + provider interfaces.IaCProvider } // ── sdk.ModuleInstance ──────────────────────────────────────────────────────── @@ -48,6 +49,27 @@ func (m *doModuleInstance) InvokeMethod(method string, args map[string]any) (map } return map[string]any{"capabilities": out}, nil + case "IaCProvider.Plan": + return m.invokeProviderPlan(args) + + case "IaCProvider.Apply": + return m.invokeProviderApply(args) + + case "IaCProvider.Destroy": + return m.invokeProviderDestroy(args) + + case "IaCProvider.Status": + return m.invokeProviderStatus(args) + + case "IaCProvider.DetectDrift": + return m.invokeProviderDetectDrift(args) + + case "IaCProvider.Import": + return m.invokeProviderImport(args) + + case "IaCProvider.ResolveSizing": + return m.invokeProviderResolveSizing(args) + case "ResourceDriver.Update": return m.invokeDriverUpdate(args) @@ -77,6 +99,116 @@ func (m *doModuleInstance) InvokeMethod(method string, args map[string]any) (map } } +// ── IaCProvider bulk-method helpers ────────────────────────────────────────── + +// invokeProviderPlan decodes desired+current and calls IaCProvider.Plan. +func (m *doModuleInstance) invokeProviderPlan(args map[string]any) (map[string]any, error) { + var desired []interfaces.ResourceSpec + if err := decodeJSONField(args, "desired", &desired); err != nil { + return nil, fmt.Errorf("IaCProvider.Plan: %w", err) + } + var current []interfaces.ResourceState + if err := decodeJSONField(args, "current", ¤t); err != nil { + return nil, fmt.Errorf("IaCProvider.Plan: %w", err) + } + plan, err := m.provider.Plan(context.Background(), desired, current) + if err != nil { + return nil, err + } + return structToMap(plan) +} + +// invokeProviderApply decodes the plan and calls IaCProvider.Apply. +func (m *doModuleInstance) invokeProviderApply(args map[string]any) (map[string]any, error) { + var plan interfaces.IaCPlan + if err := decodeJSONField(args, "plan", &plan); err != nil { + return nil, fmt.Errorf("IaCProvider.Apply: %w", err) + } + result, err := m.provider.Apply(context.Background(), &plan) + if err != nil { + return nil, err + } + return structToMap(result) +} + +// invokeProviderDestroy decodes refs and calls IaCProvider.Destroy. +func (m *doModuleInstance) invokeProviderDestroy(args map[string]any) (map[string]any, error) { + refs, err := refsFromArgs(args) + if err != nil { + return nil, fmt.Errorf("IaCProvider.Destroy: %w", err) + } + result, err := m.provider.Destroy(context.Background(), refs) + if err != nil { + return nil, err + } + return structToMap(result) +} + +// invokeProviderStatus decodes refs and calls IaCProvider.Status. +func (m *doModuleInstance) invokeProviderStatus(args map[string]any) (map[string]any, error) { + refs, err := refsFromArgs(args) + if err != nil { + return nil, fmt.Errorf("IaCProvider.Status: %w", err) + } + statuses, err := m.provider.Status(context.Background(), refs) + if err != nil { + return nil, err + } + statusList := make([]any, len(statuses)) + for i, s := range statuses { + sm, _ := structToMap(s) + statusList[i] = sm + } + return map[string]any{"statuses": statusList}, nil +} + +// invokeProviderDetectDrift decodes refs and calls IaCProvider.DetectDrift. +func (m *doModuleInstance) invokeProviderDetectDrift(args map[string]any) (map[string]any, error) { + refs, err := refsFromArgs(args) + if err != nil { + return nil, fmt.Errorf("IaCProvider.DetectDrift: %w", err) + } + drifts, err := m.provider.DetectDrift(context.Background(), refs) + if err != nil { + return nil, err + } + driftList := make([]any, len(drifts)) + for i, d := range drifts { + dm, _ := structToMap(d) + driftList[i] = dm + } + return map[string]any{"drifts": driftList}, nil +} + +// invokeProviderImport decodes resource_type + provider_id and calls IaCProvider.Import. +func (m *doModuleInstance) invokeProviderImport(args map[string]any) (map[string]any, error) { + resourceType := stringArg(args, "resource_type") + providerID := stringArg(args, "provider_id") + state, err := m.provider.Import(context.Background(), providerID, resourceType) + if err != nil { + return nil, err + } + return structToMap(state) +} + +// invokeProviderResolveSizing decodes resource_type + size + hints and calls IaCProvider.ResolveSizing. +func (m *doModuleInstance) invokeProviderResolveSizing(args map[string]any) (map[string]any, error) { + resourceType := stringArg(args, "resource_type") + size := interfaces.Size(stringArg(args, "size")) + var hints *interfaces.ResourceHints + if h, ok := args["hints"]; ok && h != nil { + hints = &interfaces.ResourceHints{} + if err := decodeJSONValue(h, hints); err != nil { + return nil, fmt.Errorf("IaCProvider.ResolveSizing: %w", err) + } + } + sizing, err := m.provider.ResolveSizing(resourceType, size, hints) + if err != nil { + return nil, err + } + return structToMap(sizing) +} + // invokeDriverUpdate decodes args and calls ResourceDriver.Update. func (m *doModuleInstance) invokeDriverUpdate(args map[string]any) (map[string]any, error) { resourceType, _ := args["resource_type"].(string) @@ -347,6 +479,52 @@ func intArg(args map[string]any, key string) int { return 0 } +// refsFromArgs decodes the "refs" arg into a []ResourceRef via JSON round-trip. +func refsFromArgs(args map[string]any) ([]interfaces.ResourceRef, error) { + var refs []interfaces.ResourceRef + if err := decodeJSONField(args, "refs", &refs); err != nil { + return nil, err + } + return refs, nil +} + +// decodeJSONField marshals args[key] to JSON, then unmarshals into out. +func decodeJSONField(args map[string]any, key string, out any) error { + v, ok := args[key] + if !ok || v == nil { + return nil // leave out at its zero value + } + return decodeJSONValue(v, out) +} + +// decodeJSONValue marshals v to JSON, then unmarshals into out. +func decodeJSONValue(v any, out any) error { + b, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("marshal: %w", err) + } + if err := json.Unmarshal(b, out); err != nil { + return fmt.Errorf("unmarshal: %w", err) + } + return nil +} + +// structToMap serialises v to JSON and back to map[string]any for transport. +func structToMap(v any) (map[string]any, error) { + if v == nil { + return map[string]any{}, nil + } + b, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("structToMap marshal: %w", err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + return nil, fmt.Errorf("structToMap unmarshal: %w", err) + } + return m, nil +} + func stringArg(args map[string]any, key string) string { v, _ := args[key].(string) return v diff --git a/internal/module_instance_test.go b/internal/module_instance_test.go index 69c77e4..db24786 100644 --- a/internal/module_instance_test.go +++ b/internal/module_instance_test.go @@ -465,6 +465,203 @@ func TestDoModuleInstance_InvokeMethod_ResourceOutputSensitive(t *testing.T) { } } +// ── IaCProvider bulk-method dispatch tests ──────────────────────────────────── + +func TestDoModuleInstance_InvokeMethod_Plan_DispatchesToProvider(t *testing.T) { + fake := &fakeIaCProvider{planResult: &interfaces.IaCPlan{ + ID: "plan-abc", + Actions: []interfaces.PlanAction{ + {Action: "create", Resource: interfaces.ResourceSpec{Name: "my-db", Type: "infra.database"}}, + }, + }} + mi := &doModuleInstance{provider: fake} + + result, err := mi.InvokeMethod("IaCProvider.Plan", map[string]any{ + "desired": []any{ + map[string]any{"name": "my-db", "type": "infra.database", "config": map[string]any{}}, + }, + "current": []any{}, + }) + if err != nil { + t.Fatalf("Plan: %v", err) + } + if !fake.planCalled { + t.Error("Plan was not called on the provider") + } + if result["id"] != "plan-abc" { + t.Errorf("expected id=plan-abc, got %v", result["id"]) + } + actions, _ := result["actions"].([]any) + if len(actions) != 1 { + t.Errorf("expected 1 action, got %d", len(actions)) + } +} + +func TestDoModuleInstance_InvokeMethod_Apply_DispatchesToProvider(t *testing.T) { + fake := &fakeIaCProvider{applyResult: &interfaces.ApplyResult{ + PlanID: "plan-abc", + Resources: []interfaces.ResourceOutput{{ProviderID: "do-111", Status: "active"}}, + }} + mi := &doModuleInstance{provider: fake} + + result, err := mi.InvokeMethod("IaCProvider.Apply", map[string]any{ + "plan": map[string]any{"id": "plan-abc", "actions": []any{}}, + }) + if err != nil { + t.Fatalf("Apply: %v", err) + } + if !fake.applyCalled { + t.Error("Apply was not called on the provider") + } + if result["plan_id"] != "plan-abc" { + t.Errorf("expected plan_id=plan-abc, got %v", result["plan_id"]) + } + resources, _ := result["resources"].([]any) + if len(resources) != 1 { + t.Errorf("expected 1 resource, got %d", len(resources)) + } +} + +func TestDoModuleInstance_InvokeMethod_Destroy_DispatchesToProvider(t *testing.T) { + fake := &fakeIaCProvider{destroyResult: &interfaces.DestroyResult{ + Destroyed: []string{"my-db"}, + }} + mi := &doModuleInstance{provider: fake} + + result, err := mi.InvokeMethod("IaCProvider.Destroy", map[string]any{ + "refs": []any{ + map[string]any{"name": "my-db", "type": "infra.database", "provider_id": "do-222"}, + }, + }) + if err != nil { + t.Fatalf("Destroy: %v", err) + } + if !fake.destroyCalled { + t.Error("Destroy was not called on the provider") + } + destroyed, _ := result["destroyed"].([]any) + if len(destroyed) != 1 || destroyed[0] != "my-db" { + t.Errorf("expected destroyed=[my-db], got %v", result["destroyed"]) + } +} + +func TestDoModuleInstance_InvokeMethod_Status_DispatchesToProvider(t *testing.T) { + fake := &fakeIaCProvider{statusResult: []interfaces.ResourceStatus{ + {Name: "my-app", Type: "infra.container_service", ProviderID: "do-333", Status: "running"}, + }} + mi := &doModuleInstance{provider: fake} + + result, err := mi.InvokeMethod("IaCProvider.Status", map[string]any{ + "refs": []any{ + map[string]any{"name": "my-app", "type": "infra.container_service", "provider_id": "do-333"}, + }, + }) + if err != nil { + t.Fatalf("Status: %v", err) + } + if !fake.statusCalled { + t.Error("Status was not called on the provider") + } + statuses, _ := result["statuses"].([]any) + if len(statuses) != 1 { + t.Errorf("expected 1 status, got %d", len(statuses)) + } + s, _ := statuses[0].(map[string]any) + if s["status"] != "running" { + t.Errorf("expected status=running, got %v", s["status"]) + } +} + +func TestDoModuleInstance_InvokeMethod_DetectDrift_DispatchesToProvider(t *testing.T) { + fake := &fakeIaCProvider{driftResult: []interfaces.DriftResult{ + {Name: "my-vpc", Type: "infra.vpc", Drifted: true, Fields: []string{"cidr"}}, + }} + mi := &doModuleInstance{provider: fake} + + result, err := mi.InvokeMethod("IaCProvider.DetectDrift", map[string]any{ + "refs": []any{ + map[string]any{"name": "my-vpc", "type": "infra.vpc", "provider_id": "do-444"}, + }, + }) + if err != nil { + t.Fatalf("DetectDrift: %v", err) + } + if !fake.detectDriftCalled { + t.Error("DetectDrift was not called on the provider") + } + drifts, _ := result["drifts"].([]any) + if len(drifts) != 1 { + t.Errorf("expected 1 drift, got %d", len(drifts)) + } + d, _ := drifts[0].(map[string]any) + if d["drifted"] != true { + t.Errorf("expected drifted=true, got %v", d["drifted"]) + } +} + +func TestDoModuleInstance_InvokeMethod_Import_DispatchesToProvider(t *testing.T) { + fake := &fakeIaCProvider{importResult: &interfaces.ResourceState{ + ID: "do-555", Name: "imported-db", Type: "infra.database", Provider: "digitalocean", + }} + mi := &doModuleInstance{provider: fake} + + result, err := mi.InvokeMethod("IaCProvider.Import", map[string]any{ + "resource_type": "infra.database", + "provider_id": "do-555", + }) + if err != nil { + t.Fatalf("Import: %v", err) + } + if !fake.importCalled { + t.Error("Import was not called on the provider") + } + if result["id"] != "do-555" { + t.Errorf("expected id=do-555, got %v", result["id"]) + } + if result["name"] != "imported-db" { + t.Errorf("expected name=imported-db, got %v", result["name"]) + } +} + +func TestDoModuleInstance_InvokeMethod_ResolveSizing_DispatchesToProvider(t *testing.T) { + fake := &fakeIaCProvider{sizingResult: &interfaces.ProviderSizing{ + InstanceType: "db-s-1vcpu-1gb", + Specs: map[string]any{"cpu": "1", "memory": "1Gi"}, + }} + mi := &doModuleInstance{provider: fake} + + result, err := mi.InvokeMethod("IaCProvider.ResolveSizing", map[string]any{ + "resource_type": "infra.database", + "size": "s", + "hints": map[string]any{"cpu": "2"}, + }) + if err != nil { + t.Fatalf("ResolveSizing: %v", err) + } + if !fake.resolveSizingCalled { + t.Error("ResolveSizing was not called on the provider") + } + if result["instance_type"] != "db-s-1vcpu-1gb" { + t.Errorf("expected instance_type=db-s-1vcpu-1gb, got %v", result["instance_type"]) + } +} + +func TestDoModuleInstance_InvokeMethod_ResolveSizing_NoHints(t *testing.T) { + fake := &fakeIaCProvider{sizingResult: &interfaces.ProviderSizing{InstanceType: "db-xs"}} + mi := &doModuleInstance{provider: fake} + + _, err := mi.InvokeMethod("IaCProvider.ResolveSizing", map[string]any{ + "resource_type": "infra.database", + "size": "xs", + }) + if err != nil { + t.Fatalf("ResolveSizing without hints: %v", err) + } + if fake.resolveSizingHints != nil { + t.Errorf("expected nil hints, got %v", fake.resolveSizingHints) + } +} + // ── stub driver ─────────────────────────────────────────────────────────────── type stubResourceDriver struct { @@ -536,3 +733,65 @@ func (s *stubResourceDriver) SensitiveKeys() []string { s.sensitiveKeysCalled = true return s.sensitiveKeys } + +// ── fake IaCProvider ────────────────────────────────────────────────────────── + +type fakeIaCProvider struct { + // call tracking + planCalled bool + applyCalled bool + destroyCalled bool + statusCalled bool + detectDriftCalled bool + importCalled bool + resolveSizingCalled bool + resolveSizingHints *interfaces.ResourceHints + + // return values + planResult *interfaces.IaCPlan + applyResult *interfaces.ApplyResult + destroyResult *interfaces.DestroyResult + statusResult []interfaces.ResourceStatus + driftResult []interfaces.DriftResult + importResult *interfaces.ResourceState + sizingResult *interfaces.ProviderSizing +} + +func (f *fakeIaCProvider) Name() string { return "fake" } +func (f *fakeIaCProvider) Version() string { return "0.0.0" } +func (f *fakeIaCProvider) Initialize(_ context.Context, _ map[string]any) error { return nil } +func (f *fakeIaCProvider) Capabilities() []interfaces.IaCCapabilityDeclaration { return nil } +func (f *fakeIaCProvider) ResourceDriver(_ string) (interfaces.ResourceDriver, error) { + return &stubResourceDriver{}, nil +} +func (f *fakeIaCProvider) Close() error { return nil } + +func (f *fakeIaCProvider) Plan(_ context.Context, _ []interfaces.ResourceSpec, _ []interfaces.ResourceState) (*interfaces.IaCPlan, error) { + f.planCalled = true + return f.planResult, nil +} +func (f *fakeIaCProvider) Apply(_ context.Context, _ *interfaces.IaCPlan) (*interfaces.ApplyResult, error) { + f.applyCalled = true + return f.applyResult, nil +} +func (f *fakeIaCProvider) Destroy(_ context.Context, _ []interfaces.ResourceRef) (*interfaces.DestroyResult, error) { + f.destroyCalled = true + return f.destroyResult, nil +} +func (f *fakeIaCProvider) Status(_ context.Context, _ []interfaces.ResourceRef) ([]interfaces.ResourceStatus, error) { + f.statusCalled = true + return f.statusResult, nil +} +func (f *fakeIaCProvider) DetectDrift(_ context.Context, _ []interfaces.ResourceRef) ([]interfaces.DriftResult, error) { + f.detectDriftCalled = true + return f.driftResult, nil +} +func (f *fakeIaCProvider) Import(_ context.Context, _ string, _ string) (*interfaces.ResourceState, error) { + f.importCalled = true + return f.importResult, nil +} +func (f *fakeIaCProvider) ResolveSizing(_ string, _ interfaces.Size, hints *interfaces.ResourceHints) (*interfaces.ProviderSizing, error) { + f.resolveSizingCalled = true + f.resolveSizingHints = hints + return f.sizingResult, nil +}