Skip to content

feat: IaC support — wfctl infra commands, DO database module, PlatformProvider adapters#281

Merged
intel352 merged 10 commits intomainfrom
feat/iac-digitalocean
Mar 9, 2026
Merged

feat: IaC support — wfctl infra commands, DO database module, PlatformProvider adapters#281
intel352 merged 10 commits intomainfrom
feat/iac-digitalocean

Conversation

@intel352
Copy link
Contributor

@intel352 intel352 commented Mar 9, 2026

Summary

  • Add wfctl infra plan/apply/status/drift/destroy CLI commands for IaC lifecycle management
  • Add platform.do_database module for DigitalOcean Managed Databases (mock + real backends)
  • Add PlatformProvider adapters for existing DO modules (App Platform, Networking, DNS) enabling generic IaC step usage
  • Register infra command in wfctl.yaml CLI definition
  • Update DOCUMENTATION.md and WFCTL.md with new module and command reference

Design

See: docs/plans/2026-03-09-iac-digitalocean-design.md (in buymywishlist repo)

Changes

Commit Description
c467405 PlatformProvider adapter for DO App Platform
3d0eb6a PlatformProvider adapter for DO Networking
41ab522 PlatformProvider adapter for DO DNS
1bb9da7 platform.do_database module (mock + real backends)
a8cc7a1 wfctl infra command (plan/apply/status/drift/destroy)
3038438 wfctl.yaml CLI definition update
e5cb0e1 Documentation updates

Test Plan

  • All existing tests pass (go test ./...)
  • go vet ./... clean
  • go build ./cmd/wfctl/ and ./cmd/server/ compile
  • New adapter tests (18 tests across 3 modules)
  • New do_database tests
  • Scenario 51 validates full IaC lifecycle with mock providers (13/13 pass)

🤖 Generated with Claude Code

intel352 and others added 7 commits March 9, 2026 00:31
DOAppPlatformAdapter wraps PlatformDOApp to implement the PlatformProvider
interface (Plan/Apply/Status/Destroy), registered as "<name>.iac" service.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
DONetworkingPlatformAdapter wraps PlatformDONetworking to implement
PlatformProvider (Plan/Apply/Status/Destroy), registered as "<name>.iac"
service. Converts DONetworkPlan changes to PlatformAction entries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
DODNSPlatformAdapter wraps PlatformDODNS to implement PlatformProvider
(Plan/Apply/Status/Destroy), registered as "<name>.iac" service.
Converts DODNSPlan changes to PlatformAction entries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds first-class infrastructure-as-code (IaC) workflow support to the workflow engine by introducing wfctl infra lifecycle commands and expanding the platform plugin with DigitalOcean-managed infrastructure modules/adapters to drive generic step.iac_* pipelines.

Changes:

  • Add wfctl infra plan/apply/status/drift/destroy command wired via wfctl.yaml and cmd/wfctl/main.go.
  • Add platform.do_database module (mock + real godo backend) and register it in the platform plugin (factories + schema).
  • Add PlatformProvider adapters for existing DigitalOcean modules (app, DNS, networking) by registering *.iac services plus accompanying adapter tests.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
plugins/platform/plugin.go Registers platform.do_database module type, factory, and UI schema.
module/platform_do_networking.go Registers *.iac adapter service and implements PlatformProvider adapter for DO networking.
module/platform_do_networking_test.go Adds tests validating the DO networking PlatformProvider adapter behavior.
module/platform_do_dns.go Registers *.iac adapter service and implements PlatformProvider adapter for DO DNS.
module/platform_do_dns_test.go Adds tests validating the DO DNS PlatformProvider adapter behavior.
module/platform_do_app.go Registers *.iac adapter service and implements PlatformProvider adapter for DO App Platform.
module/platform_do_app_test.go Adds tests validating the DO App Platform PlatformProvider adapter behavior.
module/platform_do_database.go Introduces a new DO managed database platform module (mock + real backend).
module/platform_do_database_test.go Adds basic mock-backend test coverage for the new DB module.
cmd/wfctl/infra.go Implements wfctl infra subcommands, config discovery, and pipeline delegation.
cmd/wfctl/main.go Registers the new infra command dispatcher.
cmd/wfctl/wfctl.yaml Adds the infra CLI workflow + pipeline mapping to step.cli_invoke.
docs/WFCTL.md Documents the new wfctl infra command and its flags/examples.
DOCUMENTATION.md Documents newly added DigitalOcean platform module types including platform.do_database.

