diff --git a/internal/module_instance.go b/internal/module_instance.go new file mode 100644 index 0000000..2c125fa --- /dev/null +++ b/internal/module_instance.go @@ -0,0 +1,156 @@ +package internal + +import ( + "context" + "fmt" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// doModuleInstance wraps a DOProvider 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 +} + +// ── sdk.ModuleInstance ──────────────────────────────────────────────────────── + +func (m *doModuleInstance) Init() error { return nil } +func (m *doModuleInstance) Start(_ context.Context) error { return nil } +func (m *doModuleInstance) Stop(_ context.Context) error { return nil } + +// ── sdk.ServiceInvoker ──────────────────────────────────────────────────────── + +// InvokeMethod dispatches host calls to the underlying DOProvider and its +// resource drivers. Method names follow the convention "Interface.MethodName". +func (m *doModuleInstance) InvokeMethod(method string, args map[string]any) (map[string]any, error) { + switch method { + case "IaCProvider.Initialize": + // Already initialised in CreateModule; accept a re-init call as a no-op. + return map[string]any{}, nil + + case "IaCProvider.Name": + return map[string]any{"name": m.provider.Name()}, nil + + case "IaCProvider.Version": + return map[string]any{"version": m.provider.Version()}, nil + + case "IaCProvider.Capabilities": + caps := m.provider.Capabilities() + out := make([]any, len(caps)) + for i, c := range caps { + out[i] = map[string]any{ + "resource_type": c.ResourceType, + "tier": c.Tier, + "operations": c.Operations, + } + } + return map[string]any{"capabilities": out}, nil + + case "ResourceDriver.Update": + return m.invokeDriverUpdate(args) + + case "ResourceDriver.HealthCheck": + return m.invokeDriverHealthCheck(args) + + case "ResourceDriver.Create", "ResourceDriver.Read", "ResourceDriver.Delete", + "ResourceDriver.Scale", "ResourceDriver.Diff": + return nil, fmt.Errorf("digitalocean plugin: %s is not yet supported via remote invocation — use wfctl infra apply", method) + + default: + return nil, fmt.Errorf("digitalocean plugin: unknown method %q", method) + } +} + +// invokeDriverUpdate decodes args and calls ResourceDriver.Update. +func (m *doModuleInstance) invokeDriverUpdate(args map[string]any) (map[string]any, error) { + resourceType, _ := args["resource_type"].(string) + if resourceType == "" { + return nil, fmt.Errorf("ResourceDriver.Update: missing resource_type arg") + } + + driver, err := m.provider.ResourceDriver(resourceType) + if err != nil { + return nil, fmt.Errorf("ResourceDriver.Update: %w", err) + } + + ref := refFromArgs(args) + spec, err := specFromArgs(args) + if err != nil { + return nil, fmt.Errorf("ResourceDriver.Update: %w", err) + } + + out, err := driver.Update(context.Background(), ref, spec) + if err != nil { + return nil, err + } + return resourceOutputToMap(out), nil +} + +// invokeDriverHealthCheck decodes args and calls ResourceDriver.HealthCheck. +func (m *doModuleInstance) invokeDriverHealthCheck(args map[string]any) (map[string]any, error) { + resourceType, _ := args["resource_type"].(string) + if resourceType == "" { + return nil, fmt.Errorf("ResourceDriver.HealthCheck: missing resource_type arg") + } + + driver, err := m.provider.ResourceDriver(resourceType) + if err != nil { + return nil, fmt.Errorf("ResourceDriver.HealthCheck: %w", err) + } + + ref := refFromArgs(args) + result, err := driver.HealthCheck(context.Background(), ref) + if err != nil { + return nil, err + } + return map[string]any{ + "healthy": result.Healthy, + "message": result.Message, + }, nil +} + +// ── arg helpers ─────────────────────────────────────────────────────────────── + +func refFromArgs(args map[string]any) interfaces.ResourceRef { + return interfaces.ResourceRef{ + Name: stringArg(args, "ref_name"), + Type: stringArg(args, "ref_type"), + ProviderID: stringArg(args, "ref_provider_id"), + } +} + +func specFromArgs(args map[string]any) (interfaces.ResourceSpec, error) { + cfg, ok := args["spec_config"] + if !ok { + cfg = map[string]any{} + } + cfgMap, ok := cfg.(map[string]any) + if !ok { + return interfaces.ResourceSpec{}, fmt.Errorf("spec_config must be a map") + } + return interfaces.ResourceSpec{ + Name: stringArg(args, "spec_name"), + Type: stringArg(args, "spec_type"), + Config: cfgMap, + }, nil +} + +func resourceOutputToMap(out *interfaces.ResourceOutput) map[string]any { + if out == nil { + return map[string]any{} + } + return map[string]any{ + "provider_id": out.ProviderID, + "name": out.Name, + "type": out.Type, + "status": out.Status, + "outputs": out.Outputs, + } +} + +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 new file mode 100644 index 0000000..51481ed --- /dev/null +++ b/internal/module_instance_test.go @@ -0,0 +1,243 @@ +package internal + +import ( + "context" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" +) + +// compile-time interface checks +var ( + _ sdk.ModuleProvider = (*doPlugin)(nil) + _ sdk.ModuleInstance = (*doModuleInstance)(nil) + _ sdk.ServiceInvoker = (*doModuleInstance)(nil) +) + +// ── doPlugin.ModuleProvider ─────────────────────────────────────────────────── + +func TestDoPlugin_ModuleTypes(t *testing.T) { + p := &doPlugin{} + types := p.ModuleTypes() + if len(types) != 1 || types[0] != "iac.provider" { + t.Errorf("ModuleTypes() = %v, want [\"iac.provider\"]", types) + } +} + +func TestDoPlugin_CreateModule_UnknownType(t *testing.T) { + p := &doPlugin{} + _, err := p.CreateModule("something.else", "x", nil) + if err == nil { + t.Fatal("expected error for unknown module type") + } +} + +func TestDoPlugin_CreateModule_MissingToken(t *testing.T) { + p := &doPlugin{} + _, err := p.CreateModule("iac.provider", "test", map[string]any{}) + if err == nil { + t.Fatal("expected error when token is missing") + } +} + +func TestDoPlugin_CreateModule_ValidConfig(t *testing.T) { + // Use a stub token — Initialize creates a godo client but doesn't call the API. + p := &doPlugin{} + mod, err := p.CreateModule("iac.provider", "test", map[string]any{ + "token": "test-token", + }) + if err != nil { + t.Fatalf("CreateModule: %v", err) + } + if mod == nil { + t.Fatal("expected non-nil ModuleInstance") + } + if _, ok := mod.(sdk.ServiceInvoker); !ok { + t.Errorf("ModuleInstance does not implement ServiceInvoker") + } +} + +// ── doModuleInstance lifecycle ──────────────────────────────────────────────── + +func TestDoModuleInstance_Lifecycle(t *testing.T) { + mi := &doModuleInstance{provider: &DOProvider{}} + if err := mi.Init(); err != nil { + t.Errorf("Init: %v", err) + } + ctx := context.Background() + if err := mi.Start(ctx); err != nil { + t.Errorf("Start: %v", err) + } + if err := mi.Stop(ctx); err != nil { + t.Errorf("Stop: %v", err) + } +} + +// ── doModuleInstance.InvokeMethod ───────────────────────────────────────────── + +func TestDoModuleInstance_InvokeMethod_Initialize_NoOp(t *testing.T) { + mi := &doModuleInstance{provider: &DOProvider{}} + result, err := mi.InvokeMethod("IaCProvider.Initialize", map[string]any{"token": "x"}) + if err != nil { + t.Fatalf("IaCProvider.Initialize: %v", err) + } + if len(result) != 0 { + // empty map is expected + } +} + +func TestDoModuleInstance_InvokeMethod_Name(t *testing.T) { + mi := &doModuleInstance{provider: &DOProvider{}} + result, err := mi.InvokeMethod("IaCProvider.Name", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result["name"] != "digitalocean" { + t.Errorf("name = %q, want \"digitalocean\"", result["name"]) + } +} + +func TestDoModuleInstance_InvokeMethod_Version(t *testing.T) { + mi := &doModuleInstance{provider: &DOProvider{}} + result, err := mi.InvokeMethod("IaCProvider.Version", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result["version"] == "" { + t.Error("expected non-empty version") + } +} + +func TestDoModuleInstance_InvokeMethod_Capabilities(t *testing.T) { + mi := &doModuleInstance{provider: &DOProvider{}} + result, err := mi.InvokeMethod("IaCProvider.Capabilities", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + caps, ok := result["capabilities"].([]any) + if !ok || len(caps) == 0 { + t.Errorf("expected non-empty capabilities, got: %v", result) + } +} + +func TestDoModuleInstance_InvokeMethod_Unknown(t *testing.T) { + mi := &doModuleInstance{provider: &DOProvider{}} + _, err := mi.InvokeMethod("IaCProvider.Nonexistent", nil) + if err == nil { + t.Fatal("expected error for unknown method") + } +} + +func TestDoModuleInstance_InvokeMethod_Update_MissingResourceType(t *testing.T) { + mi := &doModuleInstance{provider: &DOProvider{}} + _, err := mi.InvokeMethod("ResourceDriver.Update", map[string]any{}) + if err == nil { + t.Fatal("expected error when resource_type is absent") + } +} + +func TestDoModuleInstance_InvokeMethod_Update_DispatchesToDriver(t *testing.T) { + stub := &stubResourceDriver{} + provider := &DOProvider{ + drivers: map[string]interfaces.ResourceDriver{ + "infra.container_service": stub, + }, + } + mi := &doModuleInstance{provider: provider} + + result, err := mi.InvokeMethod("ResourceDriver.Update", map[string]any{ + "resource_type": "infra.container_service", + "ref_name": "bmw-app", + "ref_type": "infra.container_service", + "spec_name": "bmw-app", + "spec_type": "infra.container_service", + "spec_config": map[string]any{"image": "registry.example.com/bmw:v2"}, + }) + if err != nil { + t.Fatalf("Update: %v", err) + } + if !stub.updateCalled { + t.Error("Update was not called on the driver") + } + if result == nil { + t.Error("expected non-nil result map") + } +} + +func TestDoModuleInstance_InvokeMethod_HealthCheck_DispatchesToDriver(t *testing.T) { + stub := &stubResourceDriver{healthyResult: true} + provider := &DOProvider{ + drivers: map[string]interfaces.ResourceDriver{ + "infra.container_service": stub, + }, + } + mi := &doModuleInstance{provider: provider} + + result, err := mi.InvokeMethod("ResourceDriver.HealthCheck", map[string]any{ + "resource_type": "infra.container_service", + "ref_name": "bmw-app", + "ref_type": "infra.container_service", + }) + if err != nil { + t.Fatalf("HealthCheck: %v", err) + } + if result["healthy"] != true { + t.Errorf("expected healthy=true, got: %v", result) + } +} + +func TestDoModuleInstance_InvokeMethod_HealthCheck_Unhealthy(t *testing.T) { + stub := &stubResourceDriver{healthyResult: false, healthMessage: "not ready"} + provider := &DOProvider{ + drivers: map[string]interfaces.ResourceDriver{ + "infra.container_service": stub, + }, + } + mi := &doModuleInstance{provider: provider} + + result, err := mi.InvokeMethod("ResourceDriver.HealthCheck", map[string]any{ + "resource_type": "infra.container_service", + "ref_name": "bmw-app", + "ref_type": "infra.container_service", + }) + if err != nil { + t.Fatalf("HealthCheck: %v", err) + } + if result["healthy"] != false { + t.Errorf("expected healthy=false, got: %v", result["healthy"]) + } + if result["message"] != "not ready" { + t.Errorf("expected message 'not ready', got: %v", result["message"]) + } +} + +// ── stub driver ─────────────────────────────────────────────────────────────── + +type stubResourceDriver struct { + updateCalled bool + healthyResult bool + healthMessage string +} + +func (s *stubResourceDriver) Create(_ context.Context, _ interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + return &interfaces.ResourceOutput{}, nil +} +func (s *stubResourceDriver) Read(_ context.Context, _ interfaces.ResourceRef) (*interfaces.ResourceOutput, error) { + return &interfaces.ResourceOutput{}, nil +} +func (s *stubResourceDriver) Update(_ context.Context, _ interfaces.ResourceRef, _ interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + s.updateCalled = true + return &interfaces.ResourceOutput{Status: "running"}, nil +} +func (s *stubResourceDriver) Delete(_ context.Context, _ interfaces.ResourceRef) error { return nil } +func (s *stubResourceDriver) Diff(_ context.Context, _ interfaces.ResourceSpec, _ *interfaces.ResourceOutput) (*interfaces.DiffResult, error) { + return &interfaces.DiffResult{}, nil +} +func (s *stubResourceDriver) HealthCheck(_ context.Context, _ interfaces.ResourceRef) (*interfaces.HealthResult, error) { + return &interfaces.HealthResult{Healthy: s.healthyResult, Message: s.healthMessage}, nil +} +func (s *stubResourceDriver) Scale(_ context.Context, _ interfaces.ResourceRef, _ int) (*interfaces.ResourceOutput, error) { + return &interfaces.ResourceOutput{}, nil +} +func (s *stubResourceDriver) SensitiveKeys() []string { return nil } diff --git a/internal/plugin.go b/internal/plugin.go index 86b63fd..77bca63 100644 --- a/internal/plugin.go +++ b/internal/plugin.go @@ -2,10 +2,13 @@ package internal import ( + "context" + "fmt" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" ) -// doPlugin implements sdk.PluginProvider. +// doPlugin implements sdk.PluginProvider and sdk.ModuleProvider. type doPlugin struct{} // NewDOPlugin returns a new DigitalOcean plugin instance. @@ -17,8 +20,26 @@ func NewDOPlugin() sdk.PluginProvider { func (p *doPlugin) Manifest() sdk.PluginManifest { return sdk.PluginManifest{ Name: "workflow-plugin-digitalocean", - Version: "0.1.0", + Version: "0.2.0", Author: "GoCodeAlone", Description: "DigitalOcean IaC provider: App Platform, DOKS, databases, load balancers, VPC, firewall, DNS, Spaces, DOCR, certificates, and Droplets", } } + +// ModuleTypes returns the module types this plugin exposes. +func (p *doPlugin) ModuleTypes() []string { + return []string{"iac.provider"} +} + +// CreateModule creates and initialises a module instance of the given type. +// For "iac.provider", a DOProvider is constructed and initialised with config. +func (p *doPlugin) CreateModule(typeName, _ string, config map[string]any) (sdk.ModuleInstance, error) { + if typeName != "iac.provider" { + return nil, fmt.Errorf("digitalocean plugin: unknown module type %q (supported: iac.provider)", typeName) + } + provider := NewDOProvider() + if err := provider.Initialize(context.Background(), config); err != nil { + return nil, fmt.Errorf("digitalocean: initialize provider: %w", err) + } + return &doModuleInstance{provider: provider}, nil +} diff --git a/internal/provider.go b/internal/provider.go index 1ff2fa6..dbbeb20 100644 --- a/internal/provider.go +++ b/internal/provider.go @@ -36,7 +36,7 @@ func NewDOProvider() *DOProvider { } func (p *DOProvider) Name() string { return "digitalocean" } -func (p *DOProvider) Version() string { return "0.1.0" } +func (p *DOProvider) Version() string { return "0.2.0" } // Initialize configures the godo client using the provided config map. // Required: "token". diff --git a/plugin.json b/plugin.json index 1a9aa79..6d0783f 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "workflow-plugin-digitalocean", - "version": "0.1.0", + "version": "0.2.0", "description": "DigitalOcean IaC provider: App Platform, DOKS, databases, Redis cache, load balancers, VPC, firewall, DNS, Spaces, DOCR, certificates, Droplets, IAM (declared), and API gateway", "author": "GoCodeAlone", "license": "MIT", @@ -12,7 +12,7 @@ "repository": "https://github.com/GoCodeAlone/workflow-plugin-digitalocean", "capabilities": { "configProvider": false, - "moduleTypes": [], + "moduleTypes": ["iac.provider"], "stepTypes": [], "triggerTypes": [], "iacProvider": {