diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 7b997e8a..2f8b80d5 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -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 | diff --git a/cmd/wfctl/type_registry.go b/cmd/wfctl/type_registry.go index c1943f30..1a71827c 100644 --- a/cmd/wfctl/type_registry.go +++ b/cmd/wfctl/type_registry.go @@ -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", diff --git a/module/pipeline_step_iac_provider_apply.go b/module/pipeline_step_iac_provider_apply.go new file mode 100644 index 00000000..d3f05986 --- /dev/null +++ b/module/pipeline_step_iac_provider_apply.go @@ -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 +} diff --git a/module/pipeline_step_iac_provider_apply_test.go b/module/pipeline_step_iac_provider_apply_test.go new file mode 100644 index 00000000..030b93e4 --- /dev/null +++ b/module/pipeline_step_iac_provider_apply_test.go @@ -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") + } +} diff --git a/module/pipeline_step_iac_provider_catalog.go b/module/pipeline_step_iac_provider_catalog.go new file mode 100644 index 00000000..652a918b --- /dev/null +++ b/module/pipeline_step_iac_provider_catalog.go @@ -0,0 +1,104 @@ +package module + +import ( + "context" + "fmt" + + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/workflow/iac/providerclient" +) + +// staticRegions is the fallback region list when the provider does not +// advertise IaCProviderRegionLister. Covers the most common cloud regions +// across major providers; plugins that support a narrower or wider set +// will surface that via the live path. +var staticRegions = []string{ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "eu-west-1", + "eu-west-2", + "eu-central-1", + "ap-southeast-1", + "ap-southeast-2", + "ap-northeast-1", +} + +// ─── step.iac_provider_catalog ─────────────────────────────────────────────── + +// IaCProviderCatalogStep resolves an IaCProvider, fetches regions and resource +// type capabilities, and returns a catalog suitable for UI rendering. +type IaCProviderCatalogStep struct { + name string + provider string + env string + app modular.Application +} + +// NewIaCProviderCatalogStepFactory returns a StepFactory for step.iac_provider_catalog. +func NewIaCProviderCatalogStepFactory() StepFactory { + 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_catalog step %q: 'provider' is required", name) + } + env, _ := cfg["env"].(string) + return &IaCProviderCatalogStep{ + name: name, + provider: providerName, + env: env, + app: app, + }, nil + } +} + +func (s *IaCProviderCatalogStep) Name() string { return s.name } + +func (s *IaCProviderCatalogStep) Execute(ctx context.Context, _ *PipelineContext) (*StepResult, error) { + provider, err := resolveIaCProvider(s.app, s.provider, s.name, "iac_provider_catalog") + if err != nil { + return nil, err + } + + // Attempt to get live regions from the provider via the optional + // RegionListerProvider capability accessor (PR-1 pattern). + var regions []string + source := "static" + + if rlp, ok := provider.(providerclient.RegionListerProvider); ok { + if rl := rlp.RegionLister(); rl != nil { + liveRegions, listErr := rl.ListRegions(ctx, s.env) + if listErr != nil { + // Non-fatal: fall back to static list and surface a warning in + // the output so callers know the live path was attempted. + source = "static_fallback_error" + } else { + regions = liveRegions + source = "live" + } + } + } + + if source != "live" { + regions = append([]string(nil), staticRegions...) + } + + // Collect resource types from provider capabilities. + caps := provider.Capabilities() + types := make([]map[string]any, 0, len(caps)) + for _, c := range caps { + types = append(types, map[string]any{ + "resource_type": c.ResourceType, + "tier": c.Tier, + "operations": c.Operations, + }) + } + + return &StepResult{Output: map[string]any{ + "provider": s.provider, + "regions": regions, + "types": types, + "source": source, + }}, nil +} diff --git a/module/pipeline_step_iac_provider_catalog_test.go b/module/pipeline_step_iac_provider_catalog_test.go new file mode 100644 index 00000000..51472e8f --- /dev/null +++ b/module/pipeline_step_iac_provider_catalog_test.go @@ -0,0 +1,185 @@ +package module_test + +import ( + "context" + "errors" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/module" +) + +// stubRegionLister implements interfaces.IaCProviderRegionLister. +type stubRegionLister struct { + regions []string + err error +} + +func (r *stubRegionLister) ListRegions(_ context.Context, _ string) ([]string, error) { + return r.regions, r.err +} + +// compile-time check +var _ interfaces.IaCProviderRegionLister = (*stubRegionLister)(nil) + +// stubProviderWithRegionLister extends stubIaCProvider with RegionLister capability. +// It satisfies providerclient.RegionListerProvider via the RegionLister() accessor. +type stubProviderWithRegionLister struct { + stubIaCProvider + lister *stubRegionLister // nil → RegionLister() returns nil (unadvertised) +} + +// RegionLister satisfies providerclient.RegionListerProvider. +func (p *stubProviderWithRegionLister) RegionLister() interfaces.IaCProviderRegionLister { + if p.lister == nil { + return nil + } + return p.lister +} + +// ─── step.iac_provider_catalog tests ───────────────────────────────────────── + +func TestIaCProviderCatalogStep_LiveRegions(t *testing.T) { + app := module.NewMockApplication() + provider := &stubProviderWithRegionLister{ + stubIaCProvider: stubIaCProvider{ + caps: []interfaces.IaCCapabilityDeclaration{ + {ResourceType: "infra.database", Tier: 1, Operations: []string{"create", "delete"}}, + }, + }, + lister: &stubRegionLister{regions: []string{"us-east-1", "eu-west-1"}}, + } + if err := app.RegisterService("my-provider", provider); err != nil { + t.Fatal(err) + } + + factory := module.NewIaCProviderCatalogStepFactory() + step, err := factory("catalog-step", map[string]any{"provider": "my-provider"}, 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["source"] != "live" { + t.Errorf("expected source=live, got %v", result.Output["source"]) + } + regions, ok := result.Output["regions"].([]string) + if !ok { + t.Fatalf("expected []string regions, got %T", result.Output["regions"]) + } + if len(regions) != 2 { + t.Errorf("expected 2 regions, got %d", len(regions)) + } + + types, ok := result.Output["types"].([]map[string]any) + if !ok { + t.Fatalf("expected []map[string]any types, got %T", result.Output["types"]) + } + if len(types) != 1 || types[0]["resource_type"] != "infra.database" { + t.Errorf("unexpected types: %v", types) + } +} + +func TestIaCProviderCatalogStep_StaticFallback_NoRegionLister(t *testing.T) { + app := module.NewMockApplication() + // stubIaCProvider does NOT implement RegionListerProvider → static fallback. + provider := &stubIaCProvider{} + if err := app.RegisterService("my-provider", provider); err != nil { + t.Fatal(err) + } + + factory := module.NewIaCProviderCatalogStepFactory() + step, err := factory("catalog-step", map[string]any{"provider": "my-provider"}, 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["source"] != "static" { + t.Errorf("expected source=static, got %v", result.Output["source"]) + } + regions, ok := result.Output["regions"].([]string) + if !ok { + t.Fatalf("expected []string regions, got %T", result.Output["regions"]) + } + if len(regions) == 0 { + t.Error("expected non-empty static region list") + } +} + +func TestIaCProviderCatalogStep_StaticFallback_NilRegionLister(t *testing.T) { + app := module.NewMockApplication() + // Provider implements RegionListerProvider but RegionLister() returns nil + // (plugin did not advertise the service). + provider := &stubProviderWithRegionLister{lister: nil} + if err := app.RegisterService("my-provider", provider); err != nil { + t.Fatal(err) + } + + factory := module.NewIaCProviderCatalogStepFactory() + step, err := factory("catalog-step", map[string]any{"provider": "my-provider"}, 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["source"] != "static" { + t.Errorf("expected source=static (nil lister), got %v", result.Output["source"]) + } +} + +func TestIaCProviderCatalogStep_Factory_RequiresProvider(t *testing.T) { + factory := module.NewIaCProviderCatalogStepFactory() + _, err := factory("catalog-step", map[string]any{}, nil) + if err == nil { + t.Fatal("expected error when 'provider' missing, got nil") + } +} + +func TestIaCProviderCatalogStep_StaticFallback_RegionListerError(t *testing.T) { + // Provider implements RegionListerProvider AND returns a non-nil lister, + // but ListRegions returns an error. The step must fall back to static + // regions and surface source == "static_fallback_error". + app := module.NewMockApplication() + listErr := errors.New("regions API unavailable") + provider := &stubProviderWithRegionLister{ + lister: &stubRegionLister{err: listErr}, + } + if err := app.RegisterService("my-provider", provider); err != nil { + t.Fatal(err) + } + + factory := module.NewIaCProviderCatalogStepFactory() + step, err := factory("catalog-step", map[string]any{"provider": "my-provider"}, app) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + result, err := step.Execute(context.Background(), &module.PipelineContext{}) + if err != nil { + t.Fatalf("Execute must not error on ListRegions failure: %v", err) + } + + if result.Output["source"] != "static_fallback_error" { + t.Errorf("expected source=static_fallback_error, got %v", result.Output["source"]) + } + regions, ok := result.Output["regions"].([]string) + if !ok { + t.Fatalf("expected []string regions, got %T", result.Output["regions"]) + } + if len(regions) == 0 { + t.Error("expected non-empty static region list on fallback") + } +} diff --git a/module/pipeline_step_iac_provider_destroy.go b/module/pipeline_step_iac_provider_destroy.go new file mode 100644 index 00000000..045f99ea --- /dev/null +++ b/module/pipeline_step_iac_provider_destroy.go @@ -0,0 +1,74 @@ +package module + +import ( + "context" + "fmt" + + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/workflow/interfaces" +) + +// ─── step.iac_provider_destroy ─────────────────────────────────────────────── + +// IaCProviderDestroyStep resolves an IaCProvider and destroys the specified +// resources by ref list. +type IaCProviderDestroyStep struct { + name string + provider string + refs []interfaces.ResourceRef + app modular.Application +} + +// NewIaCProviderDestroyStepFactory returns a StepFactory for step.iac_provider_destroy. +func NewIaCProviderDestroyStepFactory() StepFactory { + 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_destroy step %q: 'provider' is required", name) + } + refs, err := parseResourceRefs(cfg["refs"]) + if err != nil { + return nil, fmt.Errorf("iac_provider_destroy step %q: parse refs: %w", name, err) + } + return &IaCProviderDestroyStep{ + name: name, + provider: providerName, + refs: refs, + app: app, + }, nil + } +} + +func (s *IaCProviderDestroyStep) Name() string { return s.name } + +func (s *IaCProviderDestroyStep) Execute(ctx context.Context, _ *PipelineContext) (*StepResult, error) { + provider, err := resolveIaCProvider(s.app, s.provider, s.name, "iac_provider_destroy") + if err != nil { + return nil, err + } + + result, err := provider.Destroy(ctx, s.refs) + if err != nil { + return nil, fmt.Errorf("iac_provider_destroy step %q: Destroy: %w", s.name, err) + } + + var destroyed []string + var destroyErrors []map[string]any + if result != nil { + destroyed = result.Destroyed + destroyErrors = make([]map[string]any, 0, len(result.Errors)) + for _, e := range result.Errors { + destroyErrors = append(destroyErrors, map[string]any{ + "resource": e.Resource, + "action": e.Action, + "error": e.Error, + }) + } + } + + return &StepResult{Output: map[string]any{ + "destroyed": destroyed, + "destroy_errors": destroyErrors, + "provider": s.provider, + }}, nil +} diff --git a/module/pipeline_step_iac_provider_destroy_drift_test.go b/module/pipeline_step_iac_provider_destroy_drift_test.go new file mode 100644 index 00000000..c7ecc627 --- /dev/null +++ b/module/pipeline_step_iac_provider_destroy_drift_test.go @@ -0,0 +1,241 @@ +package module_test + +import ( + "context" + "errors" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/module" +) + +// ─── step.iac_provider_destroy tests ───────────────────────────────────────── + +func TestIaCProviderDestroyStep_Execute_ReturnsDestroyed(t *testing.T) { + app := module.NewMockApplication() + provider := &stubIaCProvider{ + destroyResult: &interfaces.DestroyResult{ + Destroyed: []string{"db-1", "vpc-1"}, + }, + } + if err := app.RegisterService("my-provider", provider); err != nil { + t.Fatal(err) + } + + factory := module.NewIaCProviderDestroyStepFactory() + step, err := factory("destroy-step", map[string]any{ + "provider": "my-provider", + "refs": []any{ + map[string]any{"name": "db-1", "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) + } + + destroyed, ok := result.Output["destroyed"].([]string) + if !ok { + t.Fatalf("expected []string destroyed, got %T", result.Output["destroyed"]) + } + if len(destroyed) != 2 { + t.Errorf("expected 2 destroyed resources, got %d", len(destroyed)) + } +} + +func TestIaCProviderDestroyStep_Execute_UnregisteredProvider(t *testing.T) { + app := module.NewMockApplication() + factory := module.NewIaCProviderDestroyStepFactory() + step, err := factory("destroy-step", map[string]any{"provider": "none"}, 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 unregistered provider") + } + if !containsString(err.Error(), "not registered") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestIaCProviderDestroyStep_Factory_RequiresProvider(t *testing.T) { + factory := module.NewIaCProviderDestroyStepFactory() + _, err := factory("destroy-step", map[string]any{}, nil) + if err == nil { + t.Fatal("expected error when 'provider' missing") + } +} + +// ─── step.iac_provider_drift tests ─────────────────────────────────────────── + +// stubDriftConfigDetector implements interfaces.DriftConfigDetector. +type stubDriftConfigDetector struct { + drifts []interfaces.DriftResult + err error +} + +func (d *stubDriftConfigDetector) DetectDriftWithSpecs(_ context.Context, _ []interfaces.ResourceRef, _ map[string]interfaces.ResourceSpec) ([]interfaces.DriftResult, error) { + return d.drifts, d.err +} + +var _ interfaces.DriftConfigDetector = (*stubDriftConfigDetector)(nil) + +// stubProviderWithDriftDetector extends stubIaCProvider with DriftDetector capability. +type stubProviderWithDriftDetector struct { + stubIaCProvider + detector interfaces.DriftConfigDetector // nil → DriftDetector() returns nil +} + +// DriftDetector satisfies providerclient.DriftDetectorProvider. +func (p *stubProviderWithDriftDetector) DriftDetector() interfaces.DriftConfigDetector { + return p.detector +} + +func TestIaCProviderDriftStep_Execute_WithDriftDetector(t *testing.T) { + app := module.NewMockApplication() + provider := &stubProviderWithDriftDetector{ + detector: &stubDriftConfigDetector{ + drifts: []interfaces.DriftResult{ + {Name: "db", Type: "infra.database", Drifted: true, Class: interfaces.DriftClassConfig, Fields: []string{"size"}}, + {Name: "vpc", Type: "infra.vpc", Drifted: false, Class: interfaces.DriftClassInSync}, + }, + }, + } + if err := app.RegisterService("my-provider", provider); err != nil { + t.Fatal(err) + } + + factory := module.NewIaCProviderDriftStepFactory() + step, err := factory("drift-step", map[string]any{"provider": "my-provider"}, 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["supported"] != true { + t.Errorf("expected supported=true, got %v", result.Output["supported"]) + } + if result.Output["any_drifted"] != true { + t.Errorf("expected any_drifted=true, got %v", result.Output["any_drifted"]) + } + if result.Output["count"] != 2 { + t.Errorf("expected count=2, got %v", result.Output["count"]) + } +} + +func TestIaCProviderDriftStep_Execute_NilDriftDetector_FallsBackToDetectDrift(t *testing.T) { + app := module.NewMockApplication() + // Provider implements DriftDetectorProvider but returns nil detector. + provider := &stubProviderWithDriftDetector{detector: nil} + // Since DetectDrift on stubIaCProvider returns (nil, nil) — treat as "supported but no drifts". + if err := app.RegisterService("my-provider", provider); err != nil { + t.Fatal(err) + } + + factory := module.NewIaCProviderDriftStepFactory() + step, err := factory("drift-step", map[string]any{"provider": "my-provider"}, 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) + } + // Falls through to DetectDrift; stubIaCProvider.driftResult is nil so drifts is empty. + if result.Output["supported"] != true { + t.Errorf("expected supported=true (existence-only path), got %v", result.Output["supported"]) + } +} + +func TestIaCProviderDriftStep_Execute_Unsupported_NoInterface(t *testing.T) { + app := module.NewMockApplication() + // stubIaCProvider.DetectDrift returns (nil, nil) — empty drifts, supported. + provider := &stubIaCProvider{} + if err := app.RegisterService("my-provider", provider); err != nil { + t.Fatal(err) + } + + factory := module.NewIaCProviderDriftStepFactory() + step, err := factory("drift-step", map[string]any{"provider": "my-provider"}, 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["supported"] != true { + t.Errorf("expected supported=true (DetectDrift returned nil error), got %v", result.Output["supported"]) + } +} + +func TestIaCProviderDriftStep_Execute_Unsupported_DetectDriftUnimplemented(t *testing.T) { + app := module.NewMockApplication() + // Provider whose DetectDrift returns the ErrProviderMethodUnimplemented sentinel. + provider := &stubIaCProvider{ + driftErr: interfaces.ErrProviderMethodUnimplemented, + } + if err := app.RegisterService("my-provider", provider); err != nil { + t.Fatal(err) + } + + factory := module.NewIaCProviderDriftStepFactory() + step, err := factory("drift-step", map[string]any{"provider": "my-provider"}, app) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + result, err := step.Execute(context.Background(), &module.PipelineContext{}) + if err != nil { + t.Fatalf("Execute should not error when DetectDrift returns unimplemented: %v", err) + } + // ErrProviderMethodUnimplemented sentinel → unsupported, not an error. + if result.Output["supported"] != false { + t.Errorf("expected supported=false when DetectDrift returns unimplemented, got %v", result.Output["supported"]) + } +} + +func TestIaCProviderDriftStep_Execute_RealError_Propagated(t *testing.T) { + app := module.NewMockApplication() + // Provider whose DetectDrift returns a genuine (non-sentinel) error. + provider := &stubIaCProvider{ + driftErr: errors.New("network timeout reaching provider API"), + } + if err := app.RegisterService("my-provider", provider); err != nil { + t.Fatal(err) + } + + factory := module.NewIaCProviderDriftStepFactory() + step, err := factory("drift-step", map[string]any{"provider": "my-provider"}, app) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + _, err = step.Execute(context.Background(), &module.PipelineContext{}) + if err == nil { + t.Fatal("expected real DetectDrift error to be propagated, got nil") + } + if !containsString(err.Error(), "network timeout reaching provider API") { + t.Errorf("expected original error text in propagated error, got: %v", err) + } +} + +func TestIaCProviderDriftStep_Factory_RequiresProvider(t *testing.T) { + factory := module.NewIaCProviderDriftStepFactory() + _, err := factory("drift-step", map[string]any{}, nil) + if err == nil { + t.Fatal("expected error when 'provider' missing") + } +} diff --git a/module/pipeline_step_iac_provider_drift.go b/module/pipeline_step_iac_provider_drift.go new file mode 100644 index 00000000..182fb783 --- /dev/null +++ b/module/pipeline_step_iac_provider_drift.go @@ -0,0 +1,123 @@ +package module + +import ( + "context" + "errors" + "fmt" + + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/workflow/iac/providerclient" + "github.com/GoCodeAlone/workflow/interfaces" +) + +// ─── step.iac_provider_drift ───────────────────────────────────────────────── + +// IaCProviderDriftStep resolves an IaCProvider and checks for configuration +// drift. It type-asserts to providerclient.DriftDetectorProvider for the +// config-aware drift path; if the provider does not advertise the optional +// IaCProviderDriftDetector service, it returns {supported:false}. +type IaCProviderDriftStep struct { + name string + provider string + refs []interfaces.ResourceRef + app modular.Application +} + +// NewIaCProviderDriftStepFactory returns a StepFactory for step.iac_provider_drift. +func NewIaCProviderDriftStepFactory() StepFactory { + 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_drift step %q: 'provider' is required", name) + } + refs, err := parseResourceRefs(cfg["refs"]) + if err != nil { + return nil, fmt.Errorf("iac_provider_drift step %q: parse refs: %w", name, err) + } + return &IaCProviderDriftStep{ + name: name, + provider: providerName, + refs: refs, + app: app, + }, nil + } +} + +func (s *IaCProviderDriftStep) Name() string { return s.name } + +func (s *IaCProviderDriftStep) Execute(ctx context.Context, _ *PipelineContext) (*StepResult, error) { + provider, err := resolveIaCProvider(s.app, s.provider, s.name, "iac_provider_drift") + if err != nil { + return nil, err + } + + // Attempt config-aware drift detection via the optional DriftDetectorProvider + // accessor (PR-1 pattern). If the accessor returns nil, fall back to the + // existence-only DetectDrift on the required interface. + if ddp, ok := provider.(providerclient.DriftDetectorProvider); ok { + if dd := ddp.DriftDetector(); dd != nil { + // Config-aware drift: build a per-ref spec map from refs (no specs + // provided here — providers fall back to existence-only for missing entries). + drifts, err := dd.DetectDriftWithSpecs(ctx, s.refs, nil) + if err != nil { + return nil, fmt.Errorf("iac_provider_drift step %q: DetectDriftWithSpecs: %w", s.name, err) + } + return driftResult(s.provider, drifts, true), nil + } + } + + // Existence-only drift via the required DetectDrift method. + drifts, driftErr := provider.DetectDrift(ctx, s.refs) + if driftErr != nil { + // Only ErrProviderMethodUnimplemented means the plugin wired neither drift + // path — surface as {supported:false} so callers can gate without pipeline + // failure. Any other error (network, provider failure, etc.) is a real error + // and MUST be returned so it propagates as HTTP 5xx rather than being masked + // as an unsupported-feature response. + if !errors.Is(driftErr, interfaces.ErrProviderMethodUnimplemented) { + return nil, fmt.Errorf("iac_provider_drift step %q: DetectDrift: %w", s.name, driftErr) + } + // Plugin declared DetectDrift is unimplemented — treat as unsupported. + // Zero-value fields are included so downstream type-assertions on + // result.Output["any_drifted"].(bool) do not panic. + return &StepResult{Output: map[string]any{ + "provider": s.provider, + "supported": false, + "reason": driftErr.Error(), + "any_drifted": false, + "drifts": []map[string]any{}, + "count": 0, + }}, nil + } + return driftResult(s.provider, drifts, true), nil +} + +// driftResult builds the step output map from a drift detection result. +func driftResult(providerName string, drifts []interfaces.DriftResult, supported bool) *StepResult { + results := make([]map[string]any, 0, len(drifts)) + for _, d := range drifts { + results = append(results, map[string]any{ + "name": d.Name, + "type": d.Type, + "drifted": d.Drifted, + "class": string(d.Class), + "fields": d.Fields, + "expected": d.Expected, + "actual": d.Actual, + }) + } + anyDrifted := false + for _, d := range drifts { + if d.Drifted { + anyDrifted = true + break + } + } + return &StepResult{Output: map[string]any{ + "provider": providerName, + "supported": supported, + "any_drifted": anyDrifted, + "drifts": results, + "count": len(results), + }} +} diff --git a/module/pipeline_step_iac_provider_list.go b/module/pipeline_step_iac_provider_list.go new file mode 100644 index 00000000..9bd403c9 --- /dev/null +++ b/module/pipeline_step_iac_provider_list.go @@ -0,0 +1,112 @@ +package module + +import ( + "context" + "fmt" + + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/workflow/interfaces" +) + +// resolveIaCProvider looks up an interfaces.IaCProvider from the service registry. +// stepType is the step type name (e.g. "iac_provider_list") for error messages. +func resolveIaCProvider(app modular.Application, providerName, stepName, stepType string) (interfaces.IaCProvider, error) { + if app == nil { + return nil, fmt.Errorf("%s step %q: no application context", stepType, stepName) + } + svc, ok := app.SvcRegistry()[providerName] + if !ok { + return nil, fmt.Errorf("%s step %q: provider service %q not registered", stepType, stepName, providerName) + } + provider, ok := svc.(interfaces.IaCProvider) + if !ok { + return nil, fmt.Errorf("%s step %q: service %q does not implement IaCProvider (got %T)", stepType, stepName, providerName, svc) + } + return provider, nil +} + +// ─── step.iac_provider_list ────────────────────────────────────────────────── + +// IaCProviderListStep resolves an IaCProvider and lists current resource statuses. +type IaCProviderListStep struct { + name string + provider string + refs []interfaces.ResourceRef + app modular.Application +} + +// NewIaCProviderListStepFactory returns a StepFactory for step.iac_provider_list. +func NewIaCProviderListStepFactory() StepFactory { + 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_list step %q: 'provider' is required", name) + } + // Optional: list of refs to query; absent means pass nil to Status + // (providers should return all resources when refs is nil/empty). + // If "refs" is present but malformed (wrong type or wrong item shape), the + // factory returns a config error — silently widening to list-all would mask a + // misconfigured step that was intended to be a filtered query. + var refs []interfaces.ResourceRef + if rawRefs, ok := cfg["refs"]; ok { + refList, ok := rawRefs.([]any) + if !ok { + return nil, fmt.Errorf("iac_provider_list step %q: 'refs' must be a list, got %T", name, rawRefs) + } + for i, r := range refList { + rm, ok := r.(map[string]any) + if !ok { + return nil, fmt.Errorf("iac_provider_list step %q: refs[%d] must be a map, got %T", name, i, r) + } + ref := interfaces.ResourceRef{} + if n, ok := rm["name"].(string); ok { + ref.Name = n + } + if t, ok := rm["type"].(string); ok { + ref.Type = t + } + if pid, ok := rm["provider_id"].(string); ok { + ref.ProviderID = pid + } + refs = append(refs, ref) + } + } + return &IaCProviderListStep{ + name: name, + provider: providerName, + refs: refs, + app: app, + }, nil + } +} + +func (s *IaCProviderListStep) Name() string { return s.name } + +func (s *IaCProviderListStep) Execute(ctx context.Context, _ *PipelineContext) (*StepResult, error) { + provider, err := resolveIaCProvider(s.app, s.provider, s.name, "iac_provider_list") + if err != nil { + return nil, err + } + + statuses, err := provider.Status(ctx, s.refs) + if err != nil { + return nil, fmt.Errorf("iac_provider_list step %q: Status: %w", s.name, err) + } + + resources := make([]map[string]any, 0, len(statuses)) + for _, st := range statuses { + resources = append(resources, map[string]any{ + "name": st.Name, + "type": st.Type, + "provider_id": st.ProviderID, + "status": st.Status, + "outputs": st.Outputs, + }) + } + + return &StepResult{Output: map[string]any{ + "provider": s.provider, + "resources": resources, + "count": len(resources), + }}, nil +} diff --git a/module/pipeline_step_iac_provider_list_test.go b/module/pipeline_step_iac_provider_list_test.go new file mode 100644 index 00000000..3a9d0530 --- /dev/null +++ b/module/pipeline_step_iac_provider_list_test.go @@ -0,0 +1,191 @@ +package module_test + +import ( + "context" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/module" +) + +// stubIaCProvider is a minimal interfaces.IaCProvider for step tests. +type stubIaCProvider struct { + statusResult []interfaces.ResourceStatus + statusErr error + caps []interfaces.IaCCapabilityDeclaration + planResult *interfaces.IaCPlan + planErr error + destroyResult *interfaces.DestroyResult + destroyErr error + driftResult []interfaces.DriftResult + driftErr error +} + +func (s *stubIaCProvider) Name() string { return "stub" } +func (s *stubIaCProvider) Version() string { return "0.0.0" } +func (s *stubIaCProvider) Initialize(_ context.Context, _ map[string]any) error { return nil } +func (s *stubIaCProvider) Capabilities() []interfaces.IaCCapabilityDeclaration { return s.caps } +func (s *stubIaCProvider) Plan(_ context.Context, _ []interfaces.ResourceSpec, _ []interfaces.ResourceState) (*interfaces.IaCPlan, error) { + return s.planResult, s.planErr +} +func (s *stubIaCProvider) Destroy(_ context.Context, _ []interfaces.ResourceRef) (*interfaces.DestroyResult, error) { + return s.destroyResult, s.destroyErr +} +func (s *stubIaCProvider) Status(_ context.Context, _ []interfaces.ResourceRef) ([]interfaces.ResourceStatus, error) { + return s.statusResult, s.statusErr +} +func (s *stubIaCProvider) DetectDrift(_ context.Context, _ []interfaces.ResourceRef) ([]interfaces.DriftResult, error) { + return s.driftResult, s.driftErr +} +func (s *stubIaCProvider) Import(_ context.Context, _ string, _ string) (*interfaces.ResourceState, error) { + return nil, nil +} +func (s *stubIaCProvider) ResolveSizing(_ string, _ interfaces.Size, _ *interfaces.ResourceHints) (*interfaces.ProviderSizing, error) { + return nil, nil +} +func (s *stubIaCProvider) ResourceDriver(_ string) (interfaces.ResourceDriver, error) { + return nil, nil +} +func (s *stubIaCProvider) SupportedCanonicalKeys() []string { return nil } +func (s *stubIaCProvider) BootstrapStateBackend(_ context.Context, _ map[string]any) (*interfaces.BootstrapResult, error) { + return nil, nil +} +func (s *stubIaCProvider) Close() error { return nil } + +// compile-time check +var _ interfaces.IaCProvider = (*stubIaCProvider)(nil) + +// ─── step.iac_provider_list tests ──────────────────────────────────────────── + +func TestIaCProviderListStep_Execute_ReturnsSummaries(t *testing.T) { + app := module.NewMockApplication() + provider := &stubIaCProvider{ + statusResult: []interfaces.ResourceStatus{ + {Name: "db", Type: "infra.database", ProviderID: "pid-1", Status: "running", Outputs: map[string]any{"host": "localhost"}}, + {Name: "vpc", Type: "infra.vpc", ProviderID: "pid-2", Status: "running"}, + }, + } + if err := app.RegisterService("my-provider", provider); err != nil { + t.Fatal(err) + } + + factory := module.NewIaCProviderListStepFactory() + step, err := factory("list-step", map[string]any{"provider": "my-provider"}, 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) + } + + resources, ok := result.Output["resources"].([]map[string]any) + if !ok { + t.Fatalf("expected resources slice, got %T", result.Output["resources"]) + } + if len(resources) != 2 { + t.Errorf("expected 2 resources, got %d", len(resources)) + } + if resources[0]["name"] != "db" { + t.Errorf("expected first resource name 'db', got %v", resources[0]["name"]) + } + if result.Output["count"] != 2 { + t.Errorf("expected count=2, got %v", result.Output["count"]) + } +} + +func TestIaCProviderListStep_Execute_UnregisteredProvider(t *testing.T) { + app := module.NewMockApplication() + factory := module.NewIaCProviderListStepFactory() + step, err := factory("list-step", map[string]any{"provider": "nonexistent"}, 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 unregistered provider, got nil") + } + if want := "not registered"; !containsString(err.Error(), want) { + t.Errorf("expected error containing %q, got %q", want, err.Error()) + } +} + +func TestIaCProviderListStep_Factory_RequiresProvider(t *testing.T) { + factory := module.NewIaCProviderListStepFactory() + _, err := factory("list-step", map[string]any{}, nil) + if err == nil { + t.Fatal("expected error when 'provider' missing, got nil") + } +} + +func TestIaCProviderListStep_Factory_MalformedRefs_WrongTopType(t *testing.T) { + // refs present but wrong top-level type (string instead of []any) — must error + // at factory time, not silently fall through to unfiltered list-all. + factory := module.NewIaCProviderListStepFactory() + _, err := factory("list-step", map[string]any{ + "provider": "my-provider", + "refs": "not-a-list", + }, nil) + if err == nil { + t.Fatal("expected factory error for non-list 'refs', got nil") + } + if want := "refs' must be a list"; !containsString(err.Error(), want) { + t.Errorf("expected error containing %q, got: %v", want, err) + } +} + +func TestIaCProviderListStep_Factory_MalformedRefs_WrongItemType(t *testing.T) { + // refs is a list but contains a non-map item — must error at factory time. + factory := module.NewIaCProviderListStepFactory() + _, err := factory("list-step", map[string]any{ + "provider": "my-provider", + "refs": []any{"not-a-map"}, + }, nil) + if err == nil { + t.Fatal("expected factory error for non-map refs item, got nil") + } + if want := "refs[0] must be a map"; !containsString(err.Error(), want) { + t.Errorf("expected error containing %q, got: %v", want, err) + } +} + +func TestIaCProviderListStep_Factory_AbsentRefs_ListsAll(t *testing.T) { + // Absent refs key is fine — the step queries all resources. + app := module.NewMockApplication() + provider := &stubIaCProvider{ + statusResult: []interfaces.ResourceStatus{ + {Name: "db", Type: "infra.database", ProviderID: "pid-1", Status: "running"}, + }, + } + if err := app.RegisterService("my-provider", provider); err != nil { + t.Fatal(err) + } + factory := module.NewIaCProviderListStepFactory() + step, err := factory("list-step", map[string]any{"provider": "my-provider"}, app) + if err != nil { + t.Fatalf("factory error for absent refs: %v", err) + } + result, err := step.Execute(context.Background(), &module.PipelineContext{}) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + if result.Output["count"] != 1 { + t.Errorf("expected count=1 for list-all, got %v", result.Output["count"]) + } +} + +// containsString is a test helper used across iac_provider step tests. +func containsString(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsSubstring(s, sub)) +} + +func containsSubstring(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/module/pipeline_step_iac_provider_plan.go b/module/pipeline_step_iac_provider_plan.go new file mode 100644 index 00000000..f510f9b7 --- /dev/null +++ b/module/pipeline_step_iac_provider_plan.go @@ -0,0 +1,244 @@ +package module + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "sort" + + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/workflow/iac/jitsubst" + "github.com/GoCodeAlone/workflow/interfaces" +) + +// ─── step.iac_provider_plan ────────────────────────────────────────────────── + +// IaCProviderPlanStep resolves an IaCProvider, fetches current state, computes +// a DesiredStateHash with a NO-OP env resolver, builds a plan, and returns the +// plan JSON plus the stable hash. +type IaCProviderPlanStep struct { + name string + provider string + env string + specs []interfaces.ResourceSpec + app modular.Application +} + +// NewIaCProviderPlanStepFactory returns a StepFactory for step.iac_provider_plan. +func NewIaCProviderPlanStepFactory() StepFactory { + 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_plan step %q: 'provider' is required", name) + } + env, _ := cfg["env"].(string) + + // Parse specs from config. Each spec is a map[string]any with at least + // name and type fields; config sub-map is optional. + specs, err := parseResourceSpecs(cfg["specs"]) + if err != nil { + return nil, fmt.Errorf("iac_provider_plan step %q: parse specs: %w", name, err) + } + + return &IaCProviderPlanStep{ + name: name, + provider: providerName, + env: env, + specs: specs, + app: app, + }, nil + } +} + +// parseResourceSpecs converts a raw config value ([]any of map[string]any) into +// []interfaces.ResourceSpec. A nil or missing "specs" key is allowed (returns empty +// slice) for providers that derive specs internally. +func parseResourceSpecs(raw any) ([]interfaces.ResourceSpec, error) { + if raw == nil { + return nil, nil + } + list, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("specs must be a list, got %T", raw) + } + specs := make([]interfaces.ResourceSpec, 0, len(list)) + for i, item := range list { + m, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("specs[%d] must be a map, got %T", i, item) + } + spec := interfaces.ResourceSpec{} + if n, ok := m["name"].(string); ok { + spec.Name = n + } + if t, ok := m["type"].(string); ok { + spec.Type = t + } + if c, ok := m["config"].(map[string]any); ok { + spec.Config = c + } + if sz, ok := m["size"].(string); ok { + spec.Size = interfaces.Size(sz) + } + specs = append(specs, spec) + } + return specs, nil +} + +// parseResourceRefs converts a raw config value to []interfaces.ResourceRef. +func parseResourceRefs(raw any) ([]interfaces.ResourceRef, error) { + if raw == nil { + return nil, nil + } + list, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("refs must be a list, got %T", raw) + } + refs := make([]interfaces.ResourceRef, 0, len(list)) + for i, item := range list { + m, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("refs[%d] must be a map, got %T", i, item) + } + ref := interfaces.ResourceRef{} + if n, ok := m["name"].(string); ok { + ref.Name = n + } + if t, ok := m["type"].(string); ok { + ref.Type = t + } + if pid, ok := m["provider_id"].(string); ok { + ref.ProviderID = pid + } + refs = append(refs, ref) + } + return refs, nil +} + +func (s *IaCProviderPlanStep) Name() string { return s.name } + +func (s *IaCProviderPlanStep) Execute(ctx context.Context, _ *PipelineContext) (*StepResult, error) { + provider, err := resolveIaCProvider(s.app, s.provider, s.name, "iac_provider_plan") + if err != nil { + return nil, err + } + + // Get current resource states via Status with empty refs (list all). + statuses, err := provider.Status(ctx, nil) + if err != nil { + return nil, fmt.Errorf("iac_provider_plan step %q: Status: %w", s.name, err) + } + + // Convert statuses to ResourceState for hash computation. + current := statusesToResourceStates(statuses) + + // Compute desired state hash with a NO-OP env resolver so that + // ${ENV_VAR} and ${secret.*} placeholders hash as literal strings — + // same hash at plan time and at apply time regardless of env values. + desiredHash, err := computeDesiredStateHash(s.specs, current) + if err != nil { + return nil, fmt.Errorf("iac_provider_plan step %q: compute desired hash: %w", s.name, err) + } + + // Build the plan from the provider. + plan, err := provider.Plan(ctx, s.specs, current) + if err != nil { + return nil, fmt.Errorf("iac_provider_plan step %q: Plan: %w", s.name, err) + } + + // Attach the hash to the plan. + if plan != nil { + plan.DesiredHash = desiredHash + } + + // JSON-encode the plan for downstream consumers (step.json_response etc.). + planJSON, err := json.Marshal(plan) + if err != nil { + return nil, fmt.Errorf("iac_provider_plan step %q: marshal plan: %w", s.name, err) + } + var planAny any + if err := json.Unmarshal(planJSON, &planAny); err != nil { + return nil, fmt.Errorf("iac_provider_plan step %q: re-parse plan: %w", s.name, err) + } + + return &StepResult{Output: map[string]any{ + "plan": planAny, + "desired_hash": desiredHash, + "provider": s.provider, + }}, nil +} + +// computeDesiredStateHash returns a stable SHA-256 hex digest of the canonical +// desired-state inputs, exactly mirroring wfctlhelpers.DesiredStateHash but +// inlined here to avoid the module→wfctlhelpers→module import cycle +// (wfctlhelpers/state.go imports module/). The algorithm is identical: +// +// 1. Build syncedOutputs from current state (name → {id: providerID, ...}). +// 2. Resolve ONLY ${MODULE.field} refs using a no-op env lookup so that +// ${ENV_VAR} and ${secret.*} placeholders hash as their literal template +// strings — hash is stable across env-value changes. +// 3. Sort resolved specs by name for stable ordering. +// 4. SHA-256 over the canonical JSON. +// +// An error is returned if marshalling the resolved specs fails. Callers MUST +// treat this as a hard failure — a constant fallback would bypass the tamper/drift +// guard. +func computeDesiredStateHash(desired []interfaces.ResourceSpec, current []interfaces.ResourceState) (string, error) { + // Step 1: build syncedOutputs from current state. + syncedOutputs := make(map[string]map[string]any, len(current)) + for i := range current { + s := ¤t[i] + m := make(map[string]any, len(s.Outputs)+1) + for k, v := range s.Outputs { + m[k] = v + } + if s.ProviderID != "" { + m["id"] = s.ProviderID + } + syncedOutputs[s.Name] = m + } + + // Step 2: resolve specs with no-op env lookup (preserves ${ENV_VAR} verbatim). + noopEnv := func(string) (string, bool) { return "", false } + resolved := make([]interfaces.ResourceSpec, 0, len(desired)) + for _, spec := range desired { + r, _, err := jitsubst.TryResolveSpec(spec, nil, syncedOutputs, noopEnv) + if err != nil { + r = spec // malformed ref — use unresolved spec + } + resolved = append(resolved, r) + } + + // Step 3: sort by name for stable ordering. + sort.Slice(resolved, func(i, j int) bool { + return resolved[i].Name < resolved[j].Name + }) + + // Step 4: SHA-256 over the canonical JSON. A marshal error here is a hard + // failure — returning a constant fallback would silently match across plan and + // apply and bypass the tamper/drift guard. + data, err := json.Marshal(resolved) + if err != nil { + return "", fmt.Errorf("marshal resolved specs: %w", err) + } + sum := sha256.Sum256(data) + return fmt.Sprintf("%x", sum), nil +} + +// statusesToResourceStates converts []interfaces.ResourceStatus to +// []interfaces.ResourceState for use as the "current" input to Plan and +// DesiredStateHash. Only Name, Type, and ProviderID are populated; Outputs +// are carried over for hash stability. +func statusesToResourceStates(statuses []interfaces.ResourceStatus) []interfaces.ResourceState { + states := make([]interfaces.ResourceState, 0, len(statuses)) + for _, st := range statuses { + states = append(states, interfaces.ResourceState{ + Name: st.Name, + Type: st.Type, + ProviderID: st.ProviderID, + Outputs: st.Outputs, + }) + } + return states +} diff --git a/module/pipeline_step_iac_provider_plan_test.go b/module/pipeline_step_iac_provider_plan_test.go new file mode 100644 index 00000000..abf3c975 --- /dev/null +++ b/module/pipeline_step_iac_provider_plan_test.go @@ -0,0 +1,143 @@ +package module_test + +import ( + "context" + "testing" + "time" + + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/module" +) + +// ─── step.iac_provider_plan tests ──────────────────────────────────────────── + +func makePlanProvider(t *testing.T) *stubIaCProvider { + t.Helper() + return &stubIaCProvider{ + statusResult: []interfaces.ResourceStatus{ + {Name: "existing-db", Type: "infra.database", ProviderID: "pid-99", Status: "running"}, + }, + planResult: &interfaces.IaCPlan{ + ID: "plan-001", + CreatedAt: time.Now(), + Actions: []interfaces.PlanAction{ + { + Action: "create", + Resource: interfaces.ResourceSpec{ + Name: "my-db", + Type: "infra.database", + }, + }, + }, + }, + } +} + +func TestIaCProviderPlanStep_Execute_ReturnsPlanAndHash(t *testing.T) { + app := module.NewMockApplication() + provider := makePlanProvider(t) + if err := app.RegisterService("my-provider", provider); err != nil { + t.Fatal(err) + } + + cfg := map[string]any{ + "provider": "my-provider", + "specs": []any{ + map[string]any{"name": "my-db", "type": "infra.database"}, + }, + } + factory := module.NewIaCProviderPlanStepFactory() + step, err := factory("plan-step", cfg, 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) + } + + hash, ok := result.Output["desired_hash"].(string) + if !ok || hash == "" { + t.Errorf("expected non-empty desired_hash, got %v", result.Output["desired_hash"]) + } + + // Plan should be present and JSON-able. + if result.Output["plan"] == nil { + t.Error("expected plan in output, got nil") + } +} + +func TestIaCProviderPlanStep_HashStableWhenEnvVarChanges(t *testing.T) { + // The NO-OP env resolver in DesiredStateHash must preserve ${ENV_VAR} + // placeholders verbatim so the hash is identical regardless of env value. + app := module.NewMockApplication() + provider := makePlanProvider(t) + if err := app.RegisterService("my-provider", provider); err != nil { + t.Fatal(err) + } + + cfg := map[string]any{ + "provider": "my-provider", + "specs": []any{ + map[string]any{ + "name": "env-db", + "type": "infra.database", + "config": map[string]any{ + "password": "${DB_PASSWORD}", + }, + }, + }, + } + + factory := module.NewIaCProviderPlanStepFactory() + step, err := factory("plan-step", cfg, app) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + // First hash: env var set to "secret1". + t.Setenv("DB_PASSWORD", "secret1") + result1, err := step.Execute(context.Background(), &module.PipelineContext{}) + if err != nil { + t.Fatalf("Execute (run 1) error: %v", err) + } + hash1 := result1.Output["desired_hash"].(string) + + // Second hash: env var changed to "secret2". + t.Setenv("DB_PASSWORD", "secret2") + result2, err := step.Execute(context.Background(), &module.PipelineContext{}) + if err != nil { + t.Fatalf("Execute (run 2) error: %v", err) + } + hash2 := result2.Output["desired_hash"].(string) + + if hash1 != hash2 { + t.Errorf("expected hash to be stable when env var value changes:\n hash1=%s\n hash2=%s", hash1, hash2) + } +} + +func TestIaCProviderPlanStep_UnregisteredProvider(t *testing.T) { + app := module.NewMockApplication() + factory := module.NewIaCProviderPlanStepFactory() + step, err := factory("plan-step", map[string]any{"provider": "none"}, 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 unregistered provider") + } + if !containsString(err.Error(), "not registered") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestIaCProviderPlanStep_Factory_RequiresProvider(t *testing.T) { + factory := module.NewIaCProviderPlanStepFactory() + _, err := factory("plan-step", map[string]any{}, nil) + if err == nil { + t.Fatal("expected error when 'provider' missing") + } +} diff --git a/plugins/platform/plugin.go b/plugins/platform/plugin.go index 816e71c4..072c1fb5 100644 --- a/plugins/platform/plugin.go +++ b/plugins/platform/plugin.go @@ -4,13 +4,25 @@ package platform import ( + "context" + "github.com/GoCodeAlone/modular" "github.com/GoCodeAlone/workflow/handlers" + "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" + "github.com/GoCodeAlone/workflow/interfaces" "github.com/GoCodeAlone/workflow/module" "github.com/GoCodeAlone/workflow/plugin" "github.com/GoCodeAlone/workflow/schema" ) +// iacProviderApplyFn is the apply dispatch function passed to +// NewIaCProviderApplyStepFactory. It wraps wfctlhelpers.ApplyPlanWithHooks +// with an empty hooks struct so the step's function signature +// (ctx, provider, plan) is satisfied without the step importing wfctlhelpers. +func iacProviderApplyFn(ctx context.Context, p interfaces.IaCProvider, plan *interfaces.IaCPlan) (*interfaces.ApplyResult, error) { + return wfctlhelpers.ApplyPlanWithHooks(ctx, p, plan, wfctlhelpers.ApplyPlanHooks{}) +} + // Plugin is the platform EnginePlugin. type Plugin struct { plugin.BaseEnginePlugin @@ -32,7 +44,7 @@ func New() *Plugin { 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.dns", "platform.region", "platform.region_router", "iac.state", "app.container", "argo.workflows"}, - StepTypes: []string{"step.platform_template", "step.k8s_plan", "step.k8s_apply", "step.k8s_status", "step.k8s_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.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"}, + StepTypes: []string{"step.platform_template", "step.k8s_plan", "step.k8s_apply", "step.k8s_status", "step.k8s_destroy", "step.iac_plan", "step.iac_apply", "step.iac_status", "step.iac_destroy", "step.iac_drift_detect", "step.iac_provider_list", "step.iac_provider_catalog", "step.iac_provider_plan", "step.iac_provider_apply", "step.iac_provider_destroy", "step.iac_provider_drift", "step.dns_plan", "step.dns_apply", "step.dns_status", "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"}, TriggerTypes: []string{"reconciliation"}, WorkflowTypes: []string{"platform"}, }, @@ -121,6 +133,25 @@ func (p *Plugin) StepFactories() map[string]plugin.StepFactory { "step.iac_drift_detect": func(name string, cfg map[string]any, app modular.Application) (any, error) { return module.NewIaCDriftDetectStepFactory()(name, cfg, app) }, + // IaCProvider steps (general, provider-agnostic). + "step.iac_provider_list": func(name string, cfg map[string]any, app modular.Application) (any, error) { + return module.NewIaCProviderListStepFactory()(name, cfg, app) + }, + "step.iac_provider_catalog": func(name string, cfg map[string]any, app modular.Application) (any, error) { + return module.NewIaCProviderCatalogStepFactory()(name, cfg, app) + }, + "step.iac_provider_plan": func(name string, cfg map[string]any, app modular.Application) (any, error) { + return module.NewIaCProviderPlanStepFactory()(name, cfg, app) + }, + "step.iac_provider_apply": func(name string, cfg map[string]any, app modular.Application) (any, error) { + return module.NewIaCProviderApplyStepFactory(iacProviderApplyFn)(name, cfg, app) + }, + "step.iac_provider_destroy": func(name string, cfg map[string]any, app modular.Application) (any, error) { + return module.NewIaCProviderDestroyStepFactory()(name, cfg, app) + }, + "step.iac_provider_drift": func(name string, cfg map[string]any, app modular.Application) (any, error) { + return module.NewIaCProviderDriftStepFactory()(name, cfg, app) + }, "step.dns_plan": func(name string, cfg map[string]any, app modular.Application) (any, error) { return module.NewDNSPlanStepFactory()(name, cfg, app) }, diff --git a/plugins/platform/plugin_test.go b/plugins/platform/plugin_test.go index eaee1efe..8a6182a1 100644 --- a/plugins/platform/plugin_test.go +++ b/plugins/platform/plugin_test.go @@ -41,6 +41,12 @@ func TestStepFactories(t *testing.T) { "step.iac_status", "step.iac_destroy", "step.iac_drift_detect", + "step.iac_provider_list", + "step.iac_provider_catalog", + "step.iac_provider_plan", + "step.iac_provider_apply", + "step.iac_provider_destroy", + "step.iac_provider_drift", "step.dns_plan", "step.dns_apply", "step.dns_status", diff --git a/schema/module_schema.go b/schema/module_schema.go index 655c267f..80120ed5 100644 --- a/schema/module_schema.go +++ b/schema/module_schema.go @@ -2852,6 +2852,12 @@ func (r *ModuleSchemaRegistry) registerBuiltins() { {"step.iac_destroy", "IaC Destroy", "Destroys IaC-managed infrastructure"}, {"step.iac_drift_detect", "IaC Drift Detect", "Detects IaC configuration drift"}, {"step.iac_plan", "IaC Plan", "Plans infrastructure changes without applying"}, + {"step.iac_provider_apply", "IaC Provider Apply", "Two-phase hash-guard apply via IaCProvider service"}, + {"step.iac_provider_catalog", "IaC Provider Catalog", "Fetches regions and resource types from IaCProvider service"}, + {"step.iac_provider_destroy", "IaC Provider Destroy", "Destroys resources via IaCProvider service"}, + {"step.iac_provider_drift", "IaC Provider Drift", "Detects drift via IaCProvider service"}, + {"step.iac_provider_list", "IaC Provider List", "Lists resource statuses from IaCProvider service"}, + {"step.iac_provider_plan", "IaC Provider Plan", "Plans infrastructure changes via IaCProvider service"}, {"step.iac_status", "IaC Status", "Gets IaC provisioning status"}, {"step.k8s_apply", "K8s Apply", "Applies Kubernetes manifests"}, {"step.k8s_destroy", "K8s Destroy", "Deletes Kubernetes resources"}, diff --git a/schema/schema.go b/schema/schema.go index 432aaca8..0ef8316a 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -312,6 +312,12 @@ var coreModuleTypes = []string{ "step.iac_destroy", "step.iac_drift_detect", "step.iac_plan", + "step.iac_provider_apply", + "step.iac_provider_catalog", + "step.iac_provider_destroy", + "step.iac_provider_drift", + "step.iac_provider_list", + "step.iac_provider_plan", "step.iac_status", "step.jq", "step.json_parse", diff --git a/schema/step_schema_builtins.go b/schema/step_schema_builtins.go index 1ed295b5..1adb08bf 100644 --- a/schema/step_schema_builtins.go +++ b/schema/step_schema_builtins.go @@ -2103,6 +2103,114 @@ func (r *StepSchemaRegistry) registerBuiltins() { }, }) + // ---- IaC Provider List ---- + + r.Register(&StepSchema{ + Type: "step.iac_provider_list", + Plugin: "platform", + Description: "Lists current resource statuses from an IaCProvider service.", + ConfigFields: []ConfigFieldDef{ + {Key: "provider", Type: FieldTypeString, Description: "Name of the registered IaCProvider service", Required: true}, + {Key: "refs", Type: FieldTypeArray, Description: "Optional list of resource refs to query; empty queries all"}, + }, + Outputs: []StepOutputDef{ + {Key: "provider", Type: "string", Description: "Provider service name"}, + {Key: "resources", Type: "[]any", Description: "Resource status summaries"}, + {Key: "count", Type: "number", Description: "Number of resources returned"}, + }, + }) + + // ---- IaC Provider Catalog ---- + + r.Register(&StepSchema{ + Type: "step.iac_provider_catalog", + Plugin: "platform", + Description: "Fetches provider regions and supported resource types; falls back to a static region list when the provider does not advertise IaCProviderRegionLister.", + ConfigFields: []ConfigFieldDef{ + {Key: "provider", Type: FieldTypeString, Description: "Name of the registered IaCProvider service", Required: true}, + {Key: "env", Type: FieldTypeString, Description: "Environment name forwarded to ListRegions for env-scoped region lists"}, + }, + Outputs: []StepOutputDef{ + {Key: "provider", Type: "string", Description: "Provider service name"}, + {Key: "regions", Type: "[]any", Description: "Available region identifiers"}, + {Key: "types", Type: "[]any", Description: "Supported resource type capability declarations"}, + {Key: "source", Type: "string", Description: "Region source: live | static | static_fallback_error"}, + }, + }) + + // ---- IaC Provider Plan ---- + + r.Register(&StepSchema{ + Type: "step.iac_provider_plan", + Plugin: "platform", + Description: "Plans infrastructure changes against an IaCProvider service; computes a stable desired-state hash using a no-op env resolver.", + ConfigFields: []ConfigFieldDef{ + {Key: "provider", Type: FieldTypeString, Description: "Name of the registered IaCProvider service", Required: true}, + {Key: "specs", Type: FieldTypeArray, Description: "Desired resource specs (list of {name, type, config, size})"}, + {Key: "env", Type: FieldTypeString, Description: "Environment name (reserved; unused by hash computation)"}, + }, + Outputs: []StepOutputDef{ + {Key: "plan", Type: "any", Description: "IaCPlan with DesiredHash set"}, + {Key: "desired_hash", Type: "string", Description: "SHA-256 hex of canonical desired state"}, + {Key: "provider", Type: "string", Description: "Provider service name"}, + }, + }) + + // ---- IaC Provider Apply ---- + + r.Register(&StepSchema{ + Type: "step.iac_provider_apply", + Plugin: "platform", + Description: "Applies an IaC plan via two-phase hash guard: recomputes DesiredStateHash and rejects with 'plan hash mismatch' when hashes diverge.", + ConfigFields: []ConfigFieldDef{ + {Key: "provider", Type: FieldTypeString, Description: "Name of the registered IaCProvider service", Required: true}, + {Key: "specs", Type: FieldTypeArray, Description: "Desired resource specs passed to plan and hash recomputation"}, + {Key: "desired_hash", Type: FieldTypeString, Description: "Client-submitted hash from the plan step; must match recomputed hash", Required: true}, + }, + Outputs: []StepOutputDef{ + {Key: "apply_result", Type: "any", Description: "ApplyResult from wfctlhelpers.ApplyPlanWithHooks"}, + {Key: "desired_hash", Type: "string", Description: "Recomputed desired-state hash"}, + {Key: "provider", Type: "string", Description: "Provider service name"}, + {Key: "action_count", Type: "number", Description: "Number of plan actions dispatched"}, + }, + }) + + // ---- IaC Provider Destroy ---- + + r.Register(&StepSchema{ + Type: "step.iac_provider_destroy", + Plugin: "platform", + Description: "Destroys resources via an IaCProvider service.", + ConfigFields: []ConfigFieldDef{ + {Key: "provider", Type: FieldTypeString, Description: "Name of the registered IaCProvider service", Required: true}, + {Key: "refs", Type: FieldTypeArray, Description: "Resource refs to destroy (list of {name, type, provider_id})"}, + }, + Outputs: []StepOutputDef{ + {Key: "destroyed", Type: "[]any", Description: "Names of destroyed resources"}, + {Key: "destroy_errors", Type: "[]any", Description: "Per-resource errors if any"}, + {Key: "provider", Type: "string", Description: "Provider service name"}, + }, + }) + + // ---- IaC Provider Drift ---- + + r.Register(&StepSchema{ + Type: "step.iac_provider_drift", + Plugin: "platform", + Description: "Detects drift via an IaCProvider service; uses config-aware DetectDriftWithSpecs when the provider advertises DriftDetectorProvider, else falls back to existence-only DetectDrift.", + ConfigFields: []ConfigFieldDef{ + {Key: "provider", Type: FieldTypeString, Description: "Name of the registered IaCProvider service", Required: true}, + {Key: "refs", Type: FieldTypeArray, Description: "Resource refs to check (list of {name, type, provider_id})"}, + }, + Outputs: []StepOutputDef{ + {Key: "provider", Type: "string", Description: "Provider service name"}, + {Key: "supported", Type: "boolean", Description: "Whether drift detection is supported"}, + {Key: "any_drifted", Type: "boolean", Description: "Whether any resource has drifted"}, + {Key: "drifts", Type: "[]any", Description: "Per-resource drift results"}, + {Key: "count", Type: "number", Description: "Number of resources checked"}, + }, + }) + // ---- Kubernetes Apply ---- r.Register(&StepSchema{ diff --git a/schema/testdata/editor-schemas.golden.json b/schema/testdata/editor-schemas.golden.json index e5b8f74c..10fb3c71 100644 --- a/schema/testdata/editor-schemas.golden.json +++ b/schema/testdata/editor-schemas.golden.json @@ -6013,6 +6013,48 @@ "description": "Plans infrastructure changes without applying", "configFields": [] }, + "step.iac_provider_apply": { + "type": "step.iac_provider_apply", + "label": "IaC Provider Apply", + "category": "pipeline", + "description": "Two-phase hash-guard apply via IaCProvider service", + "configFields": [] + }, + "step.iac_provider_catalog": { + "type": "step.iac_provider_catalog", + "label": "IaC Provider Catalog", + "category": "pipeline", + "description": "Fetches regions and resource types from IaCProvider service", + "configFields": [] + }, + "step.iac_provider_destroy": { + "type": "step.iac_provider_destroy", + "label": "IaC Provider Destroy", + "category": "pipeline", + "description": "Destroys resources via IaCProvider service", + "configFields": [] + }, + "step.iac_provider_drift": { + "type": "step.iac_provider_drift", + "label": "IaC Provider Drift", + "category": "pipeline", + "description": "Detects drift via IaCProvider service", + "configFields": [] + }, + "step.iac_provider_list": { + "type": "step.iac_provider_list", + "label": "IaC Provider List", + "category": "pipeline", + "description": "Lists resource statuses from IaCProvider service", + "configFields": [] + }, + "step.iac_provider_plan": { + "type": "step.iac_provider_plan", + "label": "IaC Provider Plan", + "category": "pipeline", + "description": "Plans infrastructure changes via IaCProvider service", + "configFields": [] + }, "step.iac_status": { "type": "step.iac_status", "label": "IaC Status",