From c467405064c2fb68bbc028aa952fd5a241169eec Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 9 Mar 2026 00:31:29 -0400 Subject: [PATCH 01/10] feat: add PlatformProvider adapter for DO App Platform module DOAppPlatformAdapter wraps PlatformDOApp to implement the PlatformProvider interface (Plan/Apply/Status/Destroy), registered as ".iac" service. Co-Authored-By: Claude Opus 4.6 --- module/platform_do_app.go | 55 +++++++++++++++++- module/platform_do_app_test.go | 102 +++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) diff --git a/module/platform_do_app.go b/module/platform_do_app.go index ec630cc2..a93eacad 100644 --- a/module/platform_do_app.go +++ b/module/platform_do_app.go @@ -123,13 +123,17 @@ func (m *PlatformDOApp) Init(app modular.Application) error { return fmt.Errorf("platform.do_app %q: unsupported provider %q", m.name, providerType) } - return app.RegisterService(m.name, m) + if err := app.RegisterService(m.name, m); err != nil { + return err + } + return app.RegisterService(m.name+".iac", &DOAppPlatformAdapter{m}) } // ProvidesServices declares the service this module provides. func (m *PlatformDOApp) ProvidesServices() []modular.ServiceProvider { return []modular.ServiceProvider{ {Name: m.name, Description: "DO App: " + m.name, Instance: m}, + {Name: m.name + ".iac", Description: "DO App IaC adapter: " + m.name, Instance: &DOAppPlatformAdapter{m}}, } } @@ -207,6 +211,55 @@ func (m *PlatformDOApp) buildAppSpec() *godo.AppSpec { } } +// ─── PlatformProvider adapter ────────────────────────────────────────────────── + +// DOAppPlatformAdapter wraps PlatformDOApp to implement PlatformProvider. +type DOAppPlatformAdapter struct { + *PlatformDOApp +} + +// Plan implements PlatformProvider. Returns a plan based on current state. +func (a *DOAppPlatformAdapter) Plan() (*PlatformPlan, error) { + actionType := "create" + detail := fmt.Sprintf("Deploy app %s to region %s (image: %s, instances: %d)", + a.state.Name, a.state.Region, a.state.Image, a.state.Instances) + if a.state.ID != "" { + actionType = "update" + detail = fmt.Sprintf("Update app %s (image: %s, instances: %d)", + a.state.Name, a.state.Image, a.state.Instances) + } + return &PlatformPlan{ + Provider: "digitalocean", + Resource: "app_platform", + Actions: []PlatformAction{ + {Type: actionType, Resource: a.state.Name, Detail: detail}, + }, + }, nil +} + +// Apply implements PlatformProvider. Deploys via the backend. +func (a *DOAppPlatformAdapter) Apply() (*PlatformResult, error) { + st, err := a.PlatformDOApp.Deploy() + if err != nil { + return &PlatformResult{Success: false, Message: err.Error()}, err + } + return &PlatformResult{ + Success: true, + Message: fmt.Sprintf("App %s deployed (id: %s, url: %s)", st.Name, st.ID, st.LiveURL), + State: st, + }, nil +} + +// Status implements PlatformProvider. +func (a *DOAppPlatformAdapter) Status() (any, error) { + return a.PlatformDOApp.Status() +} + +// Destroy implements PlatformProvider. +func (a *DOAppPlatformAdapter) Destroy() error { + return a.PlatformDOApp.Destroy() +} + // ─── mock backend ────────────────────────────────────────────────────────────── type doAppMockBackend struct{} diff --git a/module/platform_do_app_test.go b/module/platform_do_app_test.go index e54df2a5..b4d9f6dd 100644 --- a/module/platform_do_app_test.go +++ b/module/platform_do_app_test.go @@ -151,6 +151,108 @@ func TestDO_App_DestroyIdempotent(t *testing.T) { } } +// ─── PlatformProvider adapter ───────────────────────────────────────────────── + +func TestDO_App_AdapterImplementsPlatformProvider(t *testing.T) { + app, _ := newDOAppApp(t) + svc, ok := app.Services["my-app.iac"] + if !ok { + t.Fatal("expected my-app.iac in service registry") + } + if _, ok := svc.(module.PlatformProvider); !ok { + t.Fatalf("my-app.iac service (%T) does not implement PlatformProvider", svc) + } +} + +func TestDO_App_AdapterPlan(t *testing.T) { + app, _ := newDOAppApp(t) + prov := app.Services["my-app.iac"].(module.PlatformProvider) + plan, err := prov.Plan() + if err != nil { + t.Fatalf("Plan() error: %v", err) + } + if plan.Provider != "digitalocean" { + t.Errorf("expected provider digitalocean, got %s", plan.Provider) + } + if plan.Resource != "app_platform" { + t.Errorf("expected resource app_platform, got %s", plan.Resource) + } + if len(plan.Actions) == 0 { + t.Fatal("expected at least one action") + } + if plan.Actions[0].Type != "create" { + t.Errorf("expected action type create, got %s", plan.Actions[0].Type) + } +} + +func TestDO_App_AdapterPlanUpdate(t *testing.T) { + app, _ := newDOAppApp(t) + m := app.Services["my-app"].(*module.PlatformDOApp) + // Deploy first so the app has an ID + if _, err := m.Deploy(); err != nil { + t.Fatalf("Deploy: %v", err) + } + prov := app.Services["my-app.iac"].(module.PlatformProvider) + plan, err := prov.Plan() + if err != nil { + t.Fatalf("Plan() error: %v", err) + } + if plan.Actions[0].Type != "update" { + t.Errorf("expected action type update after deploy, got %s", plan.Actions[0].Type) + } +} + +func TestDO_App_AdapterApply(t *testing.T) { + app, _ := newDOAppApp(t) + prov := app.Services["my-app.iac"].(module.PlatformProvider) + result, err := prov.Apply() + if err != nil { + t.Fatalf("Apply() error: %v", err) + } + if !result.Success { + t.Errorf("expected success, got message: %s", result.Message) + } + if result.State == nil { + t.Error("expected non-nil state") + } +} + +func TestDO_App_AdapterStatus(t *testing.T) { + app, _ := newDOAppApp(t) + prov := app.Services["my-app.iac"].(module.PlatformProvider) + st, err := prov.Status() + if err != nil { + t.Fatalf("Status() error: %v", err) + } + if st == nil { + t.Error("expected non-nil status") + } +} + +func TestDO_App_AdapterDestroy(t *testing.T) { + app, _ := newDOAppApp(t) + prov := app.Services["my-app.iac"].(module.PlatformProvider) + // Deploy first + if _, err := prov.Apply(); err != nil { + t.Fatalf("Apply: %v", err) + } + if err := prov.Destroy(); err != nil { + t.Fatalf("Destroy() error: %v", err) + } + // Verify status shows deleted + st, err := prov.Status() + if err != nil { + t.Fatalf("Status after destroy: %v", err) + } + appState, ok := st.(*module.DOAppState) + if !ok { + t.Fatalf("expected *DOAppState, got %T", st) + } + if appState.Status != "deleted" { + t.Errorf("expected status deleted, got %s", appState.Status) + } +} + func TestDO_App_UnsupportedProvider(t *testing.T) { app := module.NewMockApplication() m := module.NewPlatformDOApp("bad-app", map[string]any{ From 3d0eb6a2cf119af5f0abe3ed678701bb42e48126 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 9 Mar 2026 00:32:53 -0400 Subject: [PATCH 02/10] feat: add PlatformProvider adapter for DO Networking module DONetworkingPlatformAdapter wraps PlatformDONetworking to implement PlatformProvider (Plan/Apply/Status/Destroy), registered as ".iac" service. Converts DONetworkPlan changes to PlatformAction entries. Co-Authored-By: Claude Opus 4.6 --- module/platform_do_networking.go | 61 ++++++++++++++++- module/platform_do_networking_test.go | 98 +++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 1 deletion(-) diff --git a/module/platform_do_networking.go b/module/platform_do_networking.go index 47ba86d5..79bc7632 100644 --- a/module/platform_do_networking.go +++ b/module/platform_do_networking.go @@ -120,13 +120,17 @@ func (m *PlatformDONetworking) Init(app modular.Application) error { return fmt.Errorf("platform.do_networking %q: unsupported provider %q", m.name, providerType) } - return app.RegisterService(m.name, m) + if err := app.RegisterService(m.name, m); err != nil { + return err + } + return app.RegisterService(m.name+".iac", &DONetworkingPlatformAdapter{m}) } // ProvidesServices declares the service this module provides. func (m *PlatformDONetworking) ProvidesServices() []modular.ServiceProvider { return []modular.ServiceProvider{ {Name: m.name, Description: "DO networking: " + m.name, Instance: m}, + {Name: m.name + ".iac", Description: "DO networking IaC adapter: " + m.name, Instance: &DONetworkingPlatformAdapter{m}}, } } @@ -186,6 +190,61 @@ func (m *PlatformDONetworking) firewallConfigs() []DOFirewallConfig { return fws } +// ─── PlatformProvider adapter ────────────────────────────────────────────────── + +// DONetworkingPlatformAdapter wraps PlatformDONetworking to implement PlatformProvider. +type DONetworkingPlatformAdapter struct { + *PlatformDONetworking +} + +// Plan implements PlatformProvider. +func (a *DONetworkingPlatformAdapter) Plan() (*PlatformPlan, error) { + p, err := a.PlatformDONetworking.Plan() + if err != nil { + return nil, err + } + var actions []PlatformAction + for _, change := range p.Changes { + actionType := "create" + if change == "no changes" { + actionType = "noop" + } + actions = append(actions, PlatformAction{ + Type: actionType, + Resource: p.VPC, + Detail: change, + }) + } + return &PlatformPlan{ + Provider: "digitalocean", + Resource: "networking", + Actions: actions, + }, nil +} + +// Apply implements PlatformProvider. +func (a *DONetworkingPlatformAdapter) Apply() (*PlatformResult, error) { + st, err := a.PlatformDONetworking.Apply() + if err != nil { + return &PlatformResult{Success: false, Message: err.Error()}, err + } + return &PlatformResult{ + Success: true, + Message: fmt.Sprintf("VPC %s created in %s", st.Name, st.Region), + State: st, + }, nil +} + +// Status implements PlatformProvider. +func (a *DONetworkingPlatformAdapter) Status() (any, error) { + return a.PlatformDONetworking.Status() +} + +// Destroy implements PlatformProvider. +func (a *DONetworkingPlatformAdapter) Destroy() error { + return a.PlatformDONetworking.Destroy() +} + // ─── mock backend ────────────────────────────────────────────────────────────── type doNetworkingMockBackend struct{} diff --git a/module/platform_do_networking_test.go b/module/platform_do_networking_test.go index 602710b3..08667a08 100644 --- a/module/platform_do_networking_test.go +++ b/module/platform_do_networking_test.go @@ -142,6 +142,104 @@ func TestDO_Networking_DestroyIdempotent(t *testing.T) { } } +// ─── PlatformProvider adapter ───────────────────────────────────────────────── + +func TestDO_Networking_AdapterImplementsPlatformProvider(t *testing.T) { + app, _ := newDONetworkingApp(t) + svc, ok := app.Services["staging-vpc.iac"] + if !ok { + t.Fatal("expected staging-vpc.iac in service registry") + } + if _, ok := svc.(module.PlatformProvider); !ok { + t.Fatalf("staging-vpc.iac service (%T) does not implement PlatformProvider", svc) + } +} + +func TestDO_Networking_AdapterPlan(t *testing.T) { + app, _ := newDONetworkingApp(t) + prov := app.Services["staging-vpc.iac"].(module.PlatformProvider) + plan, err := prov.Plan() + if err != nil { + t.Fatalf("Plan() error: %v", err) + } + if plan.Provider != "digitalocean" { + t.Errorf("expected provider digitalocean, got %s", plan.Provider) + } + if plan.Resource != "networking" { + t.Errorf("expected resource networking, got %s", plan.Resource) + } + if len(plan.Actions) == 0 { + t.Fatal("expected at least one action") + } +} + +func TestDO_Networking_AdapterPlanNoop(t *testing.T) { + app, m := newDONetworkingApp(t) + if _, err := m.Apply(); err != nil { + t.Fatalf("Apply: %v", err) + } + prov := app.Services["staging-vpc.iac"].(module.PlatformProvider) + plan, err := prov.Plan() + if err != nil { + t.Fatalf("Plan() error: %v", err) + } + if len(plan.Actions) != 1 { + t.Fatalf("expected 1 noop action, got %d", len(plan.Actions)) + } + if plan.Actions[0].Type != "noop" { + t.Errorf("expected noop action after apply, got %s", plan.Actions[0].Type) + } +} + +func TestDO_Networking_AdapterApply(t *testing.T) { + app, _ := newDONetworkingApp(t) + prov := app.Services["staging-vpc.iac"].(module.PlatformProvider) + result, err := prov.Apply() + if err != nil { + t.Fatalf("Apply() error: %v", err) + } + if !result.Success { + t.Errorf("expected success, got message: %s", result.Message) + } + if result.State == nil { + t.Error("expected non-nil state") + } +} + +func TestDO_Networking_AdapterStatus(t *testing.T) { + app, _ := newDONetworkingApp(t) + prov := app.Services["staging-vpc.iac"].(module.PlatformProvider) + st, err := prov.Status() + if err != nil { + t.Fatalf("Status() error: %v", err) + } + if st == nil { + t.Error("expected non-nil status") + } +} + +func TestDO_Networking_AdapterDestroy(t *testing.T) { + app, _ := newDONetworkingApp(t) + prov := app.Services["staging-vpc.iac"].(module.PlatformProvider) + if _, err := prov.Apply(); err != nil { + t.Fatalf("Apply: %v", err) + } + if err := prov.Destroy(); err != nil { + t.Fatalf("Destroy() error: %v", err) + } + st, err := prov.Status() + if err != nil { + t.Fatalf("Status after destroy: %v", err) + } + vpcState, ok := st.(*module.DOVPCState) + if !ok { + t.Fatalf("expected *DOVPCState, got %T", st) + } + if vpcState.Status != "deleted" { + t.Errorf("expected status deleted, got %s", vpcState.Status) + } +} + func TestDO_Networking_UnsupportedProvider(t *testing.T) { app := module.NewMockApplication() m := module.NewPlatformDONetworking("bad-vpc", map[string]any{ From 1bb9da7ba5cc7c44ebeee858fcf6397a2bb7e57b Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 9 Mar 2026 00:33:05 -0400 Subject: [PATCH 03/10] feat: add platform.do_database module for DO Managed Databases Co-Authored-By: Claude Opus 4.6 --- module/platform_do_database.go | 263 ++++++++++++++++++++++++++++ module/platform_do_database_test.go | 66 +++++++ plugins/platform/plugin.go | 21 ++- 3 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 module/platform_do_database.go create mode 100644 module/platform_do_database_test.go diff --git a/module/platform_do_database.go b/module/platform_do_database.go new file mode 100644 index 00000000..08342f5d --- /dev/null +++ b/module/platform_do_database.go @@ -0,0 +1,263 @@ +package module + +import ( + "context" + "fmt" + "time" + + "github.com/CrisisTextLine/modular" + "github.com/digitalocean/godo" +) + +// DODatabaseState holds the current state of a DO Managed Database. +type DODatabaseState struct { + ID string `json:"id"` + Name string `json:"name"` + Engine string `json:"engine"` // pg, mysql, redis, mongodb, kafka + Version string `json:"version"` + Size string `json:"size"` // e.g. db-s-1vcpu-1gb + Region string `json:"region"` + NumNodes int `json:"numNodes"` + Status string `json:"status"` // pending, online, resizing, migrating, error + Host string `json:"host"` + Port int `json:"port"` + DatabaseName string `json:"databaseName"` + User string `json:"user"` + Password string `json:"password"` + URI string `json:"uri"` + CreatedAt time.Time `json:"createdAt"` +} + +// doDatabaseBackend is the interface for DO managed database backends. +type doDatabaseBackend interface { + create(m *PlatformDODatabase) (*DODatabaseState, error) + status(m *PlatformDODatabase) (*DODatabaseState, error) + destroy(m *PlatformDODatabase) error +} + +// PlatformDODatabase manages DigitalOcean Managed Databases. +// Config: +// +// account: name of a cloud.account module (provider=digitalocean) +// provider: digitalocean | mock +// engine: pg | mysql | redis | mongodb | kafka +// version: engine version string (e.g. "16" for pg) +// size: droplet size slug (e.g. db-s-1vcpu-1gb) +// region: DO region slug (e.g. nyc1) +// num_nodes: number of nodes (default: 1) +// name: database cluster name +type PlatformDODatabase struct { + name string + config map[string]any + state *DODatabaseState + backend doDatabaseBackend +} + +// NewPlatformDODatabase creates a new PlatformDODatabase module. +func NewPlatformDODatabase(name string, cfg map[string]any) *PlatformDODatabase { + return &PlatformDODatabase{name: name, config: cfg} +} + +func (m *PlatformDODatabase) Name() string { return m.name } + +func (m *PlatformDODatabase) Init(app modular.Application) error { + dbName, _ := m.config["name"].(string) + if dbName == "" { + dbName = m.name + } + engine, _ := m.config["engine"].(string) + if engine == "" { + engine = "pg" + } + version, _ := m.config["version"].(string) + size, _ := m.config["size"].(string) + if size == "" { + size = "db-s-1vcpu-1gb" + } + region, _ := m.config["region"].(string) + if region == "" { + region = "nyc1" + } + numNodes, _ := intFromAny(m.config["num_nodes"]) + if numNodes == 0 { + numNodes = 1 + } + + m.state = &DODatabaseState{ + Name: dbName, + Engine: engine, + Version: version, + Size: size, + Region: region, + NumNodes: numNodes, + Status: "pending", + } + + providerType, _ := m.config["provider"].(string) + if providerType == "" { + providerType = "mock" + } + + switch providerType { + case "mock": + m.backend = &doDatabaseMockBackend{} + case "digitalocean": + accountName, _ := m.config["account"].(string) + acc, ok := app.SvcRegistry()[accountName].(*CloudAccount) + if !ok { + return fmt.Errorf("platform.do_database %q: account %q is not a *CloudAccount", m.name, accountName) + } + client, err := acc.doClient() + if err != nil { + return fmt.Errorf("platform.do_database %q: %w", m.name, err) + } + m.backend = &doDatabaseRealBackend{client: client} + default: + return fmt.Errorf("platform.do_database %q: unsupported provider %q", m.name, providerType) + } + + return app.RegisterService(m.name, m) +} + +func (m *PlatformDODatabase) ProvidesServices() []modular.ServiceProvider { + return []modular.ServiceProvider{ + {Name: m.name, Description: "DO Database: " + m.name, Instance: m}, + } +} + +func (m *PlatformDODatabase) RequiresServices() []modular.ServiceDependency { return nil } + +// PlatformProvider implementation — directly, no adapter needed since this is new. + +func (m *PlatformDODatabase) Plan() (*PlatformPlan, error) { + actionType := "create" + detail := fmt.Sprintf("Create %s %s database %q in %s (%s, %d nodes)", + m.state.Engine, m.state.Version, m.state.Name, m.state.Region, m.state.Size, m.state.NumNodes) + if m.state.ID != "" { + actionType = "update" + detail = fmt.Sprintf("Update database %q (%s → %s, %d nodes)", + m.state.Name, m.state.Size, m.state.Size, m.state.NumNodes) + } + return &PlatformPlan{ + Provider: "digitalocean", + Resource: "managed_database", + Actions: []PlatformAction{{Type: actionType, Resource: m.state.Name, Detail: detail}}, + }, nil +} + +func (m *PlatformDODatabase) Apply() (*PlatformResult, error) { + st, err := m.backend.create(m) + if err != nil { + return &PlatformResult{Success: false, Message: err.Error()}, err + } + m.state = st + return &PlatformResult{ + Success: true, + Message: fmt.Sprintf("Database %s online (host: %s:%d)", st.Name, st.Host, st.Port), + State: st, + }, nil +} + +func (m *PlatformDODatabase) Status() (any, error) { + return m.backend.status(m) +} + +func (m *PlatformDODatabase) Destroy() error { + return m.backend.destroy(m) +} + +// ─── mock backend ────────────────────────────────────────────────────────────── + +type doDatabaseMockBackend struct{} + +func (b *doDatabaseMockBackend) create(m *PlatformDODatabase) (*DODatabaseState, error) { + m.state.ID = "mock-db-" + m.state.Name + m.state.Status = "online" + m.state.Host = m.state.Name + ".db.ondigitalocean.com" + m.state.Port = 25060 + m.state.DatabaseName = "defaultdb" + m.state.User = "doadmin" + m.state.Password = "mock-password" + m.state.URI = fmt.Sprintf("postgresql://%s:%s@%s:%d/%s?sslmode=require", + m.state.User, m.state.Password, m.state.Host, m.state.Port, m.state.DatabaseName) + m.state.CreatedAt = time.Now().UTC() + return m.state, nil +} + +func (b *doDatabaseMockBackend) status(m *PlatformDODatabase) (*DODatabaseState, error) { + return m.state, nil +} + +func (b *doDatabaseMockBackend) destroy(m *PlatformDODatabase) error { + m.state.Status = "deleted" + m.state.ID = "" + return nil +} + +// ─── real backend ────────────────────────────────────────────────────────────── + +type doDatabaseRealBackend struct { + client *godo.Client +} + +func (b *doDatabaseRealBackend) create(m *PlatformDODatabase) (*DODatabaseState, error) { + req := &godo.DatabaseCreateRequest{ + Name: m.state.Name, + EngineSlug: m.state.Engine, + Version: m.state.Version, + SizeSlug: m.state.Size, + Region: m.state.Region, + NumNodes: m.state.NumNodes, + } + db, _, err := b.client.Databases.Create(context.Background(), req) + if err != nil { + return nil, fmt.Errorf("create database: %w", err) + } + return doDatabaseFromGodo(db), nil +} + +func (b *doDatabaseRealBackend) status(m *PlatformDODatabase) (*DODatabaseState, error) { + if m.state.ID == "" { + return m.state, nil + } + db, _, err := b.client.Databases.Get(context.Background(), m.state.ID) + if err != nil { + return nil, fmt.Errorf("get database: %w", err) + } + return doDatabaseFromGodo(db), nil +} + +func (b *doDatabaseRealBackend) destroy(m *PlatformDODatabase) error { + if m.state.ID == "" { + return nil + } + _, err := b.client.Databases.Delete(context.Background(), m.state.ID) + if err != nil { + return fmt.Errorf("delete database: %w", err) + } + m.state.Status = "deleted" + return nil +} + +func doDatabaseFromGodo(db *godo.Database) *DODatabaseState { + st := &DODatabaseState{ + ID: db.ID, + Name: db.Name, + Engine: db.EngineSlug, + Version: db.VersionSlug, + Size: db.SizeSlug, + Region: db.RegionSlug, + NumNodes: db.NumNodes, + Status: db.Status, + CreatedAt: db.CreatedAt, + } + if db.Connection != nil { + st.Host = db.Connection.Host + st.Port = db.Connection.Port + st.DatabaseName = db.Connection.Database + st.User = db.Connection.User + st.Password = db.Connection.Password + st.URI = db.Connection.URI + } + return st +} diff --git a/module/platform_do_database_test.go b/module/platform_do_database_test.go new file mode 100644 index 00000000..52e36f98 --- /dev/null +++ b/module/platform_do_database_test.go @@ -0,0 +1,66 @@ +package module + +import "testing" + +func TestPlatformDODatabase_MockBackend(t *testing.T) { + m := &PlatformDODatabase{ + name: "test-db", + config: map[string]any{ + "provider": "mock", + "engine": "pg", + "version": "16", + "size": "db-s-1vcpu-1gb", + "region": "nyc1", + "num_nodes": 1, + "name": "test-db", + }, + state: &DODatabaseState{ + Name: "test-db", + Engine: "pg", + Version: "16", + Size: "db-s-1vcpu-1gb", + Region: "nyc1", + NumNodes: 1, + Status: "pending", + }, + backend: &doDatabaseMockBackend{}, + } + + // Test PlatformProvider interface + var _ PlatformProvider = m + + // Plan + plan, err := m.Plan() + if err != nil { + t.Fatalf("Plan() error: %v", err) + } + if plan.Provider != "digitalocean" { + t.Errorf("expected provider digitalocean, got %s", plan.Provider) + } + if plan.Resource != "managed_database" { + t.Errorf("expected resource managed_database, got %s", plan.Resource) + } + + // Apply + result, err := m.Apply() + if err != nil { + t.Fatalf("Apply() error: %v", err) + } + if !result.Success { + t.Error("expected success") + } + + // Status + st, err := m.Status() + if err != nil { + t.Fatalf("Status() error: %v", err) + } + if st == nil { + t.Error("expected non-nil status") + } + + // Destroy + if err := m.Destroy(); err != nil { + t.Fatalf("Destroy() error: %v", err) + } +} diff --git a/plugins/platform/plugin.go b/plugins/platform/plugin.go index 8709fc2d..8c317fb9 100644 --- a/plugins/platform/plugin.go +++ b/plugins/platform/plugin.go @@ -31,7 +31,7 @@ func New() *Plugin { Author: "GoCodeAlone", Description: "Platform infrastructure modules, workflow handler, reconciliation trigger, and template step", Tier: plugin.TierCore, - ModuleTypes: []string{"platform.provider", "platform.resource", "platform.context", "platform.kubernetes", "platform.ecs", "platform.dns", "platform.networking", "platform.apigateway", "platform.autoscaling", "platform.region", "platform.region_router", "platform.doks", "platform.do_networking", "platform.do_dns", "platform.do_app", "iac.state", "app.container", "argo.workflows"}, + ModuleTypes: []string{"platform.provider", "platform.resource", "platform.context", "platform.kubernetes", "platform.ecs", "platform.dns", "platform.networking", "platform.apigateway", "platform.autoscaling", "platform.region", "platform.region_router", "platform.doks", "platform.do_networking", "platform.do_dns", "platform.do_app", "platform.do_database", "iac.state", "app.container", "argo.workflows"}, StepTypes: []string{"step.platform_template", "step.k8s_plan", "step.k8s_apply", "step.k8s_status", "step.k8s_destroy", "step.ecs_plan", "step.ecs_apply", "step.ecs_status", "step.ecs_destroy", "step.iac_plan", "step.iac_apply", "step.iac_status", "step.iac_destroy", "step.iac_drift_detect", "step.dns_plan", "step.dns_apply", "step.dns_status", "step.network_plan", "step.network_apply", "step.network_status", "step.apigw_plan", "step.apigw_apply", "step.apigw_status", "step.apigw_destroy", "step.scaling_plan", "step.scaling_apply", "step.scaling_status", "step.scaling_destroy", "step.app_deploy", "step.app_status", "step.app_rollback", "step.region_deploy", "step.region_promote", "step.region_failover", "step.region_status", "step.region_weight", "step.region_sync", "step.argo_submit", "step.argo_status", "step.argo_logs", "step.argo_delete", "step.argo_list", "step.do_deploy", "step.do_status", "step.do_logs", "step.do_scale", "step.do_destroy"}, TriggerTypes: []string{"reconciliation"}, WorkflowTypes: []string{"platform"}, @@ -106,6 +106,9 @@ func (p *Plugin) ModuleFactories() map[string]plugin.ModuleFactory { "platform.do_app": func(name string, cfg map[string]any) modular.Module { return module.NewPlatformDOApp(name, cfg) }, + "platform.do_database": func(name string, cfg map[string]any) modular.Module { + return module.NewPlatformDODatabase(name, cfg) + }, "platform.region_router": func(name string, cfg map[string]any) modular.Module { return module.NewMultiRegionRoutingModule(name, cfg) }, @@ -475,5 +478,21 @@ func (p *Plugin) ModuleSchemas() []*schema.ModuleSchema { {Key: "envs", Label: "Environment Variables", Type: schema.FieldTypeMap, Description: "Environment variables for the app"}, }, }, + { + Type: "platform.do_database", + Label: "DigitalOcean Managed Database", + Category: "infrastructure", + Description: "Manages DigitalOcean Managed Databases (PostgreSQL, MySQL, Redis, MongoDB, Kafka)", + ConfigFields: []schema.ConfigFieldDef{ + {Key: "account", Label: "Cloud Account", Type: schema.FieldTypeString, Description: "Name of the cloud.account module (provider=digitalocean)"}, + {Key: "provider", Label: "Provider", Type: schema.FieldTypeString, Description: "mock | digitalocean"}, + {Key: "engine", Label: "Engine", Type: schema.FieldTypeString, Description: "Database engine: pg | mysql | redis | mongodb | kafka"}, + {Key: "version", Label: "Version", Type: schema.FieldTypeString, Description: "Engine version (e.g. 16 for PostgreSQL)"}, + {Key: "size", Label: "Size", Type: schema.FieldTypeString, Description: "Droplet size slug (e.g. db-s-1vcpu-1gb)"}, + {Key: "region", Label: "Region", Type: schema.FieldTypeString, Description: "DO region slug (e.g. nyc1)"}, + {Key: "num_nodes", Label: "Node Count", Type: schema.FieldTypeNumber, Description: "Number of nodes (default: 1)"}, + {Key: "name", Label: "Cluster Name", Type: schema.FieldTypeString, Description: "Database cluster name"}, + }, + }, } } From 41ab522996f66fc71b20d7a3b378a911baeb9712 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 9 Mar 2026 00:34:04 -0400 Subject: [PATCH 04/10] feat: add PlatformProvider adapter for DO DNS module DODNSPlatformAdapter wraps PlatformDODNS to implement PlatformProvider (Plan/Apply/Status/Destroy), registered as ".iac" service. Converts DODNSPlan changes to PlatformAction entries. Co-Authored-By: Claude Opus 4.6 --- module/platform_do_dns.go | 61 ++++++++++++++++++++- module/platform_do_dns_test.go | 98 ++++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 1 deletion(-) diff --git a/module/platform_do_dns.go b/module/platform_do_dns.go index 3a77fbc1..709b7d81 100644 --- a/module/platform_do_dns.go +++ b/module/platform_do_dns.go @@ -113,13 +113,17 @@ func (m *PlatformDODNS) Init(app modular.Application) error { return fmt.Errorf("platform.do_dns %q: unsupported provider %q", m.name, providerType) } - return app.RegisterService(m.name, m) + if err := app.RegisterService(m.name, m); err != nil { + return err + } + return app.RegisterService(m.name+".iac", &DODNSPlatformAdapter{m}) } // ProvidesServices declares the service this module provides. func (m *PlatformDODNS) ProvidesServices() []modular.ServiceProvider { return []modular.ServiceProvider{ {Name: m.name, Description: "DO DNS: " + m.name, Instance: m}, + {Name: m.name + ".iac", Description: "DO DNS IaC adapter: " + m.name, Instance: &DODNSPlatformAdapter{m}}, } } @@ -167,6 +171,61 @@ func (m *PlatformDODNS) recordConfigs() []DODNSRecordState { return records } +// ─── PlatformProvider adapter ────────────────────────────────────────────────── + +// DODNSPlatformAdapter wraps PlatformDODNS to implement PlatformProvider. +type DODNSPlatformAdapter struct { + *PlatformDODNS +} + +// Plan implements PlatformProvider. +func (a *DODNSPlatformAdapter) Plan() (*PlatformPlan, error) { + p, err := a.PlatformDODNS.Plan() + if err != nil { + return nil, err + } + var actions []PlatformAction + for _, change := range p.Changes { + actionType := "create" + if change == "no changes" { + actionType = "noop" + } + actions = append(actions, PlatformAction{ + Type: actionType, + Resource: p.Domain, + Detail: change, + }) + } + return &PlatformPlan{ + Provider: "digitalocean", + Resource: "dns", + Actions: actions, + }, nil +} + +// Apply implements PlatformProvider. +func (a *DODNSPlatformAdapter) Apply() (*PlatformResult, error) { + st, err := a.PlatformDODNS.Apply() + if err != nil { + return &PlatformResult{Success: false, Message: err.Error()}, err + } + return &PlatformResult{ + Success: true, + Message: fmt.Sprintf("DNS domain %s configured with %d records", st.DomainName, len(st.Records)), + State: st, + }, nil +} + +// Status implements PlatformProvider. +func (a *DODNSPlatformAdapter) Status() (any, error) { + return a.PlatformDODNS.Status() +} + +// Destroy implements PlatformProvider. +func (a *DODNSPlatformAdapter) Destroy() error { + return a.PlatformDODNS.Destroy() +} + // ─── mock backend ────────────────────────────────────────────────────────────── type doDNSMockBackend struct{} diff --git a/module/platform_do_dns_test.go b/module/platform_do_dns_test.go index c735a13c..78d8c7f7 100644 --- a/module/platform_do_dns_test.go +++ b/module/platform_do_dns_test.go @@ -138,6 +138,104 @@ func TestDO_DNS_DestroyIdempotent(t *testing.T) { } } +// ─── PlatformProvider adapter ───────────────────────────────────────────────── + +func TestDO_DNS_AdapterImplementsPlatformProvider(t *testing.T) { + app, _ := newDODNSApp(t) + svc, ok := app.Services["prod-do-dns.iac"] + if !ok { + t.Fatal("expected prod-do-dns.iac in service registry") + } + if _, ok := svc.(module.PlatformProvider); !ok { + t.Fatalf("prod-do-dns.iac service (%T) does not implement PlatformProvider", svc) + } +} + +func TestDO_DNS_AdapterPlan(t *testing.T) { + app, _ := newDODNSApp(t) + prov := app.Services["prod-do-dns.iac"].(module.PlatformProvider) + plan, err := prov.Plan() + if err != nil { + t.Fatalf("Plan() error: %v", err) + } + if plan.Provider != "digitalocean" { + t.Errorf("expected provider digitalocean, got %s", plan.Provider) + } + if plan.Resource != "dns" { + t.Errorf("expected resource dns, got %s", plan.Resource) + } + if len(plan.Actions) == 0 { + t.Fatal("expected at least one action") + } +} + +func TestDO_DNS_AdapterPlanNoop(t *testing.T) { + app, m := newDODNSApp(t) + if _, err := m.Apply(); err != nil { + t.Fatalf("Apply: %v", err) + } + prov := app.Services["prod-do-dns.iac"].(module.PlatformProvider) + plan, err := prov.Plan() + if err != nil { + t.Fatalf("Plan() error: %v", err) + } + if len(plan.Actions) != 1 { + t.Fatalf("expected 1 noop action, got %d", len(plan.Actions)) + } + if plan.Actions[0].Type != "noop" { + t.Errorf("expected noop action after apply, got %s", plan.Actions[0].Type) + } +} + +func TestDO_DNS_AdapterApply(t *testing.T) { + app, _ := newDODNSApp(t) + prov := app.Services["prod-do-dns.iac"].(module.PlatformProvider) + result, err := prov.Apply() + if err != nil { + t.Fatalf("Apply() error: %v", err) + } + if !result.Success { + t.Errorf("expected success, got message: %s", result.Message) + } + if result.State == nil { + t.Error("expected non-nil state") + } +} + +func TestDO_DNS_AdapterStatus(t *testing.T) { + app, _ := newDODNSApp(t) + prov := app.Services["prod-do-dns.iac"].(module.PlatformProvider) + st, err := prov.Status() + if err != nil { + t.Fatalf("Status() error: %v", err) + } + if st == nil { + t.Error("expected non-nil status") + } +} + +func TestDO_DNS_AdapterDestroy(t *testing.T) { + app, _ := newDODNSApp(t) + prov := app.Services["prod-do-dns.iac"].(module.PlatformProvider) + if _, err := prov.Apply(); err != nil { + t.Fatalf("Apply: %v", err) + } + if err := prov.Destroy(); err != nil { + t.Fatalf("Destroy() error: %v", err) + } + st, err := prov.Status() + if err != nil { + t.Fatalf("Status after destroy: %v", err) + } + dnsState, ok := st.(*module.DODNSState) + if !ok { + t.Fatalf("expected *DODNSState, got %T", st) + } + if dnsState.Status != "deleted" { + t.Errorf("expected status deleted, got %s", dnsState.Status) + } +} + func TestDO_DNS_MissingDomain(t *testing.T) { app := module.NewMockApplication() m := module.NewPlatformDODNS("bad-dns", map[string]any{ From a8cc7a1385bcb558fa877021cfd1d8b5a68985c4 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 9 Mar 2026 00:37:44 -0400 Subject: [PATCH 05/10] feat: add wfctl infra command for IaC lifecycle management Co-Authored-By: Claude Opus 4.6 --- cmd/wfctl/infra.go | 260 +++++++++++++++++++++++++++++++++++++++++++++ cmd/wfctl/main.go | 1 + 2 files changed, 261 insertions(+) create mode 100644 cmd/wfctl/infra.go diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go new file mode 100644 index 00000000..3a64879f --- /dev/null +++ b/cmd/wfctl/infra.go @@ -0,0 +1,260 @@ +package main + +import ( + "flag" + "fmt" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +func runInfra(args []string) error { + if len(args) < 1 { + return infraUsage() + } + switch args[0] { + case "plan": + return runInfraPlan(args[1:]) + case "apply": + return runInfraApply(args[1:]) + case "status": + return runInfraStatus(args[1:]) + case "drift": + return runInfraDrift(args[1:]) + case "destroy": + return runInfraDestroy(args[1:]) + default: + return infraUsage() + } +} + +func infraUsage() error { + fmt.Fprintf(flag.CommandLine.Output(), `Usage: wfctl infra [options] [config.yaml] + +Manage infrastructure defined in a workflow config. + +Actions: + plan Show planned infrastructure changes + apply Apply infrastructure changes + status Show current infrastructure status + drift Detect configuration drift + destroy Tear down infrastructure + +Options: + --config Config file (default: infra.yaml or config/infra.yaml) + --auto-approve Skip confirmation prompt (apply/destroy only) +`) + return fmt.Errorf("missing action") +} + +// resolveInfraConfig finds the config file from flags or defaults. +func resolveInfraConfig(fs *flag.FlagSet) (string, error) { + configFile := fs.Lookup("config").Value.String() + if configFile != "" { + return configFile, nil + } + for _, candidate := range []string{"infra.yaml", "config/infra.yaml"} { + if _, err := os.Stat(candidate); err == nil { + return candidate, nil + } + } + // Check remaining args for a positional config file + for _, arg := range fs.Args() { + if strings.HasSuffix(arg, ".yaml") || strings.HasSuffix(arg, ".yml") { + return arg, nil + } + } + return "", fmt.Errorf("no config file found (tried infra.yaml, config/infra.yaml)") +} + +// infraModuleEntry is a minimal struct for parsing modules from YAML. +type infraModuleEntry struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + Config map[string]any `yaml:"config"` +} + +// discoverInfraModules parses the config and finds IaC-related modules. +func discoverInfraModules(cfgFile string) (iacState []infraModuleEntry, platforms []infraModuleEntry, cloudAccounts []infraModuleEntry, err error) { + data, readErr := os.ReadFile(cfgFile) + if readErr != nil { + return nil, nil, nil, fmt.Errorf("read %s: %w", cfgFile, readErr) + } + + var parsed struct { + Modules []infraModuleEntry `yaml:"modules"` + } + if yamlErr := yaml.Unmarshal(data, &parsed); yamlErr != nil { + return nil, nil, nil, fmt.Errorf("parse %s: %w", cfgFile, yamlErr) + } + + for _, m := range parsed.Modules { + switch { + case m.Type == "iac.state": + iacState = append(iacState, m) + case m.Type == "cloud.account": + cloudAccounts = append(cloudAccounts, m) + case strings.HasPrefix(m.Type, "platform."): + platforms = append(platforms, m) + } + } + return +} + +func runInfraPlan(args []string) error { + fs := flag.NewFlagSet("infra plan", flag.ContinueOnError) + _ = fs.String("config", "", "Config file") + if err := fs.Parse(args); err != nil { + return err + } + + cfgFile, err := resolveInfraConfig(fs) + if err != nil { + return err + } + + iacStates, platforms, cloudAccounts, err := discoverInfraModules(cfgFile) + if err != nil { + return err + } + + fmt.Printf("Infrastructure Plan\n") + fmt.Printf("===================\n") + fmt.Printf("Config: %s\n\n", cfgFile) + + if len(cloudAccounts) == 0 { + fmt.Printf("WARNING: No cloud.account modules found.\n\n") + } else { + for _, ca := range cloudAccounts { + provider, _ := ca.Config["provider"].(string) + fmt.Printf("Cloud Account: %s (provider: %s)\n", ca.Name, provider) + } + fmt.Println() + } + + if len(iacStates) == 0 { + fmt.Printf("WARNING: No iac.state modules found — state will not be persisted.\n\n") + } else { + for _, is := range iacStates { + backend, _ := is.Config["backend"].(string) + dir, _ := is.Config["directory"].(string) + fmt.Printf("State Backend: %s (backend: %s, dir: %s)\n", is.Name, backend, dir) + } + fmt.Println() + } + + if len(platforms) == 0 { + return fmt.Errorf("no platform.* modules found in %s", cfgFile) + } + + fmt.Printf("Resources to manage (%d):\n", len(platforms)) + for _, p := range platforms { + fmt.Printf(" + %s (%s)\n", p.Name, p.Type) + for k, v := range p.Config { + if k == "account" || k == "provider" { + continue + } + fmt.Printf(" %s: %v\n", k, v) + } + } + fmt.Println() + + // Execute plan via wfctl pipeline run + fmt.Printf("Running plan pipeline...\n") + return runPipelineRun([]string{"-c", cfgFile, "-p", "plan"}) +} + +func runInfraApply(args []string) error { + fs := flag.NewFlagSet("infra apply", flag.ContinueOnError) + configFlag := fs.String("config", "", "Config file") + autoApprove := fs.Bool("auto-approve", false, "Skip confirmation") + if err := fs.Parse(args); err != nil { + return err + } + + cfgFile := *configFlag + if cfgFile == "" { + var err error + cfgFile, err = resolveInfraConfig(fs) + if err != nil { + return err + } + } + + if !*autoApprove { + fmt.Printf("Apply infrastructure changes from %s? [y/N]: ", cfgFile) + var answer string + fmt.Scanln(&answer) + if strings.ToLower(answer) != "y" && strings.ToLower(answer) != "yes" { + fmt.Println("Cancelled.") + return nil + } + } + + fmt.Printf("Applying infrastructure from %s...\n", cfgFile) + return runPipelineRun([]string{"-c", cfgFile, "-p", "apply"}) +} + +func runInfraStatus(args []string) error { + fs := flag.NewFlagSet("infra status", flag.ContinueOnError) + _ = fs.String("config", "", "Config file") + if err := fs.Parse(args); err != nil { + return err + } + + cfgFile, err := resolveInfraConfig(fs) + if err != nil { + return err + } + + fmt.Printf("Infrastructure status from %s...\n", cfgFile) + return runPipelineRun([]string{"-c", cfgFile, "-p", "status"}) +} + +func runInfraDrift(args []string) error { + fs := flag.NewFlagSet("infra drift", flag.ContinueOnError) + _ = fs.String("config", "", "Config file") + if err := fs.Parse(args); err != nil { + return err + } + + cfgFile, err := resolveInfraConfig(fs) + if err != nil { + return err + } + + fmt.Printf("Detecting drift for %s...\n", cfgFile) + return runPipelineRun([]string{"-c", cfgFile, "-p", "drift"}) +} + +func runInfraDestroy(args []string) error { + fs := flag.NewFlagSet("infra destroy", flag.ContinueOnError) + configFlag := fs.String("config", "", "Config file") + autoApprove := fs.Bool("auto-approve", false, "Skip confirmation") + if err := fs.Parse(args); err != nil { + return err + } + + cfgFile := *configFlag + if cfgFile == "" { + var err error + cfgFile, err = resolveInfraConfig(fs) + if err != nil { + return err + } + } + + if !*autoApprove { + fmt.Printf("DESTROY all infrastructure defined in %s? This cannot be undone. [y/N]: ", cfgFile) + var answer string + fmt.Scanln(&answer) + if strings.ToLower(answer) != "y" && strings.ToLower(answer) != "yes" { + fmt.Println("Cancelled.") + return nil + } + } + + fmt.Printf("Destroying infrastructure from %s...\n", cfgFile) + return runPipelineRun([]string{"-c", cfgFile, "-p", "destroy"}) +} diff --git a/cmd/wfctl/main.go b/cmd/wfctl/main.go index 255a1bc0..2c31bff4 100644 --- a/cmd/wfctl/main.go +++ b/cmd/wfctl/main.go @@ -57,6 +57,7 @@ var commands = map[string]func([]string) error{ "update": runUpdate, "mcp": runMCP, "modernize": runModernize, + "infra": runInfra, } func main() { From 3038438cbdeaf5b36c6e0b9f7284e4305047f600 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 9 Mar 2026 00:39:29 -0400 Subject: [PATCH 06/10] feat: add infra command to wfctl CLI definition Co-Authored-By: Claude Opus 4.6 --- cmd/wfctl/wfctl.yaml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/cmd/wfctl/wfctl.yaml b/cmd/wfctl/wfctl.yaml index a887c5fb..5a7111d5 100644 --- a/cmd/wfctl/wfctl.yaml +++ b/cmd/wfctl/wfctl.yaml @@ -53,6 +53,8 @@ workflows: description: Start the MCP server over stdio for AI assistant integration - name: modernize description: "Detect and fix known YAML config anti-patterns (dry-run by default)" + - name: infra + description: "Manage infrastructure lifecycle (plan, apply, status, drift, destroy)" # Each command is expressed as a workflow pipeline triggered by the CLI. # The pipeline delegates to the registered Go implementation via step.cli_invoke, @@ -335,3 +337,14 @@ pipelines: config: command: modernize + cmd-infra: + trigger: + type: cli + config: + command: infra + steps: + - name: run + type: step.cli_invoke + config: + command: infra + From e5cb0e19be676aef714da279fc4126c08ab22ecc Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 9 Mar 2026 00:40:01 -0400 Subject: [PATCH 07/10] docs: add DO platform modules and wfctl infra command reference Add platform.do_app, platform.do_networking, platform.do_dns, and platform.do_database to the Infrastructure module table in DOCUMENTATION.md. Add wfctl infra command reference to docs/WFCTL.md. Co-Authored-By: Claude Opus 4.6 --- DOCUMENTATION.md | 4 ++++ docs/WFCTL.md | 31 +++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index f6cd9ff0..5478b805 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -285,6 +285,10 @@ value: '{{ index .steps "parse-request" "path_params" "id" }}' | `platform.provider` | Cloud infrastructure provider declaration (e.g., Terraform, Pulumi) | | `platform.resource` | Infrastructure resource managed by a platform provider | | `platform.context` | Execution context for platform operations (org, environment, tier) | +| `platform.do_app` | DigitalOcean App Platform deployment (deploy, scale, logs, destroy) | +| `platform.do_networking` | DigitalOcean VPC and firewall management | +| `platform.do_dns` | DigitalOcean domain and DNS record management | +| `platform.do_database` | DigitalOcean Managed Database (PostgreSQL, MySQL, Redis) | ### Observability | Type | Description | diff --git a/docs/WFCTL.md b/docs/WFCTL.md index 30cc85df..65974d3c 100644 --- a/docs/WFCTL.md +++ b/docs/WFCTL.md @@ -788,6 +788,37 @@ wfctl deploy cloud --target production --yes --- +### `infra` + +Manage infrastructure lifecycle defined in a workflow config. Discovers `cloud.account`, `iac.state`, and `platform.*` modules, then executes the corresponding IaC pipeline. + +``` +wfctl infra [options] [config.yaml] +``` + +| Action | Description | +|--------|-------------| +| `plan` | Show planned infrastructure changes | +| `apply` | Apply infrastructure changes | +| `status` | Show current infrastructure status | +| `drift` | Detect configuration drift | +| `destroy` | Tear down all managed infrastructure | + +| Flag | Default | Description | +|------|---------|-------------| +| `--config` | _(auto-detected)_ | Config file (searches `infra.yaml`, `config/infra.yaml`) | +| `--auto-approve` | `false` | Skip confirmation prompt (apply/destroy only) | + +```bash +wfctl infra plan infra.yaml +wfctl infra apply --auto-approve infra.yaml +wfctl infra status --config infra.yaml +wfctl infra drift infra.yaml +wfctl infra destroy --auto-approve infra.yaml +``` + +--- + ### `api extract` Parse a workflow config file offline and output an OpenAPI 3.0 specification of all HTTP endpoints defined in the config. From 84f20b43374c53b6531a1ce7b2fe24594abaa99c Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 9 Mar 2026 01:02:48 -0400 Subject: [PATCH 08/10] fix: resolve golangci-lint errors in infra.go and platform_do_app.go - Check fmt.Scanln error return values (errcheck) - Use strings.EqualFold instead of strings.ToLower comparison (gocritic) - Remove redundant embedded field selector PlatformDOApp (staticcheck) Co-Authored-By: Claude Opus 4.6 --- cmd/wfctl/infra.go | 12 ++++++++---- module/platform_do_app.go | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index 3a64879f..7bb2ce7a 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -185,8 +185,10 @@ func runInfraApply(args []string) error { if !*autoApprove { fmt.Printf("Apply infrastructure changes from %s? [y/N]: ", cfgFile) var answer string - fmt.Scanln(&answer) - if strings.ToLower(answer) != "y" && strings.ToLower(answer) != "yes" { + if _, err := fmt.Scanln(&answer); err != nil { + return fmt.Errorf("reading input: %w", err) + } + if !strings.EqualFold(answer, "y") && !strings.EqualFold(answer, "yes") { fmt.Println("Cancelled.") return nil } @@ -248,8 +250,10 @@ func runInfraDestroy(args []string) error { if !*autoApprove { fmt.Printf("DESTROY all infrastructure defined in %s? This cannot be undone. [y/N]: ", cfgFile) var answer string - fmt.Scanln(&answer) - if strings.ToLower(answer) != "y" && strings.ToLower(answer) != "yes" { + if _, err := fmt.Scanln(&answer); err != nil { + return fmt.Errorf("reading input: %w", err) + } + if !strings.EqualFold(answer, "y") && !strings.EqualFold(answer, "yes") { fmt.Println("Cancelled.") return nil } diff --git a/module/platform_do_app.go b/module/platform_do_app.go index a93eacad..73627e47 100644 --- a/module/platform_do_app.go +++ b/module/platform_do_app.go @@ -239,7 +239,7 @@ func (a *DOAppPlatformAdapter) Plan() (*PlatformPlan, error) { // Apply implements PlatformProvider. Deploys via the backend. func (a *DOAppPlatformAdapter) Apply() (*PlatformResult, error) { - st, err := a.PlatformDOApp.Deploy() + st, err := a.Deploy() if err != nil { return &PlatformResult{Success: false, Message: err.Error()}, err } From 052eebd91150b12c5c8066b09c4d69fca0129fbf Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 9 Mar 2026 01:14:18 -0400 Subject: [PATCH 09/10] fix: address Copilot review feedback - Fix misleading Plan() detail format string (was printing same value twice) - Improve infraUsage() error message for unknown actions Co-Authored-By: Claude Opus 4.6 --- cmd/wfctl/infra.go | 2 +- module/platform_do_database.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index 7bb2ce7a..b4f42cb4 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -45,7 +45,7 @@ Options: --config Config file (default: infra.yaml or config/infra.yaml) --auto-approve Skip confirmation prompt (apply/destroy only) `) - return fmt.Errorf("missing action") + return fmt.Errorf("missing or unknown action") } // resolveInfraConfig finds the config file from flags or defaults. diff --git a/module/platform_do_database.go b/module/platform_do_database.go index 08342f5d..396861b2 100644 --- a/module/platform_do_database.go +++ b/module/platform_do_database.go @@ -135,8 +135,8 @@ func (m *PlatformDODatabase) Plan() (*PlatformPlan, error) { m.state.Engine, m.state.Version, m.state.Name, m.state.Region, m.state.Size, m.state.NumNodes) if m.state.ID != "" { actionType = "update" - detail = fmt.Sprintf("Update database %q (%s → %s, %d nodes)", - m.state.Name, m.state.Size, m.state.Size, m.state.NumNodes) + detail = fmt.Sprintf("Update database %q (size: %s, %d nodes)", + m.state.Name, m.state.Size, m.state.NumNodes) } return &PlatformPlan{ Provider: "digitalocean", From 399bd153f9eb7d943e53e7410527900c69311a61 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 9 Mar 2026 01:25:22 -0400 Subject: [PATCH 10/10] feat: add DigitalOcean Spaces backend for iac.state module S3-compatible remote state storage using DO Spaces. Supports custom endpoints for any S3-compatible store. Addresses PR review feedback about avoiding repo-committed state files with credentials. Co-Authored-By: Claude Opus 4.6 --- DOCUMENTATION.md | 32 +++ module/iac_module.go | 20 +- module/iac_state_spaces.go | 328 +++++++++++++++++++++++ module/iac_state_spaces_test.go | 449 ++++++++++++++++++++++++++++++++ 4 files changed, 827 insertions(+), 2 deletions(-) create mode 100644 module/iac_state_spaces.go create mode 100644 module/iac_state_spaces_test.go diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 5478b805..ed165ed5 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -289,6 +289,7 @@ value: '{{ index .steps "parse-request" "path_params" "id" }}' | `platform.do_networking` | DigitalOcean VPC and firewall management | | `platform.do_dns` | DigitalOcean domain and DNS record management | | `platform.do_database` | DigitalOcean Managed Database (PostgreSQL, MySQL, Redis) | +| `iac.state` | IaC state persistence (memory, filesystem, or spaces/S3-compatible backends) | ### Observability | Type | Description | @@ -471,6 +472,37 @@ modules: --- +### `iac.state` + +Persists infrastructure-as-code state records. Supports three backends: `memory` (default, ephemeral), `filesystem` (local JSON files), and `spaces` (DigitalOcean Spaces / any S3-compatible store). + +**Configuration (spaces backend):** + +| Key | Type | Required | Description | +|-----|------|----------|-------------| +| `backend` | string | no | `memory`, `filesystem`, or `spaces` (default: `memory`). | +| `region` | string | no | DO region (e.g. `nyc3`). Constructs endpoint `https://.digitaloceanspaces.com`. | +| `bucket` | string | yes (spaces) | Spaces bucket name. | +| `prefix` | string | no | Object key prefix (default: `iac-state/`). | +| `accessKey` | string | no | Spaces access key. Falls back to `DO_SPACES_ACCESS_KEY` env var. | +| `secretKey` | string | no | Spaces secret key. Falls back to `DO_SPACES_SECRET_KEY` env var. | +| `endpoint` | string | no | Custom S3-compatible endpoint (overrides region-based URL). | + +**Example:** + +```yaml +modules: + - name: iac-state + type: iac.state + config: + backend: spaces + region: nyc3 + bucket: my-iac-state + prefix: "prod/" +``` + +--- + ### `observability.otel` Initializes an OpenTelemetry distributed tracing provider that exports spans via OTLP/HTTP to a collector. Sets the global OTel tracer provider so all instrumented code in the process is covered. diff --git a/module/iac_module.go b/module/iac_module.go index 482d23c1..4df5f955 100644 --- a/module/iac_module.go +++ b/module/iac_module.go @@ -8,7 +8,8 @@ import ( ) // IaCModule registers an IaCStateStore in the service registry. -// Supported backends: "memory" (default) and "filesystem". +// Supported backends: "memory" (default), "filesystem", and "spaces" +// (DigitalOcean Spaces / S3-compatible). // // Config example: // @@ -49,8 +50,23 @@ func (m *IaCModule) Init(app modular.Application) error { dir = "/var/lib/workflow/iac-state" } m.store = NewFSIaCStateStore(dir) + case "spaces": + region, _ := m.config["region"].(string) + bucket, _ := m.config["bucket"].(string) + prefix, _ := m.config["prefix"].(string) + accessKey, _ := m.config["accessKey"].(string) + secretKey, _ := m.config["secretKey"].(string) + endpoint, _ := m.config["endpoint"].(string) + if bucket == "" { + return fmt.Errorf("iac.state %q: spaces backend requires 'bucket' config", m.name) + } + store, err := NewSpacesIaCStateStore(region, bucket, prefix, accessKey, secretKey, endpoint) + if err != nil { + return fmt.Errorf("iac.state %q: spaces backend: %w", m.name, err) + } + m.store = store default: - return fmt.Errorf("iac.state %q: unsupported backend %q (use 'memory' or 'filesystem')", m.name, m.backend) + return fmt.Errorf("iac.state %q: unsupported backend %q (use 'memory', 'filesystem', or 'spaces')", m.name, m.backend) } return app.RegisterService(m.name, m.store) diff --git a/module/iac_state_spaces.go b/module/iac_state_spaces.go new file mode 100644 index 00000000..477cdcb2 --- /dev/null +++ b/module/iac_state_spaces.go @@ -0,0 +1,328 @@ +package module + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strings" + "sync" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" +) + +// SpacesS3Client abstracts the S3 API methods used by SpacesIaCStateStore, +// allowing a mock to be injected for testing. +type SpacesS3Client interface { + GetObject(ctx context.Context, input *s3.GetObjectInput, opts ...func(*s3.Options)) (*s3.GetObjectOutput, error) + PutObject(ctx context.Context, input *s3.PutObjectInput, opts ...func(*s3.Options)) (*s3.PutObjectOutput, error) + DeleteObject(ctx context.Context, input *s3.DeleteObjectInput, opts ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) + ListObjectsV2(ctx context.Context, input *s3.ListObjectsV2Input, opts ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) + HeadObject(ctx context.Context, input *s3.HeadObjectInput, opts ...func(*s3.Options)) (*s3.HeadObjectOutput, error) +} + +// SpacesIaCStateStore persists IaC state as JSON objects in a DigitalOcean Spaces +// bucket (or any S3-compatible store). Lock objects are used for advisory locking. +type SpacesIaCStateStore struct { + client SpacesS3Client + bucket string + prefix string + mu sync.Mutex +} + +// NewSpacesIaCStateStore creates a Spaces/S3-compatible state store. +// +// Parameters: +// - region: DO region (e.g. "nyc3"); used to construct the endpoint +// https://.digitaloceanspaces.com unless endpoint is set. +// - bucket: Spaces bucket name (required). +// - prefix: optional key prefix (default "iac-state/"). +// - accessKey: Spaces access key; falls back to DO_SPACES_ACCESS_KEY env var. +// - secretKey: Spaces secret key; falls back to DO_SPACES_SECRET_KEY env var. +// - endpoint: optional custom endpoint override. +func NewSpacesIaCStateStore(region, bucket, prefix, accessKey, secretKey, endpoint string) (*SpacesIaCStateStore, error) { + if bucket == "" { + return nil, fmt.Errorf("iac spaces state: bucket must not be empty") + } + if prefix == "" { + prefix = "iac-state/" + } + if accessKey == "" { + accessKey = os.Getenv("DO_SPACES_ACCESS_KEY") + } + if secretKey == "" { + secretKey = os.Getenv("DO_SPACES_SECRET_KEY") + } + if endpoint == "" && region != "" { + endpoint = fmt.Sprintf("https://%s.digitaloceanspaces.com", region) + } + if endpoint == "" { + return nil, fmt.Errorf("iac spaces state: either region or endpoint must be set") + } + + cfg, err := awsconfig.LoadDefaultConfig(context.Background(), + awsconfig.WithRegion(regionOrDefault(region)), + awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")), + ) + if err != nil { + return nil, fmt.Errorf("iac spaces state: load config: %w", err) + } + + client := s3.NewFromConfig(cfg, func(o *s3.Options) { + o.BaseEndpoint = &endpoint + o.UsePathStyle = true + }) + + return &SpacesIaCStateStore{ + client: client, + bucket: bucket, + prefix: prefix, + }, nil +} + +// NewSpacesIaCStateStoreWithClient creates a store with an injected client (for testing). +func NewSpacesIaCStateStoreWithClient(client SpacesS3Client, bucket, prefix string) *SpacesIaCStateStore { + if prefix == "" { + prefix = "iac-state/" + } + return &SpacesIaCStateStore{ + client: client, + bucket: bucket, + prefix: prefix, + } +} + +func regionOrDefault(region string) string { + if region == "" { + return "us-east-1" + } + return region +} + +// stateKey returns the S3 key for a resource's state JSON. +func (s *SpacesIaCStateStore) stateKey(resourceID string) string { + return s.prefix + sanitizeID(resourceID) + ".json" +} + +// lockKey returns the S3 key for a resource's lock object. +func (s *SpacesIaCStateStore) lockKey(resourceID string) string { + return s.prefix + sanitizeID(resourceID) + ".lock" +} + +// GetState retrieves a state record by resource ID. Returns nil, nil when not found. +func (s *SpacesIaCStateStore) GetState(resourceID string) (*IaCState, error) { + key := s.stateKey(resourceID) + out, err := s.client.GetObject(context.Background(), &s3.GetObjectInput{ + Bucket: &s.bucket, + Key: &key, + }) + if err != nil { + if isNotFoundErr(err) { + return nil, nil + } + return nil, fmt.Errorf("iac spaces state: GetState %q: %w", resourceID, err) + } + defer out.Body.Close() + + data, err := io.ReadAll(out.Body) + if err != nil { + return nil, fmt.Errorf("iac spaces state: GetState %q: read body: %w", resourceID, err) + } + + var st IaCState + if err := json.Unmarshal(data, &st); err != nil { + return nil, fmt.Errorf("iac spaces state: GetState %q: unmarshal: %w", resourceID, err) + } + return &st, nil +} + +// SaveState writes the state record as a JSON object to Spaces. +func (s *SpacesIaCStateStore) SaveState(state *IaCState) error { + if state == nil { + return fmt.Errorf("iac spaces state: SaveState: state must not be nil") + } + if state.ResourceID == "" { + return fmt.Errorf("iac spaces state: SaveState: resource_id must not be empty") + } + + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return fmt.Errorf("iac spaces state: SaveState %q: marshal: %w", state.ResourceID, err) + } + + key := s.stateKey(state.ResourceID) + contentType := "application/json" + _, err = s.client.PutObject(context.Background(), &s3.PutObjectInput{ + Bucket: &s.bucket, + Key: &key, + Body: bytes.NewReader(data), + ContentType: &contentType, + }) + if err != nil { + return fmt.Errorf("iac spaces state: SaveState %q: put: %w", state.ResourceID, err) + } + return nil +} + +// ListStates lists all state objects under the prefix and returns those matching filter. +// Supported filter keys: "resource_type", "provider", "status". +func (s *SpacesIaCStateStore) ListStates(filter map[string]string) ([]*IaCState, error) { + var results []*IaCState + var continuationToken *string + + for { + out, err := s.client.ListObjectsV2(context.Background(), &s3.ListObjectsV2Input{ + Bucket: &s.bucket, + Prefix: &s.prefix, + ContinuationToken: continuationToken, + }) + if err != nil { + return nil, fmt.Errorf("iac spaces state: ListStates: %w", err) + } + + for _, obj := range out.Contents { + key := aws.ToString(obj.Key) + // Skip lock files and non-JSON objects. + if strings.HasSuffix(key, ".lock") || !strings.HasSuffix(key, ".json") { + continue + } + + getOut, err := s.client.GetObject(context.Background(), &s3.GetObjectInput{ + Bucket: &s.bucket, + Key: obj.Key, + }) + if err != nil { + continue // skip unreadable objects + } + data, err := io.ReadAll(getOut.Body) + getOut.Body.Close() + if err != nil { + continue + } + + var st IaCState + if err := json.Unmarshal(data, &st); err != nil { + continue + } + if matchesFilter(&st, filter) { + results = append(results, &st) + } + } + + if !aws.ToBool(out.IsTruncated) { + break + } + continuationToken = out.NextContinuationToken + } + + return results, nil +} + +// DeleteState removes the state object for resourceID. +func (s *SpacesIaCStateStore) DeleteState(resourceID string) error { + // Verify existence first to return a meaningful error. + key := s.stateKey(resourceID) + _, err := s.client.HeadObject(context.Background(), &s3.HeadObjectInput{ + Bucket: &s.bucket, + Key: &key, + }) + if err != nil { + if isNotFoundErr(err) { + return fmt.Errorf("iac spaces state: DeleteState %q: not found", resourceID) + } + return fmt.Errorf("iac spaces state: DeleteState %q: head: %w", resourceID, err) + } + + _, err = s.client.DeleteObject(context.Background(), &s3.DeleteObjectInput{ + Bucket: &s.bucket, + Key: &key, + }) + if err != nil { + return fmt.Errorf("iac spaces state: DeleteState %q: %w", resourceID, err) + } + return nil +} + +// Lock creates a lock object for resourceID. Fails if the lock already exists. +func (s *SpacesIaCStateStore) Lock(resourceID string) error { + s.mu.Lock() + defer s.mu.Unlock() + + key := s.lockKey(resourceID) + + // Check if lock exists. + _, err := s.client.HeadObject(context.Background(), &s3.HeadObjectInput{ + Bucket: &s.bucket, + Key: &key, + }) + if err == nil { + return fmt.Errorf("iac spaces state: Lock %q: resource is already locked", resourceID) + } + if !isNotFoundErr(err) { + return fmt.Errorf("iac spaces state: Lock %q: head: %w", resourceID, err) + } + + // Create lock object with a timestamp. + body := []byte(time.Now().UTC().Format(time.RFC3339)) + _, err = s.client.PutObject(context.Background(), &s3.PutObjectInput{ + Bucket: &s.bucket, + Key: &key, + Body: bytes.NewReader(body), + }) + if err != nil { + return fmt.Errorf("iac spaces state: Lock %q: put: %w", resourceID, err) + } + return nil +} + +// Unlock removes the lock object for resourceID. +func (s *SpacesIaCStateStore) Unlock(resourceID string) error { + s.mu.Lock() + defer s.mu.Unlock() + + key := s.lockKey(resourceID) + + // Verify lock exists. + _, err := s.client.HeadObject(context.Background(), &s3.HeadObjectInput{ + Bucket: &s.bucket, + Key: &key, + }) + if err != nil { + if isNotFoundErr(err) { + return fmt.Errorf("iac spaces state: Unlock %q: not locked", resourceID) + } + return fmt.Errorf("iac spaces state: Unlock %q: head: %w", resourceID, err) + } + + _, err = s.client.DeleteObject(context.Background(), &s3.DeleteObjectInput{ + Bucket: &s.bucket, + Key: &key, + }) + if err != nil { + return fmt.Errorf("iac spaces state: Unlock %q: %w", resourceID, err) + } + return nil +} + +// isNotFoundErr checks whether an S3 error indicates the key was not found. +func isNotFoundErr(err error) bool { + var nsk *types.NoSuchKey + if errors.As(err, &nsk) { + return true + } + // HeadObject returns a generic "NotFound" status, not NoSuchKey. + var nf *types.NotFound + if errors.As(err, &nf) { + return true + } + // Some S3-compatible stores return a plain "not found" in the message. + return strings.Contains(err.Error(), "NotFound") || strings.Contains(err.Error(), "NoSuchKey") +} diff --git a/module/iac_state_spaces_test.go b/module/iac_state_spaces_test.go new file mode 100644 index 00000000..a6f6c090 --- /dev/null +++ b/module/iac_state_spaces_test.go @@ -0,0 +1,449 @@ +package module_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "strings" + "sync" + "testing" + + "github.com/GoCodeAlone/workflow/module" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" +) + +// mockS3Client implements spacesS3Client for testing. +type mockS3Client struct { + mu sync.Mutex + objects map[string][]byte // key -> body +} + +func newMockS3Client() *mockS3Client { + return &mockS3Client{objects: make(map[string][]byte)} +} + +func (m *mockS3Client) GetObject(_ context.Context, input *s3.GetObjectInput, _ ...func(*s3.Options)) (*s3.GetObjectOutput, error) { + m.mu.Lock() + defer m.mu.Unlock() + key := aws.ToString(input.Key) + data, ok := m.objects[key] + if !ok { + return nil, &types.NoSuchKey{Message: aws.String("NoSuchKey: " + key)} + } + return &s3.GetObjectOutput{ + Body: io.NopCloser(bytes.NewReader(data)), + }, nil +} + +func (m *mockS3Client) PutObject(_ context.Context, input *s3.PutObjectInput, _ ...func(*s3.Options)) (*s3.PutObjectOutput, error) { + m.mu.Lock() + defer m.mu.Unlock() + key := aws.ToString(input.Key) + data, err := io.ReadAll(input.Body) + if err != nil { + return nil, err + } + m.objects[key] = data + return &s3.PutObjectOutput{}, nil +} + +func (m *mockS3Client) DeleteObject(_ context.Context, input *s3.DeleteObjectInput, _ ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) { + m.mu.Lock() + defer m.mu.Unlock() + key := aws.ToString(input.Key) + delete(m.objects, key) + return &s3.DeleteObjectOutput{}, nil +} + +func (m *mockS3Client) ListObjectsV2(_ context.Context, input *s3.ListObjectsV2Input, _ ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) { + m.mu.Lock() + defer m.mu.Unlock() + prefix := aws.ToString(input.Prefix) + var contents []types.Object + for key := range m.objects { + if strings.HasPrefix(key, prefix) { + contents = append(contents, types.Object{Key: aws.String(key)}) + } + } + return &s3.ListObjectsV2Output{ + Contents: contents, + IsTruncated: aws.Bool(false), + }, nil +} + +func (m *mockS3Client) HeadObject(_ context.Context, input *s3.HeadObjectInput, _ ...func(*s3.Options)) (*s3.HeadObjectOutput, error) { + m.mu.Lock() + defer m.mu.Unlock() + key := aws.ToString(input.Key) + if _, ok := m.objects[key]; !ok { + return nil, &types.NotFound{Message: aws.String("NotFound: " + key)} + } + return &s3.HeadObjectOutput{}, nil +} + +func newTestSpacesStore(client *mockS3Client) *module.SpacesIaCStateStore { + return module.NewSpacesIaCStateStoreWithClient(client, "test-bucket", "iac-state/") +} + +func TestSpacesIaCStateStore_GetState_NotFound(t *testing.T) { + store := newTestSpacesStore(newMockS3Client()) + + st, err := store.GetState("nonexistent") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if st != nil { + t.Fatalf("expected nil state, got %+v", st) + } +} + +func TestSpacesIaCStateStore_SaveAndGetState(t *testing.T) { + store := newTestSpacesStore(newMockS3Client()) + + state := &module.IaCState{ + ResourceID: "cluster-1", + ResourceType: "kubernetes", + Provider: "digitalocean", + Status: "active", + Outputs: map[string]any{"endpoint": "https://k8s.example.com"}, + Config: map[string]any{"region": "nyc3"}, + CreatedAt: "2026-03-09T00:00:00Z", + UpdatedAt: "2026-03-09T00:00:00Z", + } + + if err := store.SaveState(state); err != nil { + t.Fatalf("SaveState: %v", err) + } + + got, err := store.GetState("cluster-1") + if err != nil { + t.Fatalf("GetState: %v", err) + } + if got == nil { + t.Fatal("expected state, got nil") + } + if got.ResourceID != "cluster-1" { + t.Errorf("ResourceID = %q, want %q", got.ResourceID, "cluster-1") + } + if got.Provider != "digitalocean" { + t.Errorf("Provider = %q, want %q", got.Provider, "digitalocean") + } + if got.Status != "active" { + t.Errorf("Status = %q, want %q", got.Status, "active") + } +} + +func TestSpacesIaCStateStore_SaveState_Nil(t *testing.T) { + store := newTestSpacesStore(newMockS3Client()) + + err := store.SaveState(nil) + if err == nil { + t.Fatal("expected error for nil state") + } +} + +func TestSpacesIaCStateStore_SaveState_EmptyID(t *testing.T) { + store := newTestSpacesStore(newMockS3Client()) + + err := store.SaveState(&module.IaCState{}) + if err == nil { + t.Fatal("expected error for empty resource_id") + } +} + +func TestSpacesIaCStateStore_ListStates(t *testing.T) { + client := newMockS3Client() + store := newTestSpacesStore(client) + + states := []*module.IaCState{ + {ResourceID: "r1", ResourceType: "kubernetes", Provider: "aws", Status: "active"}, + {ResourceID: "r2", ResourceType: "database", Provider: "digitalocean", Status: "active"}, + {ResourceID: "r3", ResourceType: "kubernetes", Provider: "aws", Status: "destroyed"}, + } + for _, st := range states { + if err := store.SaveState(st); err != nil { + t.Fatalf("SaveState %q: %v", st.ResourceID, err) + } + } + + // No filter — returns all. + all, err := store.ListStates(nil) + if err != nil { + t.Fatalf("ListStates(nil): %v", err) + } + if len(all) != 3 { + t.Errorf("ListStates(nil) = %d items, want 3", len(all)) + } + + // Filter by provider. + filtered, err := store.ListStates(map[string]string{"provider": "aws"}) + if err != nil { + t.Fatalf("ListStates(provider=aws): %v", err) + } + if len(filtered) != 2 { + t.Errorf("ListStates(provider=aws) = %d items, want 2", len(filtered)) + } + + // Filter by status. + active, err := store.ListStates(map[string]string{"status": "active"}) + if err != nil { + t.Fatalf("ListStates(status=active): %v", err) + } + if len(active) != 2 { + t.Errorf("ListStates(status=active) = %d items, want 2", len(active)) + } +} + +func TestSpacesIaCStateStore_ListStates_SkipsLockFiles(t *testing.T) { + client := newMockS3Client() + store := newTestSpacesStore(client) + + // Save a state and lock it — lock file should be skipped in list. + if err := store.SaveState(&module.IaCState{ResourceID: "r1", Status: "active"}); err != nil { + t.Fatalf("SaveState: %v", err) + } + if err := store.Lock("r1"); err != nil { + t.Fatalf("Lock: %v", err) + } + + results, err := store.ListStates(nil) + if err != nil { + t.Fatalf("ListStates: %v", err) + } + if len(results) != 1 { + t.Errorf("ListStates returned %d items (expected 1, lock file should be excluded)", len(results)) + } +} + +func TestSpacesIaCStateStore_DeleteState(t *testing.T) { + store := newTestSpacesStore(newMockS3Client()) + + if err := store.SaveState(&module.IaCState{ResourceID: "del-me", Status: "active"}); err != nil { + t.Fatalf("SaveState: %v", err) + } + + if err := store.DeleteState("del-me"); err != nil { + t.Fatalf("DeleteState: %v", err) + } + + // Should be gone. + st, err := store.GetState("del-me") + if err != nil { + t.Fatalf("GetState after delete: %v", err) + } + if st != nil { + t.Fatal("expected nil after delete") + } +} + +func TestSpacesIaCStateStore_DeleteState_NotFound(t *testing.T) { + store := newTestSpacesStore(newMockS3Client()) + + err := store.DeleteState("nonexistent") + if err == nil { + t.Fatal("expected error deleting nonexistent state") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("error = %q, expected 'not found'", err) + } +} + +func TestSpacesIaCStateStore_LockUnlock(t *testing.T) { + store := newTestSpacesStore(newMockS3Client()) + + // Lock should succeed. + if err := store.Lock("res-1"); err != nil { + t.Fatalf("Lock: %v", err) + } + + // Double-lock should fail. + if err := store.Lock("res-1"); err == nil { + t.Fatal("expected error on double lock") + } + + // Unlock should succeed. + if err := store.Unlock("res-1"); err != nil { + t.Fatalf("Unlock: %v", err) + } + + // Re-lock after unlock should succeed. + if err := store.Lock("res-1"); err != nil { + t.Fatalf("Lock after unlock: %v", err) + } +} + +func TestSpacesIaCStateStore_Unlock_NotLocked(t *testing.T) { + store := newTestSpacesStore(newMockS3Client()) + + err := store.Unlock("not-locked") + if err == nil { + t.Fatal("expected error unlocking a resource that is not locked") + } + if !strings.Contains(err.Error(), "not locked") { + t.Errorf("error = %q, expected 'not locked'", err) + } +} + +func TestSpacesIaCStateStore_SaveState_Overwrite(t *testing.T) { + store := newTestSpacesStore(newMockS3Client()) + + original := &module.IaCState{ResourceID: "r1", Status: "planned"} + if err := store.SaveState(original); err != nil { + t.Fatalf("SaveState (original): %v", err) + } + + updated := &module.IaCState{ResourceID: "r1", Status: "active"} + if err := store.SaveState(updated); err != nil { + t.Fatalf("SaveState (updated): %v", err) + } + + got, err := store.GetState("r1") + if err != nil { + t.Fatalf("GetState: %v", err) + } + if got.Status != "active" { + t.Errorf("Status = %q, want %q (overwrite failed)", got.Status, "active") + } +} + +func TestSpacesIaCStateStore_SanitizesResourceID(t *testing.T) { + client := newMockS3Client() + store := newTestSpacesStore(client) + + state := &module.IaCState{ResourceID: "ns/cluster\\1", Status: "active"} + if err := store.SaveState(state); err != nil { + t.Fatalf("SaveState: %v", err) + } + + // Verify the key was sanitized. + client.mu.Lock() + _, exists := client.objects["iac-state/ns_cluster_1.json"] + client.mu.Unlock() + if !exists { + t.Error("expected sanitized key 'iac-state/ns_cluster_1.json' in mock objects") + } + + // Retrieve by original ID. + got, err := store.GetState("ns/cluster\\1") + if err != nil { + t.Fatalf("GetState: %v", err) + } + if got == nil { + t.Fatal("expected state, got nil") + } +} + +// TestSpacesIaCStateStore_GetState_BadJSON verifies graceful handling of corrupt data. +func TestSpacesIaCStateStore_GetState_BadJSON(t *testing.T) { + client := newMockS3Client() + store := newTestSpacesStore(client) + + // Manually inject bad JSON. + client.mu.Lock() + client.objects["iac-state/bad.json"] = []byte("{invalid json") + client.mu.Unlock() + + _, err := store.GetState("bad") + if err == nil { + t.Fatal("expected unmarshal error for bad JSON") + } + if !strings.Contains(err.Error(), "unmarshal") { + t.Errorf("error = %q, expected 'unmarshal' substring", err) + } +} + +// Ensure the mock properly serializes JSON round-trip. +func TestSpacesIaCStateStore_JSONRoundTrip(t *testing.T) { + store := newTestSpacesStore(newMockS3Client()) + + state := &module.IaCState{ + ResourceID: "rt-1", + ResourceType: "ecs", + Provider: "aws", + Status: "provisioning", + Outputs: map[string]any{"arn": "arn:aws:ecs:us-east-1:123:cluster/test"}, + Config: map[string]any{"cpu": float64(256), "memory": float64(512)}, + CreatedAt: "2026-01-01T00:00:00Z", + UpdatedAt: "2026-03-09T12:00:00Z", + Error: "timeout waiting for stabilization", + } + + if err := store.SaveState(state); err != nil { + t.Fatalf("SaveState: %v", err) + } + + got, err := store.GetState("rt-1") + if err != nil { + t.Fatalf("GetState: %v", err) + } + + // Compare via JSON to handle map ordering. + wantJSON, _ := json.Marshal(state) + gotJSON, _ := json.Marshal(got) + if string(wantJSON) != string(gotJSON) { + t.Errorf("round-trip mismatch:\n want: %s\n got: %s", wantJSON, gotJSON) + } +} + +// errS3Client is a mock that returns errors for all operations. +type errS3Client struct{} + +func (e *errS3Client) GetObject(_ context.Context, _ *s3.GetObjectInput, _ ...func(*s3.Options)) (*s3.GetObjectOutput, error) { + return nil, fmt.Errorf("simulated GetObject failure") +} +func (e *errS3Client) PutObject(_ context.Context, _ *s3.PutObjectInput, _ ...func(*s3.Options)) (*s3.PutObjectOutput, error) { + return nil, fmt.Errorf("simulated PutObject failure") +} +func (e *errS3Client) DeleteObject(_ context.Context, _ *s3.DeleteObjectInput, _ ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) { + return nil, fmt.Errorf("simulated DeleteObject failure") +} +func (e *errS3Client) ListObjectsV2(_ context.Context, _ *s3.ListObjectsV2Input, _ ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) { + return nil, fmt.Errorf("simulated ListObjectsV2 failure") +} +func (e *errS3Client) HeadObject(_ context.Context, _ *s3.HeadObjectInput, _ ...func(*s3.Options)) (*s3.HeadObjectOutput, error) { + return nil, fmt.Errorf("simulated HeadObject failure") +} + +func TestSpacesIaCStateStore_ErrorPropagation(t *testing.T) { + store := module.NewSpacesIaCStateStoreWithClient(&errS3Client{}, "test-bucket", "iac-state/") + + // GetState error. + _, err := store.GetState("x") + if err == nil || !strings.Contains(err.Error(), "simulated") { + t.Errorf("GetState error = %v, want simulated error", err) + } + + // SaveState error. + err = store.SaveState(&module.IaCState{ResourceID: "x"}) + if err == nil || !strings.Contains(err.Error(), "simulated") { + t.Errorf("SaveState error = %v, want simulated error", err) + } + + // ListStates error. + _, err = store.ListStates(nil) + if err == nil || !strings.Contains(err.Error(), "simulated") { + t.Errorf("ListStates error = %v, want simulated error", err) + } + + // DeleteState error (HeadObject fails). + err = store.DeleteState("x") + if err == nil || !strings.Contains(err.Error(), "simulated") { + t.Errorf("DeleteState error = %v, want simulated error", err) + } + + // Lock error (HeadObject fails with non-NotFound). + err = store.Lock("x") + if err == nil || !strings.Contains(err.Error(), "simulated") { + t.Errorf("Lock error = %v, want simulated error", err) + } + + // Unlock error (HeadObject fails with non-NotFound). + err = store.Unlock("x") + if err == nil || !strings.Contains(err.Error(), "simulated") { + t.Errorf("Unlock error = %v, want simulated error", err) + } +}