Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
8df4a76
feat(module): Task 4 — step.iac_provider_list (resolves IaCProvider, …
intel352 Jun 2, 2026
6453a32
feat(module): Task 5 — step.iac_provider_catalog (regions + capabilit…
intel352 Jun 2, 2026
5cc406e
feat(module): Task 6 — step.iac_provider_plan (stateless plan + stabl…
intel352 Jun 2, 2026
a197812
feat(module): Task 7 — step.iac_provider_apply (stateless two-phase h…
intel352 Jun 2, 2026
43a8989
feat(module): Task 8 — step.iac_provider_destroy + step.iac_provider_…
intel352 Jun 2, 2026
46268d7
feat: Task 9 — register 6 iac_provider_* steps in platform plugin, ty…
intel352 Jun 2, 2026
5e63680
chore(schema): update editor golden file for 6 new iac_provider_* ste…
intel352 Jun 2, 2026
afa71d6
refactor(iac): delete dead applydispatch shim package
intel352 Jun 2, 2026
b33dd23
fix(module): add missing keys to drift {supported:false} output
intel352 Jun 2, 2026
d8f0ea2
refactor(module): remove dead exported IaCApplyFn type
intel352 Jun 2, 2026
678b8c3
test(module): rename misleading NilDriftDetector test
intel352 Jun 2, 2026
6c999b9
test(module): add catalog step test for ListRegions error → static_fa…
intel352 Jun 2, 2026
d47a847
docs: document the 6 step.iac_provider_* steps (TestDocumentationCove…
intel352 Jun 2, 2026
2a2cbd3
fix(security): computeDesiredStateHash returns (string, error) — no c…
intel352 Jun 2, 2026
dddf78a
fix: drift step propagates real DetectDrift errors instead of masking…
intel352 Jun 2, 2026
da1ccd8
fix: iac_provider_list step errors on malformed refs instead of silen…
intel352 Jun 2, 2026
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
6 changes: 6 additions & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,12 @@ flowchart TD
| `step.iac_destroy` | Destroys all resources in an IaC stack | platform |
| `step.iac_drift_detect` | Detects configuration drift between desired and actual state | platform |
| `step.iac_generate_hcl` | Generates Terraform HCL from infrastructure definitions | platform |
| `step.iac_provider_list` | Lists current resources via a registered `iac.provider` plugin (`interfaces.IaCProvider`) | platform |
| `step.iac_provider_catalog` | Returns a provider's regions (via `IaCProviderRegionLister`, static fallback) + resource-type capabilities | platform |
| `step.iac_provider_plan` | Plans changes against an `iac.provider`; returns the plan + a stateless `desired_hash` | platform |
| `step.iac_provider_apply` | Applies a plan after recomputing + validating `desired_hash` (stateless two-phase TOCTOU guard) | platform |
| `step.iac_provider_destroy` | Destroys resources via an `iac.provider` plugin | platform |
| `step.iac_provider_drift` | Detects drift via an `iac.provider` (optional `IaCProviderDriftDetector`; `supported:false` fallback) | platform |
| `step.tofu_init` | Initializes an OpenTofu working directory | platform |
| `step.tofu_plan` | Creates an OpenTofu execution plan | platform |
| `step.tofu_apply` | Applies OpenTofu changes to infrastructure | platform |
Expand Down
32 changes: 32 additions & 0 deletions cmd/wfctl/type_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -1447,6 +1447,38 @@ func KnownStepTypes() map[string]StepTypeInfo {
ConfigKeys: []string{"platform", "resource_id", "state_store"},
},

// platform plugin steps (iac provider — general, provider-agnostic)
"step.iac_provider_list": {
Type: "step.iac_provider_list",
Plugin: "platform",
ConfigKeys: []string{"provider", "refs"},
},
"step.iac_provider_catalog": {
Type: "step.iac_provider_catalog",
Plugin: "platform",
ConfigKeys: []string{"provider", "env"},
},
"step.iac_provider_plan": {
Type: "step.iac_provider_plan",
Plugin: "platform",
ConfigKeys: []string{"provider", "specs", "env"},
},
"step.iac_provider_apply": {
Type: "step.iac_provider_apply",
Plugin: "platform",
ConfigKeys: []string{"provider", "specs", "desired_hash"},
},
"step.iac_provider_destroy": {
Type: "step.iac_provider_destroy",
Plugin: "platform",
ConfigKeys: []string{"provider", "refs"},
},
"step.iac_provider_drift": {
Type: "step.iac_provider_drift",
Plugin: "platform",
ConfigKeys: []string{"provider", "refs"},
},

// platform plugin steps (dns)
"step.dns_plan": {
Type: "step.dns_plan",
Expand Down
118 changes: 118 additions & 0 deletions module/pipeline_step_iac_provider_apply.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package module

import (
"context"
"encoding/json"
"fmt"

"github.com/GoCodeAlone/modular"
"github.com/GoCodeAlone/workflow/interfaces"
)

// ─── step.iac_provider_apply ─────────────────────────────────────────────────

// IaCProviderApplyStep implements the stateless two-phase apply:
//
// 1. Resolve the IaCProvider.
// 2. Recompute the desired-state hash from current live state.
// 3. Compare against the client-submitted desired_hash — mismatch → reject.
// 4. Dispatch via the injected applyFn (wfctlhelpers.ApplyPlanWithHooks in prod).
type IaCProviderApplyStep struct {
name string
provider string
submittedHash string
specs []interfaces.ResourceSpec
app modular.Application
applyFn func(ctx context.Context, p interfaces.IaCProvider, plan *interfaces.IaCPlan) (*interfaces.ApplyResult, error)
}

// NewIaCProviderApplyStepFactory returns a StepFactory for step.iac_provider_apply.
// applyFn is the apply dispatch function — pass wfctlhelpers.ApplyPlanWithHooks
// (with a nil-hooks wrapper) from the registration site in plugins/platform/plugin.go.
// Tests may inject a stub. The factory panics if applyFn is nil.
func NewIaCProviderApplyStepFactory(applyFn func(ctx context.Context, p interfaces.IaCProvider, plan *interfaces.IaCPlan) (*interfaces.ApplyResult, error)) StepFactory {
if applyFn == nil {
panic("NewIaCProviderApplyStepFactory: applyFn must not be nil")
}
return func(name string, cfg map[string]any, app modular.Application) (PipelineStep, error) {
providerName, _ := cfg["provider"].(string)
if providerName == "" {
return nil, fmt.Errorf("iac_provider_apply step %q: 'provider' is required", name)
}
submittedHash, _ := cfg["desired_hash"].(string)
if submittedHash == "" {
return nil, fmt.Errorf("iac_provider_apply step %q: 'desired_hash' is required", name)
}

specs, err := parseResourceSpecs(cfg["specs"])
if err != nil {
return nil, fmt.Errorf("iac_provider_apply step %q: parse specs: %w", name, err)
}

return &IaCProviderApplyStep{
name: name,
provider: providerName,
submittedHash: submittedHash,
specs: specs,
app: app,
applyFn: applyFn,
}, nil
}
}

func (s *IaCProviderApplyStep) Name() string { return s.name }

func (s *IaCProviderApplyStep) Execute(ctx context.Context, _ *PipelineContext) (*StepResult, error) {
provider, err := resolveIaCProvider(s.app, s.provider, s.name, "iac_provider_apply")
if err != nil {
return nil, err
}

// Phase 1: recompute hash from current live state.
statuses, err := provider.Status(ctx, nil)
if err != nil {
return nil, fmt.Errorf("iac_provider_apply step %q: Status: %w", s.name, err)
}
current := statusesToResourceStates(statuses)
recomputedHash, err := computeDesiredStateHash(s.specs, current)
if err != nil {
return nil, fmt.Errorf("iac_provider_apply step %q: compute desired hash: %w", s.name, err)
}

// Phase 2: guard — reject if hashes diverge (state changed or plan tampered).
if recomputedHash != s.submittedHash {
return nil, fmt.Errorf("iac_provider_apply step %q: plan hash mismatch (state changed or plan tampered); re-plan", s.name)
}

// Phase 3: build the plan and dispatch via the injected apply function.
plan, err := provider.Plan(ctx, s.specs, current)
if err != nil {
return nil, fmt.Errorf("iac_provider_apply step %q: Plan: %w", s.name, err)
}
if plan == nil {
plan = &interfaces.IaCPlan{}
}
plan.DesiredHash = recomputedHash

applyResult, err := s.applyFn(ctx, provider, plan)
if err != nil {
return nil, fmt.Errorf("iac_provider_apply step %q: apply: %w", s.name, err)
}

// JSON-encode apply result for downstream consumers.
resultJSON, err := json.Marshal(applyResult)
if err != nil {
return nil, fmt.Errorf("iac_provider_apply step %q: marshal result: %w", s.name, err)
}
var resultAny any
if err := json.Unmarshal(resultJSON, &resultAny); err != nil {
return nil, fmt.Errorf("iac_provider_apply step %q: re-parse result: %w", s.name, err)
}

return &StepResult{Output: map[string]any{
"apply_result": resultAny,
"desired_hash": recomputedHash,
"provider": s.provider,
"action_count": len(plan.Actions),
}}, nil
}
195 changes: 195 additions & 0 deletions module/pipeline_step_iac_provider_apply_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package module_test

import (
"context"
"errors"
"testing"

"github.com/GoCodeAlone/workflow/interfaces"
"github.com/GoCodeAlone/workflow/module"
)

// ─── apply stub helpers ───────────────────────────────────────────────────────

// noopApplyFn is an apply function stub that returns an empty ApplyResult
// (simulates a successful zero-action apply).
func noopApplyFn(_ context.Context, _ interfaces.IaCProvider, plan *interfaces.IaCPlan) (*interfaces.ApplyResult, error) {
result := &interfaces.ApplyResult{PlanID: plan.ID}
// Emit one ActionOutcome per action so the engine invariant (len Actions == len plan.Actions) holds.
for range plan.Actions {
result.Actions = append(result.Actions, interfaces.ActionOutcome{Status: interfaces.ActionStatusSuccess})
}
return result, nil
}

// errApplyFn always returns a provider error.
func errApplyFn(_ context.Context, _ interfaces.IaCProvider, _ *interfaces.IaCPlan) (*interfaces.ApplyResult, error) {
return nil, errors.New("provider internal error: disk full")
}

// buildApplyProvider returns a stub provider with a known status and a plan result
// that matches the given specs so the hash-recompute path exercises the equality branch.
func buildApplyProvider(t *testing.T) (*stubIaCProvider, string) {
t.Helper()
specs := []interfaces.ResourceSpec{
{Name: "my-db", Type: "infra.database"},
}
// No current state — hash is just over the desired specs.
hash := computeDesiredStateHashTestHelper(specs, nil)
provider := &stubIaCProvider{
statusResult: nil, // no existing resources
planResult: &interfaces.IaCPlan{
ID: "plan-999",
Actions: []interfaces.PlanAction{
{Action: "create", Resource: interfaces.ResourceSpec{Name: "my-db", Type: "infra.database"}},
},
},
}
return provider, hash
}

// computeDesiredStateHashTestHelper calls the step's Execute to get the hash
// indirectly, or we replicate the logic using the plan step.
// Since we can't import the private function, we use a plan step to get the hash.
func computeDesiredStateHashTestHelper(specs []interfaces.ResourceSpec, current []interfaces.ResourceState) string {
_ = current // only specs matter for the test setup
// We reproduce the hash inline using the same algorithm as the step.
// The test just needs a matching hash string.
app := module.NewMockApplication()
provider := &stubIaCProvider{
statusResult: nil,
planResult: &interfaces.IaCPlan{ID: "x"},
}
if err := app.RegisterService("hp", provider); err != nil {
panic(err)
}
specsAny := make([]any, len(specs))
for i, s := range specs {
specsAny[i] = map[string]any{"name": s.Name, "type": s.Type}
}
planFactory := module.NewIaCProviderPlanStepFactory()
step, err := planFactory("h", map[string]any{"provider": "hp", "specs": specsAny}, app)
if err != nil {
panic(err)
}
result, err := step.Execute(context.Background(), &module.PipelineContext{})
if err != nil {
panic(err)
}
return result.Output["desired_hash"].(string)
}

// ─── step.iac_provider_apply tests ───────────────────────────────────────────

func TestIaCProviderApplyStep_Execute_Matches_Applies(t *testing.T) {
app := module.NewMockApplication()
provider, correctHash := buildApplyProvider(t)
if err := app.RegisterService("my-provider", provider); err != nil {
t.Fatal(err)
}

factory := module.NewIaCProviderApplyStepFactory(noopApplyFn)
step, err := factory("apply-step", map[string]any{
"provider": "my-provider",
"desired_hash": correctHash,
"specs": []any{
map[string]any{"name": "my-db", "type": "infra.database"},
},
}, app)
if err != nil {
t.Fatalf("factory error: %v", err)
}

result, err := step.Execute(context.Background(), &module.PipelineContext{})
if err != nil {
t.Fatalf("Execute error: %v", err)
}
if result.Output["apply_result"] == nil {
t.Error("expected apply_result in output")
}
if result.Output["desired_hash"] != correctHash {
t.Errorf("desired_hash mismatch: got %v", result.Output["desired_hash"])
}
}

func TestIaCProviderApplyStep_Execute_Mismatch_Rejected(t *testing.T) {
app := module.NewMockApplication()
provider, _ := buildApplyProvider(t)
if err := app.RegisterService("my-provider", provider); err != nil {
t.Fatal(err)
}

applied := false
trackingApplyFn := func(ctx context.Context, p interfaces.IaCProvider, plan *interfaces.IaCPlan) (*interfaces.ApplyResult, error) {
applied = true
return noopApplyFn(ctx, p, plan)
}

factory := module.NewIaCProviderApplyStepFactory(trackingApplyFn)
step, err := factory("apply-step", map[string]any{
"provider": "my-provider",
"desired_hash": "deadbeef0000000000000000000000000000000000000000000000000000dead", // wrong hash
"specs": []any{
map[string]any{"name": "my-db", "type": "infra.database"},
},
}, app)
if err != nil {
t.Fatalf("factory error: %v", err)
}

_, err = step.Execute(context.Background(), &module.PipelineContext{})
if err == nil {
t.Fatal("expected error for hash mismatch, got nil")
}
if !containsString(err.Error(), "plan hash mismatch") {
t.Errorf("expected 'plan hash mismatch' error, got: %v", err)
}
if applied {
t.Error("applyFn must not be called when hash mismatches")
}
}

func TestIaCProviderApplyStep_Execute_ProviderError_Surfaced(t *testing.T) {
app := module.NewMockApplication()
provider, correctHash := buildApplyProvider(t)
if err := app.RegisterService("my-provider", provider); err != nil {
t.Fatal(err)
}

factory := module.NewIaCProviderApplyStepFactory(errApplyFn)
step, err := factory("apply-step", map[string]any{
"provider": "my-provider",
"desired_hash": correctHash,
"specs": []any{
map[string]any{"name": "my-db", "type": "infra.database"},
},
}, app)
if err != nil {
t.Fatalf("factory error: %v", err)
}

_, err = step.Execute(context.Background(), &module.PipelineContext{})
if err == nil {
t.Fatal("expected provider error to be surfaced")
}
// Must surface the underlying provider error, not mask it as "denied".
if !containsString(err.Error(), "provider internal error") {
t.Errorf("expected provider error text, got: %v", err)
}
}

func TestIaCProviderApplyStep_Factory_RequiresProvider(t *testing.T) {
factory := module.NewIaCProviderApplyStepFactory(noopApplyFn)
_, err := factory("apply-step", map[string]any{"desired_hash": "abc"}, nil)
if err == nil {
t.Fatal("expected error when 'provider' missing")
}
}

func TestIaCProviderApplyStep_Factory_RequiresHash(t *testing.T) {
factory := module.NewIaCProviderApplyStepFactory(noopApplyFn)
_, err := factory("apply-step", map[string]any{"provider": "x"}, nil)
if err == nil {
t.Fatal("expected error when 'desired_hash' missing")
}
}
Loading
Loading