Comment on lines +148 to +158
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
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

Apply() always calls backend.create(...). For the real DigitalOcean backend this hits godo.Databases.Create, so repeated applies will create a new managed DB cluster each time instead of being idempotent (and may leak resources/cost). Consider making Apply() conditional on m.state.ID (no-op if already online) or implementing an update/resize path when ID is present, and ensure the real backend uses the corresponding godo Update/Resize APIs rather than Create.

Copilot uses AI. Check for mistakes.
if err != nil {
return fmt.Errorf("delete database: %w", err)
}
m.state.Status = "deleted"
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

doDatabaseRealBackend.destroy() sets m.state.Status = "deleted" but does not clear m.state.ID. After a successful delete, subsequent Status() calls will try Databases.Get with the now-deleted ID and likely error. Clearing the ID (and any connection fields) after delete would make destroy idempotent and keep status consistent.

Suggested change
m.state.Status = "deleted"
m.state.Status = "deleted"
m.state.ID = ""
m.state.Host = ""
m.state.Port = 0
m.state.DatabaseName = ""
m.state.User = ""
m.state.Password = ""
m.state.URI = ""

Copilot uses AI. Check for mistakes.
Comment on lines +242 to +261
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
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

DODatabaseState includes connection credentials (Password, URI) and doDatabaseFromGodo() populates them from the API response. The IaC apply step persists result.State into the IaC state store outputs (filesystem backend writes JSON), which would store these secrets in plaintext on disk. Recommend redacting/omitting credential fields from the persisted state (e.g., json:"-"), or splitting credentials into a separate secret output mechanism rather than IaC state.

Copilot uses AI. Check for mistakes.
Comment on lines +138 to +139
detail = fmt.Sprintf("Update database %q (%s → %s, %d nodes)",
m.state.Name, m.state.Size, m.state.Size, m.state.NumNodes)
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

In Plan(), the "update" detail message prints the same value for both sides of the arrow (%s → %s uses m.state.Size twice), so the plan output is misleading. Either track previous/applied values to show a real diff, or simplify the message to avoid implying a change when there isn't one.

Suggested change
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)

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 052eebd — fixed the misleading format string to show size: %s, %d nodes instead of the duplicate %s → %s.

Comment on lines +151 to +160
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)
}
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

runInfraPlan prints all platform.* module config key/values (only skipping account and provider). Platform module configs often contain secrets (e.g., env vars, API keys, DNS tokens), so this can leak sensitive data to the terminal and logs/CI output. Consider redacting common secret keys (token, secret, password, key, envs, etc.) or only printing a safe summary (module name/type) unless a --verbose flag is provided.

