Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,11 @@ 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) |
| `iac.state` | IaC state persistence (memory, filesystem, or spaces/S3-compatible backends) |

### Observability
| Type | Description |
Expand Down Expand Up @@ -467,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://<region>.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.
Expand Down
264 changes: 264 additions & 0 deletions cmd/wfctl/infra.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
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 <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 or unknown 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)
}
}
Comment on lines +151 to +160
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.
fmt.Println()

// Execute plan via wfctl pipeline run
fmt.Printf("Running plan pipeline...\n")
return runPipelineRun([]string{"-c", cfgFile, "-p", "plan"})
}
Comment on lines +163 to +166
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.

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
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
}
}

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
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
}
}

fmt.Printf("Destroying infrastructure from %s...\n", cfgFile)
return runPipelineRun([]string{"-c", cfgFile, "-p", "destroy"})
}
1 change: 1 addition & 0 deletions cmd/wfctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ var commands = map[string]func([]string) error{
"update": runUpdate,
"mcp": runMCP,
"modernize": runModernize,
"infra": runInfra,
}

func main() {
Expand Down
13 changes: 13 additions & 0 deletions cmd/wfctl/wfctl.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

31 changes: 31 additions & 0 deletions docs/WFCTL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <action> [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.
Expand Down
Loading
Loading