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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 75 additions & 21 deletions module/pipeline_step_iac_provider_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"

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

Expand All @@ -18,12 +19,14 @@ import (
// 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)
name string
provider string
submittedHash string
desiredHashFrom string // dotted context path; mutually exclusive with submittedHash
specs []interfaces.ResourceSpec
specsFrom string // dotted context path; mutually exclusive with specs
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.
Expand All @@ -39,30 +42,81 @@ func NewIaCProviderApplyStepFactory(applyFn func(ctx context.Context, p interfac
if providerName == "" {
return nil, fmt.Errorf("iac_provider_apply step %q: 'provider' is required", name)
}

specsFrom, _ := cfg["specs_from"].(string)
_, hasStaticSpecs := cfg["specs"]
if specsFrom != "" && hasStaticSpecs {
return nil, fmt.Errorf("iac_provider_apply step %q: 'specs' and 'specs_from' are mutually exclusive", name)
}

desiredHashFrom, _ := cfg["desired_hash_from"].(string)
submittedHash, _ := cfg["desired_hash"].(string)
if submittedHash == "" {
return nil, fmt.Errorf("iac_provider_apply step %q: 'desired_hash' is required", name)
// Use key-presence (not value) for the one-of check, mirroring specs/specs_from,
// so a config carrying both keys is rejected even if one decodes to null/"".
_, hasStaticHash := cfg["desired_hash"]
_, hasHashFrom := cfg["desired_hash_from"]
if hasHashFrom && hasStaticHash {
return nil, fmt.Errorf("iac_provider_apply step %q: 'desired_hash' and 'desired_hash_from' are mutually exclusive", name)
}
// Require exactly one hash source.
if !hasHashFrom && !hasStaticHash {
return nil, fmt.Errorf("iac_provider_apply step %q: one of 'desired_hash' or 'desired_hash_from' is required", name)
}
Comment thread
intel352 marked this conversation as resolved.

specs, err := parseResourceSpecs(cfg["specs"])
if err != nil {
return nil, fmt.Errorf("iac_provider_apply step %q: parse specs: %w", name, err)
// Parse static specs (skipped when specs_from is set).
var specs []interfaces.ResourceSpec
if hasStaticSpecs {
var err error
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,
name: name,
provider: providerName,
submittedHash: submittedHash,
desiredHashFrom: desiredHashFrom,
specs: specs,
specsFrom: specsFrom,
app: app,
applyFn: applyFn,
}, nil
}
}

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

func (s *IaCProviderApplyStep) Execute(ctx context.Context, _ *PipelineContext) (*StepResult, error) {
func (s *IaCProviderApplyStep) Execute(ctx context.Context, pc *PipelineContext) (*StepResult, error) {
// Resolve specs: dynamic path takes precedence when specsFrom is configured.
specs := s.specs
if s.specsFrom != "" {
raw := resolveBodyFrom(s.specsFrom, pc)
var err error
specs, err = specparse.ParseResourceSpecs(raw)
if err != nil {
return nil, fmt.Errorf("iac_provider_apply step %q: resolve specs_from %q: %w", s.name, s.specsFrom, err)
}
// Guard against zero specs: ParseResourceSpecs returns a non-nil empty
// slice for []any{}, so a len check (not a nil check) is required —
// applying over zero specs is a destroy-everything footgun.
if len(specs) == 0 {
return nil, fmt.Errorf("iac_provider_apply step %q: specs_from %q resolved to empty/zero specs", s.name, s.specsFrom)
}
}

// Resolve submitted hash: dynamic path takes precedence when desiredHashFrom is configured.
submittedHash := s.submittedHash
if s.desiredHashFrom != "" {
raw := resolveBodyFrom(s.desiredHashFrom, pc)
var ok bool
submittedHash, ok = raw.(string)
if !ok || submittedHash == "" {
return nil, fmt.Errorf("iac_provider_apply step %q: desired_hash_from %q did not resolve to a non-empty string (got %T)", s.name, s.desiredHashFrom, raw)
}
}

provider, err := resolveIaCProvider(s.app, s.provider, s.name, "iac_provider_apply")
if err != nil {
return nil, err
Expand All @@ -74,18 +128,18 @@ func (s *IaCProviderApplyStep) Execute(ctx context.Context, _ *PipelineContext)
return nil, fmt.Errorf("iac_provider_apply step %q: Status: %w", s.name, err)
}
current := statusesToResourceStates(statuses)
recomputedHash, err := computeDesiredStateHash(s.specs, current)
recomputedHash, err := computeDesiredStateHash(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 {
if recomputedHash != 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)
plan, err := provider.Plan(ctx, specs, current)
if err != nil {
return nil, fmt.Errorf("iac_provider_apply step %q: Plan: %w", s.name, err)
}
Expand Down
Loading
Loading