Copilot uses AI. Check for mistakes.
Comment on lines +12 to +49
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 <action> [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 <file> Config file (default: infra.yaml or config/infra.yaml)
--auto-approve Skip confirmation prompt (apply/destroy only)
`)
return fmt.Errorf("missing action")
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

infraUsage() always returns fmt.Errorf("missing action"), even when the user provided an unsupported/invalid action (the default case). That makes CLI error output misleading. Consider returning a different error for invalid actions (e.g., "unknown action: ...") and reserving "missing action" for the no-args case (or returning flag.ErrHelp).

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 052eebd — improved error message to say "missing or unknown action".

Comment on lines +5 to +27
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{},
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

This test constructs PlatformDODatabase by hand and bypasses Init(), so it doesn't exercise config parsing/defaulting or provider selection logic (which is where many bugs tend to hide). Consider adding an Init()-based test using NewPlatformDODatabase(...).Init(app) (similar to other platform DO module tests) and asserting defaults/required config behavior.

Copilot uses AI. Check for mistakes.
@github-actions
Copy link

github-actions bot commented Mar 9, 2026

⏱ Benchmark Results

No significant performance regressions detected.

benchstat comparison (baseline → PR)
## benchstat: baseline → PR
baseline-bench.txt:243: parsing iteration count: invalid syntax
baseline-bench.txt:314515: parsing iteration count: invalid syntax
baseline-bench.txt:609230: parsing iteration count: invalid syntax
baseline-bench.txt:910599: parsing iteration count: invalid syntax
baseline-bench.txt:1217284: parsing iteration count: invalid syntax
baseline-bench.txt:1516283: parsing iteration count: invalid syntax
benchmark-results.txt:243: parsing iteration count: invalid syntax
benchmark-results.txt:314530: parsing iteration count: invalid syntax
benchmark-results.txt:615596: parsing iteration count: invalid syntax
benchmark-results.txt:904158: parsing iteration count: invalid syntax
benchmark-results.txt:1185607: parsing iteration count: invalid syntax
benchmark-results.txt:1479833: parsing iteration count: invalid syntax
goos: linux
goarch: amd64
pkg: github.com/GoCodeAlone/workflow/dynamic
cpu: AMD EPYC 7763 64-Core Processor                
                            │ baseline-bench.txt │       benchmark-results.txt        │
                            │       sec/op       │    sec/op     vs base              │
InterpreterCreation-4               6.321m ± 59%   6.529m ± 55%       ~ (p=0.818 n=6)
ComponentLoad-4                     3.434m ±  0%   3.371m ±  5%       ~ (p=0.065 n=6)
ComponentExecute-4                  1.999µ ±  2%   1.970µ ±  3%       ~ (p=0.058 n=6)
PoolContention/workers-1-4          1.083µ ±  4%   1.069µ ±  5%       ~ (p=0.240 n=6)
PoolContention/workers-2-4          1.084µ ±  1%   1.072µ ±  1%  -1.11% (p=0.048 n=6)
PoolContention/workers-4-4          1.083µ ±  1%   1.073µ ±  1%       ~ (p=0.115 n=6)
PoolContention/workers-8-4          1.084µ ±  1%   1.081µ ±  1%       ~ (p=0.126 n=6)
PoolContention/workers-16-4         1.085µ ±  1%   1.091µ ±  2%       ~ (p=0.558 n=6)
ComponentLifecycle-4                3.453m ±  0%   3.413m ±  1%  -1.16% (p=0.002 n=6)
SourceValidation-4                  2.301µ ±  1%   2.264µ ±  1%  -1.61% (p=0.002 n=6)
RegistryConcurrent-4                766.7n ±  3%   786.1n ±  4%       ~ (p=0.180 n=6)
LoaderLoadFromString-4              3.467m ±  0%   3.432m ±  0%  -0.99% (p=0.002 n=6)
geomean                             18.25µ         18.19µ        -0.36%

                            │ baseline-bench.txt │        benchmark-results.txt         │
                            │        B/op        │     B/op      vs base                │
InterpreterCreation-4               1.944Mi ± 0%   1.944Mi ± 0%       ~ (p=0.974 n=6)
ComponentLoad-4                     2.097Mi ± 0%   2.097Mi ± 0%  -0.00% (p=0.043 n=6)
ComponentExecute-4                  1.203Ki ± 0%   1.203Ki ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-1-4          1.203Ki ± 0%   1.203Ki ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-2-4          1.203Ki ± 0%   1.203Ki ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-4-4          1.203Ki ± 0%   1.203Ki ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-8-4          1.203Ki ± 0%   1.203Ki ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-16-4         1.203Ki ± 0%   1.203Ki ± 0%       ~ (p=1.000 n=6) ¹
ComponentLifecycle-4                2.099Mi ± 0%   2.099Mi ± 0%       ~ (p=0.452 n=6)
SourceValidation-4                  1.984Ki ± 0%   1.984Ki ± 0%       ~ (p=1.000 n=6) ¹
RegistryConcurrent-4                1.133Ki ± 0%   1.133Ki ± 0%       ~ (p=1.000 n=6) ¹
LoaderLoadFromString-4              2.099Mi ± 0%   2.099Mi ± 0%       ~ (p=1.000 n=6)
geomean                             15.05Ki        15.05Ki       -0.00%
¹ all samples are equal

                            │ baseline-bench.txt │        benchmark-results.txt        │
                            │     allocs/op      │  allocs/op   vs base                │
InterpreterCreation-4                15.09k ± 0%   15.09k ± 0%       ~ (p=1.000 n=6)
ComponentLoad-4                      17.43k ± 0%   17.43k ± 0%       ~ (p=1.000 n=6)
ComponentExecute-4                    25.00 ± 0%    25.00 ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-1-4            25.00 ± 0%    25.00 ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-2-4            25.00 ± 0%    25.00 ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-4-4            25.00 ± 0%    25.00 ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-8-4            25.00 ± 0%    25.00 ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-16-4           25.00 ± 0%    25.00 ± 0%       ~ (p=1.000 n=6) ¹
ComponentLifecycle-4                 17.48k ± 0%   17.48k ± 0%       ~ (p=1.000 n=6) ¹
SourceValidation-4                    32.00 ± 0%    32.00 ± 0%       ~ (p=1.000 n=6) ¹
RegistryConcurrent-4                  2.000 ± 0%    2.000 ± 0%       ~ (p=1.000 n=6) ¹
LoaderLoadFromString-4               17.47k ± 0%   17.47k ± 0%       ~ (p=1.000 n=6) ¹
geomean                               181.2         181.2       +0.00%
¹ all samples are equal

pkg: github.com/GoCodeAlone/workflow/middleware
                                  │ baseline-bench.txt │       benchmark-results.txt       │
                                  │       sec/op       │   sec/op     vs base              │
CircuitBreakerDetection-4                  287.6n ± 7%   282.8n ± 3%  -1.67% (p=0.037 n=6)
CircuitBreakerExecution_Success-4          22.55n ± 0%   22.46n ± 1%       ~ (p=0.069 n=6)
CircuitBreakerExecution_Failure-4          66.31n ± 0%   65.21n ± 1%  -1.66% (p=0.002 n=6)
geomean                                    75.49n        74.54n       -1.26%

                                  │ baseline-bench.txt │       benchmark-results.txt        │
                                  │        B/op        │    B/op     vs base                │
CircuitBreakerDetection-4                 144.0 ± 0%     144.0 ± 0%       ~ (p=1.000 n=6) ¹
CircuitBreakerExecution_Success-4         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
CircuitBreakerExecution_Failure-4         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                              ²               +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

                                  │ baseline-bench.txt │       benchmark-results.txt        │
                                  │     allocs/op      │ allocs/op   vs base                │
CircuitBreakerDetection-4                 1.000 ± 0%     1.000 ± 0%       ~ (p=1.000 n=6) ¹
CircuitBreakerExecution_Success-4         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
CircuitBreakerExecution_Failure-4         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                              ²               +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

pkg: github.com/GoCodeAlone/workflow/module
                                 │ baseline-bench.txt │       benchmark-results.txt        │
                                 │       sec/op       │    sec/op     vs base              │
JQTransform_Simple-4                     928.0n ± 22%   871.9n ± 26%       ~ (p=0.394 n=6)
JQTransform_ObjectConstruction-4         1.448µ ±  0%   1.447µ ±  2%       ~ (p=0.786 n=6)
JQTransform_ArraySelect-4                3.329µ ±  1%   3.312µ ±  1%       ~ (p=0.082 n=6)
JQTransform_Complex-4                    38.16µ ±  1%   37.39µ ±  0%  -2.00% (p=0.002 n=6)
JQTransform_Throughput-4                 1.774µ ±  0%   1.761µ ±  1%  -0.73% (p=0.002 n=6)
SSEPublishDelivery-4                     65.92n ±  1%   63.18n ±  3%  -4.16% (p=0.002 n=6)
geomean                                  1.647µ         1.609µ        -2.27%

                                 │ baseline-bench.txt │        benchmark-results.txt         │
                                 │        B/op        │     B/op      vs base                │
JQTransform_Simple-4                   1.273Ki ± 0%     1.273Ki ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_ObjectConstruction-4       1.773Ki ± 0%     1.773Ki ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_ArraySelect-4              2.625Ki ± 0%     2.625Ki ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_Complex-4                  16.22Ki ± 0%     16.22Ki ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_Throughput-4               1.984Ki ± 0%     1.984Ki ± 0%       ~ (p=1.000 n=6) ¹
SSEPublishDelivery-4                     0.000 ± 0%       0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                             ²                 +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

                                 │ baseline-bench.txt │       benchmark-results.txt        │
                                 │     allocs/op      │ allocs/op   vs base                │
JQTransform_Simple-4                     10.00 ± 0%     10.00 ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_ObjectConstruction-4         15.00 ± 0%     15.00 ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_ArraySelect-4                30.00 ± 0%     30.00 ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_Complex-4                    324.0 ± 0%     324.0 ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_Throughput-4                 17.00 ± 0%     17.00 ± 0%       ~ (p=1.000 n=6) ¹
SSEPublishDelivery-4                     0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                             ²               +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

pkg: github.com/GoCodeAlone/workflow/schema
                                    │ baseline-bench.txt │       benchmark-results.txt       │
                                    │       sec/op       │   sec/op     vs base              │
SchemaValidation_Simple-4                    1.110µ ± 1%   1.122µ ± 3%       ~ (p=0.290 n=6)
SchemaValidation_AllFields-4                 1.683µ ± 7%   1.686µ ± 3%       ~ (p=0.937 n=6)
SchemaValidation_FormatValidation-4          1.600µ ± 4%   1.608µ ± 2%       ~ (p=0.937 n=6)
SchemaValidation_ManySchemas-4               1.834µ ± 3%   1.834µ ± 3%       ~ (p=0.844 n=6)
geomean                                      1.530µ        1.536µ       +0.43%

                                    │ baseline-bench.txt │       benchmark-results.txt        │
                                    │        B/op        │    B/op     vs base                │
SchemaValidation_Simple-4                   0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
SchemaValidation_AllFields-4                0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
SchemaValidation_FormatValidation-4         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
SchemaValidation_ManySchemas-4              0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                                ²               +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

                                    │ baseline-bench.txt │       benchmark-results.txt        │
                                    │     allocs/op      │ allocs/op   vs base                │
SchemaValidation_Simple-4                   0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
SchemaValidation_AllFields-4                0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
SchemaValidation_FormatValidation-4         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
SchemaValidation_ManySchemas-4              0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                                ²               +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

pkg: github.com/GoCodeAlone/workflow/store
                                   │ baseline-bench.txt │       benchmark-results.txt        │
                                   │       sec/op       │    sec/op     vs base              │
EventStoreAppend_InMemory-4                1.188µ ± 12%   1.100µ ± 64%       ~ (p=0.859 n=6)
EventStoreAppend_SQLite-4                  1.296m ±  3%   1.222m ±  3%  -5.75% (p=0.004 n=6)
GetTimeline_InMemory/events-10-4           14.41µ ±  4%   14.23µ ±  4%       ~ (p=0.699 n=6)
GetTimeline_InMemory/events-50-4           70.74µ ± 15%   75.33µ ± 16%       ~ (p=0.485 n=6)
GetTimeline_InMemory/events-100-4          127.7µ ±  1%   126.8µ ±  1%  -0.74% (p=0.026 n=6)
GetTimeline_InMemory/events-500-4          659.0µ ±  0%   654.2µ ±  1%  -0.73% (p=0.041 n=6)
GetTimeline_InMemory/events-1000-4         1.349m ±  2%   1.340m ±  1%  -0.67% (p=0.002 n=6)
GetTimeline_SQLite/events-10-4             109.1µ ±  0%   107.9µ ±  1%  -1.06% (p=0.004 n=6)
GetTimeline_SQLite/events-50-4             253.5µ ±  0%   249.2µ ±  1%  -1.70% (p=0.002 n=6)
GetTimeline_SQLite/events-100-4            430.0µ ±  1%   424.1µ ±  1%  -1.37% (p=0.002 n=6)
GetTimeline_SQLite/events-500-4            1.840m ±  1%   1.817m ±  1%  -1.27% (p=0.004 n=6)
GetTimeline_SQLite/events-1000-4           3.578m ±  0%   3.546m ±  1%  -0.89% (p=0.002 n=6)
geomean                                    221.5µ         218.4µ        -1.41%

                                   │ baseline-bench.txt │         benchmark-results.txt         │
                                   │        B/op        │     B/op       vs base                │
EventStoreAppend_InMemory-4                  807.5 ± 8%     788.0 ± 10%       ~ (p=0.942 n=6)
EventStoreAppend_SQLite-4                  1.986Ki ± 1%   1.985Ki ±  1%       ~ (p=0.597 n=6)
GetTimeline_InMemory/events-10-4           7.953Ki ± 0%   7.953Ki ±  0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-50-4           46.62Ki ± 0%   46.62Ki ±  0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-100-4          94.48Ki ± 0%   94.48Ki ±  0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-500-4          472.8Ki ± 0%   472.8Ki ±  0%       ~ (p=1.000 n=6)
GetTimeline_InMemory/events-1000-4         944.3Ki ± 0%   944.3Ki ±  0%       ~ (p=0.275 n=6)
GetTimeline_SQLite/events-10-4             16.74Ki ± 0%   16.74Ki ±  0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-50-4             87.14Ki ± 0%   87.14Ki ±  0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-100-4            175.4Ki ± 0%   175.4Ki ±  0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-500-4            846.1Ki ± 0%   846.1Ki ±  0%       ~ (p=1.000 n=6)
GetTimeline_SQLite/events-1000-4           1.639Mi ± 0%   1.639Mi ±  0%       ~ (p=1.000 n=6)
geomean                                    67.47Ki        67.33Ki        -0.21%
¹ all samples are equal

                                   │ baseline-bench.txt │        benchmark-results.txt        │
                                   │     allocs/op      │  allocs/op   vs base                │
EventStoreAppend_InMemory-4                  7.000 ± 0%    7.000 ± 0%       ~ (p=1.000 n=6) ¹
EventStoreAppend_SQLite-4                    53.00 ± 0%    53.00 ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-10-4             125.0 ± 0%    125.0 ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-50-4             653.0 ± 0%    653.0 ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-100-4           1.306k ± 0%   1.306k ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-500-4           6.514k ± 0%   6.514k ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-1000-4          13.02k ± 0%   13.02k ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-10-4               382.0 ± 0%    382.0 ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-50-4              1.852k ± 0%   1.852k ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-100-4             3.681k ± 0%   3.681k ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-500-4             18.54k ± 0%   18.54k ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-1000-4            37.29k ± 0%   37.29k ± 0%       ~ (p=1.000 n=6) ¹
geomean                                     1.162k        1.162k       +0.00%
¹ all samples are equal

Benchmarks run with go test -bench=. -benchmem -count=6.
Regressions ≥ 20% are flagged. Results compared via benchstat.

intel352 and others added 2 commits March 9, 2026 01:14
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 9, 2026 05:25
@intel352 intel352 merged commit d14bbc9 into main Mar 9, 2026
13 checks passed
@intel352 intel352 deleted the feat/iac-digitalocean branch March 9, 2026 05:27
@intel352 intel352 restored the feat/iac-digitalocean branch March 9, 2026 05:30
@intel352 intel352 deleted the feat/iac-digitalocean branch March 9, 2026 05:31
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 7 comments.

Comment on lines +177 to +182
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)
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

The mock backend always sets a PostgreSQL-style connection URI/port/database/user regardless of the configured engine (mysql, redis, mongodb, kafka). Since the module/schema advertises multiple engines, this makes mock outputs misleading for non-pg cases. Consider switching on m.state.Engine to populate engine-appropriate defaults (scheme/port/dbname fields) or documenting that the mock backend only emulates Postgres semantics.

Suggested change
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.Password = "mock-password"
switch m.state.Engine {
case "", "pg", "postgres", "postgresql":
// Default to PostgreSQL-style connection details.
m.state.Port = 25060
m.state.DatabaseName = "defaultdb"
m.state.User = "doadmin"
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)
case "mysql":
m.state.Port = 3306
m.state.DatabaseName = "defaultdb"
m.state.User = "doadmin"
m.state.URI = fmt.Sprintf("mysql://%s:%s@%s:%d/%s",
m.state.User, m.state.Password, m.state.Host, m.state.Port, m.state.DatabaseName)
case "redis":
m.state.Port = 6379
// Use DatabaseName as a logical database index for Redis mocks.
m.state.DatabaseName = "0"
m.state.User = "default"
m.state.URI = fmt.Sprintf("redis://%s:%s@%s:%d/%s",
m.state.User, m.state.Password, m.state.Host, m.state.Port, m.state.DatabaseName)
case "mongodb":
m.state.Port = 27017
m.state.DatabaseName = "defaultdb"
m.state.User = "doadmin"
m.state.URI = fmt.Sprintf("mongodb://%s:%s@%s:%d/%s",
m.state.User, m.state.Password, m.state.Host, m.state.Port, m.state.DatabaseName)
case "kafka":
// Kafka typically uses a broker address without database/user semantics.
m.state.Port = 9092
m.state.DatabaseName = ""
m.state.User = ""
m.state.URI = fmt.Sprintf("kafka://%s:%d",
m.state.Host, m.state.Port)
default:
// Fallback to PostgreSQL-style defaults for unknown engines.
m.state.Port = 25060
m.state.DatabaseName = "defaultdb"
m.state.User = "doadmin"
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)
}

Copilot uses AI. Check for mistakes.
Comment on lines +71 to +74
cfg, err := awsconfig.LoadDefaultConfig(context.Background(),
awsconfig.WithRegion(regionOrDefault(region)),
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
)
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

NewSpacesIaCStateStore always installs a static credentials provider even when accessKey / secretKey are empty (after env var fallback). That disables the default AWS credential chain and can lead to confusing runtime auth errors with blank credentials. Consider validating that both credentials are non-empty for Spaces (and returning a clear config error), or only setting a static provider when at least one credential value is provided so the default chain can be used.

Suggested change
cfg, err := awsconfig.LoadDefaultConfig(context.Background(),
awsconfig.WithRegion(regionOrDefault(region)),
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
)
accessProvided := accessKey != ""
secretProvided := secretKey != ""
if accessProvided != secretProvided {
return nil, fmt.Errorf("iac spaces state: both accessKey and secretKey must be provided together, or neither to use the default AWS credential chain")
}
loadOpts := []func(*awsconfig.LoadOptions) error{
awsconfig.WithRegion(regionOrDefault(region)),
}
if accessProvided && secretProvided {
loadOpts = append(loadOpts, awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")))
}
cfg, err := awsconfig.LoadDefaultConfig(context.Background(), loadOpts...)

Copilot uses AI. Check for mistakes.
Comment on lines +254 to +283
// 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
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

Lock() is implemented as HeadObject then PutObject, which is not atomic across processes and can race (two writers can both observe "not found" and then both create the lock). For a distributed lock in S3/Spaces, prefer a single conditional write (e.g., PutObject with If-None-Match: * / equivalent) or another atomic mechanism so lock acquisition is safe under concurrency.

Copilot uses AI. Check for mistakes.
Comment on lines +163 to +166
// Execute plan via wfctl pipeline run
fmt.Printf("Running plan pipeline...\n")
return runPipelineRun([]string{"-c", cfgFile, "-p", "plan"})
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

runInfra* delegates execution to runPipelineRun, which builds an engine with only the PipelineWorkflowHandler + pipeline-steps plugin. That engine will not have platform IaC step types (e.g. step.iac_plan/apply/status/destroy, DO platform steps), so infra configs that use those steps will fail to compile/run. Consider building the engine with the same default plugin set as wfctl normally uses (or explicitly loading the platform plugin) for infra pipeline execution, rather than reusing the minimal runPipelineRun engine builder.

Copilot uses AI. Check for mistakes.
m.backend = &doDatabaseMockBackend{}
case "digitalocean":
accountName, _ := m.config["account"].(string)
acc, ok := app.SvcRegistry()[accountName].(*CloudAccount)
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

For provider: digitalocean, account is effectively required but not validated. If account is missing/empty, the lookup app.SvcRegistry()[accountName] will fail the type assertion with a confusing message about account "" not being a *CloudAccount. Add an explicit check that accountName is non-empty (and ideally that the service exists) when providerType == "digitalocean", and return a clear config error.

Suggested change
acc, ok := app.SvcRegistry()[accountName].(*CloudAccount)
if accountName == "" {
return fmt.Errorf("platform.do_database %q: provider %q requires non-empty account name", m.name, providerType)
}
svc, exists := app.SvcRegistry()[accountName]
if !exists {
return fmt.Errorf("platform.do_database %q: account %q not found in service registry", m.name, accountName)
}
acc, ok := svc.(*CloudAccount)

Copilot uses AI. Check for mistakes.
Comment on lines +132 to +159
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 (size: %s, %d nodes)",
m.state.Name, 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
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

PlatformDODatabase.Apply() always calls backend.create(), and the real backend implementation always calls Databases.Create(...) even when m.state.ID is already set. This makes Apply non-idempotent and contradicts Plan() reporting an "update" action once an ID exists; a second Apply will attempt to create a second cluster / fail with a name conflict. Consider making Apply idempotent by branching on m.state.ID (e.g., no-op/status refresh when already created, or implement an update/resize path) and aligning Plan() action types with what Apply actually does.

Copilot uses AI. Check for mistakes.
Comment on lines +26 to +27
Password string `json:"password"`
URI string `json:"uri"`
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

DODatabaseState includes connection secrets (Password, URI). step.iac_apply persists result.State into IaC state outputs via JSON snapshotting, so these fields will be written to whatever backend is configured (filesystem/Spaces). Consider excluding secrets from JSON serialization (e.g., json:"-"), returning them separately via a secrets mechanism, or redacting before returning PlatformResult.State to avoid leaking credentials into persisted state/logs.

Suggested change
Password string `json:"password"`
URI string `json:"uri"`
Password string `json:"-"`
URI string `json:"-"`

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants