wfctlhelpers

package
v0.65.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 30, 2026 License: MIT Imports: 19 Imported by: 0

Documentation

Overview

Package wfctlhelpers hosts the wfctl-side dispatch helper for v2 IaC plugins. wfctl calls ApplyPlanWithHooks when a plugin's typed capability response declares compute_plan_version: "v2". The helper iterates plan.Actions, fetches the matching ResourceDriver from the provider, and dispatches each action to a per-action sub-function (doCreate, doUpdate, doReplace, doDelete).

Action lifecycle versions (workflow#640 migration)

ApplyPlanWithHooks is the only exported plan-execution helper. Its caller-supplied per-action OnResourceApplied / OnResourceDeleted hooks fire at each successful cloud-mutation boundary. Required for #640's invariants.

See docs/migrations/2026-05-16-v2-lifecycle-phase1-inventory.md and decisions/0040-v2-action-lifecycle-provider-compatibility.md for the final migration closeout.

Lifecycle inside W-3a:

  • T3.1 (this file's dispatch helper + skeleton sub-functions)
  • T3.1.5 — wraps dispatch with the input-drift postcondition
  • T3.2 — fills doCreate with UpsertSupporter recovery
  • T3.3 — fills doUpdate + doDelete (the latent doDelete bug fix)
  • T3.4 — fills doReplace and populates ApplyResult.ReplaceIDMap

workflow#743 removed the former ApplyPlan wrapper after all runtime paths moved to ApplyPlanWithHooks.

Per-action error-prefix policy

Sub-functions follow a "decompose-then-prefix" rule for the strings recorded in interfaces.ApplyResult.Errors[].Error:

  • doCreate, doUpdate, doDelete pass driver errors through unchanged. The ActionError struct already carries Resource + Action context fields, so a per-kind prefix would be redundant.
  • doCreate's upsert recovery path prefixes "upsert: " (e.g., "upsert: read after conflict: ...") because the failure is specifically about the recovery flow, not the original Create.
  • doReplace prefixes "replace: delete: " or "replace: create: " because a Replace decomposes into two driver calls — without the prefix, an operator reading result.Errors couldn't tell which sub-step failed.

Tests in apply_update_delete_test.go and apply_replace_test.go lock this contract via exact-string assertions; future refactors that drop or rename a prefix fail loudly.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func ApplyPlanWithHooks added in v0.51.3

ApplyPlanWithHooks dispatches plan actions plus action-boundary hooks for callers that need durable side effects as each cloud mutation succeeds.

func DefaultReplace added in v0.23.0

DefaultReplace is the engine's default Replace dispatcher: Delete the resource at action.Current, then Create from action.Resource. Public so drivers that opt into ResourceReplacer can delegate a particular spec to engine-default behavior without a sentinel-error round-trip.

Decomposes a Replace action into Delete-then-Create on the driver and propagates the new ProviderID through result.ReplaceIDMap[action.Resource.Name] so the JIT substitution wired into the helper loop (T5.2) can patch dependent resources whose configs reference the replaced resource by name.

Cascade contract (T5.3)

When a plan has [Replace parent, X dependent] where dependent's Config carries ${parent.id}, the cascade lands automatically: DefaultReplace's post-Create write to result.ReplaceIDMap completes BEFORE the dispatch loop's next iteration calls jitsubst.ResolveSpec on the dependent's spec, so the dependent's driver call (Create or Replace's post-Delete Create) sees the freshly-resolved parent ProviderID. Delete continues to use action.Current.ProviderID via refFromAction — JIT substitution does NOT alter action.Current, so Replace's Delete still targets the pre-Replace cloud resource.

Verified by apply_replace_cascade_test.go.

Failure semantics:

  • Delete fails → return wrapped "replace: delete: <err>"; Create does NOT run; ReplaceIDMap is NOT populated for this resource.
  • Delete succeeds, ctx canceled before Create → return wrapped "replace: canceled after delete: <err>"; Create does NOT run; ReplaceIDMap is NOT populated. The half-replaced state is the operator's recovery surface (same as the Create-fails case).
  • Delete succeeds, Create fails → return wrapped "replace: create: <err>"; ReplaceIDMap stays empty for this resource. Operators inspect the apply log + the empty-for-this- name slot in ReplaceIDMap to know which resources are in a half-replaced state and need manual cloud restoration.
  • Both succeed → result.Resources gets the new output appended, result.ReplaceIDMap[action.Resource.Name] = new ProviderID. Map is lazily-initialized on first successful Replace so plans with no Replace actions don't carry an empty map through serialisation.

The "replace: ..." prefix is essential because Replace decomposes into two driver calls — without it, an operator reading result.Errors couldn't tell whether the Delete or the Create failed. Other sub-functions (doCreate non-recovery path, doUpdate, doDelete) pass driver errors through unchanged.

func IsContainerType added in v0.64.8

func IsContainerType(t string) bool

IsContainerType returns true for module types that accept env_vars defaults from top-level environments[env]. cmd/wfctl/infra.go: isContainerType is a one-line shim that delegates here.

func IsInfraType added in v0.64.8

func IsInfraType(t string) bool

IsInfraType returns true for module types in the infra.*/platform.* namespaces. cmd/wfctl/infra.go:isInfraType is a one-line shim that delegates here so the two codepaths cannot drift.

func IsNoopStateStore added in v0.64.8

func IsNoopStateStore(s any) bool

IsNoopStateStore reports whether the resolved store is the no-op fallback returned when no iac.state module is configured. Accepts any concrete or interface value so wfctl-side subset-interface holders can check without having to widen their static type to interfaces.IaCStateStore.

func LoadAllIaCProvidersFromConfig added in v0.64.8

func LoadAllIaCProvidersFromConfig(ctx context.Context, cfgFile string) (map[string]interfaces.IaCProvider, []io.Closer, error)

LoadAllIaCProvidersFromConfig finds EVERY iac.provider module in cfgFile and resolves each one, returning them as a map keyed by module name (so the handler library + ListProviders response can attribute each Provider record to its declared module). The caller-returned []io.Closer carries one entry per resolved provider in declaration order; closing them releases the underlying plugin subprocesses.

Per design doc cycle-4 Important #6 (resolved by plan §Task 3): LoadIaCProviderFromConfig is first-match-only, which is correct for the wfctl single-cloud bootstrap path but insufficient for the admin-UI handler library that lists all configured providers.

On resolver failure for any provider, the helper closes every previously-resolved provider (best-effort) and returns (nil, nil, error) so callers cannot accidentally leak subprocesses they have no handle to release. iac.provider modules missing a `provider:` field are silently skipped (consistent with LoadIaCProviderFromConfig's single-module behavior).

Invariant: cfg.Modules has unique Names — enforced upstream by config.LoadFromFile. If two iac.provider modules ever shared a name (config-validation bug), the later one would silently overwrite the earlier in the map while the earlier's closer still gets released by the caller; per code-reviewer T3 M-1 (commit 9dff95246) this is acceptable today but worth documenting so future readers know the uniqueness assumption is load-bearing.

func LoadIaCProviderFromConfig added in v0.64.8

func LoadIaCProviderFromConfig(ctx context.Context, cfgFile string) (interfaces.IaCProvider, io.Closer, error)

LoadIaCProviderFromConfig finds the first iac.provider module in cfgFile and resolves it via the registered Resolver. Returns (nil, nil, nil) — NOT an error — when no iac.provider module is declared, so callers can treat "provider not available" as a reportable-but-non-fatal condition. The returned io.Closer (when non-nil) MUST be closed by the caller.

Lifted from cmd/wfctl/infra_bootstrap.go:loadIaCProviderFromConfig per docs/plans/2026-05-27-infra-admin-dynamic.md Task 2 so the in-tree wfctl bootstrap path and the upcoming infra.admin CLI subcommands (T19-T20) share one definition.

func ResolveStateStore added in v0.64.8

func ResolveStateStore(cfgFile, envName, pluginDir string) (interfaces.IaCStateStore, error)

ResolveStateStore loads cfgFile, finds the iac.state module, and returns its backend as a full interfaces.IaCStateStore. envName is forwarded for per-environment backend config resolution; empty = no env overrides.

pluginDir locates plugin binaries for plugin-served backends (spaces/s3/gcs). The lookup order is:

  1. pluginDir argument when non-empty
  2. WFCTL_PLUGIN_DIR environment variable
  3. "./data/plugins" (legacy default)

The host-side infra.admin module (workflow/module/infra_admin.go) is expected to pass an empty string and rely on the WFCTL_PLUGIN_DIR fallback so a single env var configures both CLI and module. The CLI passes its `currentInfraPluginDir` seam variable to honor the --plugin-dir flag.

Returns a no-op store (not an error) when no iac.state module is declared so first-run callers get silent no-op persistence — same behavior as the wfctl-internal resolveStateStore this helper was lifted from per docs/plans/2026-05-27-infra-admin-dynamic.md Task 1.

Per design doc cycle-5 row 4: out-of-subset methods on the returned store (Lock, SavePlan, GetPlan) panic with a clear message. The handler library and host module call only the subset {SaveResource, GetResource, ListResources, DeleteResource, Close}.

func SanitizeStateID added in v0.64.8

func SanitizeStateID(id string) string

SanitizeStateID returns a filesystem-safe filename for a resource ID by replacing the four path-hostile characters (/, \, :, *) with underscore. Matches cmd/wfctl/infra_state.go:sanitizeStateID byte-for-byte so files written via either path are mutually readable. cmd/wfctl's version is a one-line shim that delegates here. Code-reviewer M-3 caught an earlier draft that used a stricter allowlist; this version honors the existing on-disk format.

func ValidateAllowReplaceProtected

func ValidateAllowReplaceProtected(plan interfaces.IaCPlan, allow map[string]struct{}) error

ValidateAllowReplaceProtected gates dispatch on the per-resource `protected: true` annotation. It walks every replace or delete action in plan and aggregates ALL blockers (resources protected and not in `allow`) into a single error before returning, so the operator sees the full set of names in one apply attempt and can authorize them with one round-trip via the pre-formatted --allow-replace=<csv> value.

Format (W-6/T6.2):

plan would require destructive action on N protected resource(s):
  <name1> (replace)
  <name2> (delete)
  ...
to authorize, re-run with:
  --allow-replace=<name1>,<name2>,...

Both names and the csv preserve plan-action declaration order so the output is deterministic across runs.

`protected: true` is sourced from PlanAction.Resource.Config for replace actions (where Resource carries the desired spec) and from PlanAction.Current.AppliedConfig for delete actions (where platform.differ leaves Resource.Config empty and the protected status is preserved on the previously-applied state).

Promoted from cmd/wfctl in W-7/T7.9 so the iac/conformance suite can exercise the gate contract without importing package main. The cmd/wfctl validateAllowReplaceProtected wrapper now delegates here; behavior and error format are byte-identical with the pre-W-7 implementation, so all existing tests in cmd/wfctl continue to pass without modification.

func WriteEnvResolvedConfig added in v0.64.8

func WriteEnvResolvedConfig(cfgFile, envName string) (tmpPath string, err error)

WriteEnvResolvedConfig loads cfgFile (honoring imports:), resolves every module for envName (ResolveForEnv is called on ALL module types so that environments[envName]: null is honored for iac.*, cloud.account, etc.), applies top-level environments[env] defaults, and writes the entire WorkflowConfig back to a temp file. The caller must defer os.Remove(tmpPath).

Per docs/plans/2026-05-27-infra-admin-dynamic.md Task 1 this is the shared lift; cmd/wfctl/infra_env_resolve.go's writeEnvResolvedConfig is a one-line shim that delegates here to avoid double maintenance.

Types

type ApplyPlanHooks added in v0.51.3

type ApplyPlanHooks struct {
	// OnBeforeAction fires PRE-DISPATCH for every PlanAction, after the
	// per-iteration ctx.Err() check but before JIT substitution / driver
	// resolution / cloud-side mutation. The intended use case is policy /
	// ownership gates that can deny a record-level change before any
	// cloud-side state moves (see workflow/dns/gate + the wfctl dns-policy
	// surface).
	//
	// FATAL semantics (cycle 3.5 I-NEW-2): a non-nil return aborts the
	// per-action loop with a wrapped error — no further actions dispatch,
	// no further hook invocations fire, and the top-level
	// ApplyPlanWithHooks return value is the wrapped error. This is NOT
	// best-effort. Operators rely on "policy denied" being a hard-stop;
	// silently continuing past a gate denial would defeat the purpose of
	// having a gate.
	//
	// Distinct from OnResourceApplied / OnResourceDeleted (post-dispatch,
	// best-effort persistence hooks) — those record state AFTER cloud-side
	// work; OnBeforeAction decides whether cloud-side work happens at all.
	OnBeforeAction    func(context.Context, interfaces.PlanAction) error
	OnResourceApplied func(context.Context, interfaces.ResourceDriver, interfaces.PlanAction, interfaces.ResourceOutput) error
	OnResourceDeleted func(context.Context, interfaces.PlanAction) error
	// OnPlanComplete fires once after the per-action loop reaches its
	// natural success-exit return at the end of
	// applyPlanWithEnvProviderAndHooks, i.e., when the outer function is
	// about to return (result, nil). Used by the DO plugin's deferred-
	// flush integration via IaCProviderFinalizer.FinalizeApply RPC.
	//
	// Does NOT fire on:
	//   - The preflightProviderOwnedReplaceWithDeleteHooks early-return —
	//     loopReached=false, no cloud work happened.
	//   - The per-action loop's `if fatalErr != nil { return ... }`
	//     early-return — outer err != nil. v1 semantic preservation per
	//     cycle-1 plan-review C-3: DOProvider.Apply skips deferred-flush
	//     when ApplyPlanWithHooks returns a top-level err (the
	//     `if err != nil { return ... }` guard in the caller immediately
	//     after the helper call).
	//   - The post-loop length-invariant check that compares
	//     len(result.Actions) against len(plan.Actions) — outer err != nil.
	//
	// DOES fire on per-action driver-error paths (best-effort; driver
	// errors append to result.Errors but do NOT set fatalErr; the loop
	// continues and reaches the natural success-exit return). Mirrors v1
	// DOProvider.Apply behavior where the deferred-flush ran whenever
	// the wrapped Apply returned nil err, regardless of per-action
	// result.Errors entries.
	//
	// Per workflow#695 Phase 2.5 / ADR 0024 / ADR 0040.
	OnPlanComplete func(context.Context) error
}

ApplyPlanWithHooks dispatches each plan action to the matching ResourceDriver on the provider. Per-action errors are recorded on result.Errors and do NOT abort the loop — apply best-effort across actions, surface every failure for the operator to triage. Context cancellation between actions IS respected: when ctx is canceled or its deadline expires, the loop stops at the next iteration boundary and returns ctx.Err() as the top-level error so a long apply terminates promptly on Ctrl-C / SIGTERM.

At entry the helper captures result.InitialInputSnapshot by fingerprinting every name listed in plan.InputSnapshot through the OS env. After the dispatch loop completes — successfully or not — a deferred postcondition computes result.InputDriftReport against an apply-time snapshot taken through inputsnapshot.NewTolerantEnvProvider (sub-action env unsets are preserved, not flagged as drift). The postcondition is wrapped in recover() so a buggy env-provider closure cannot corrupt apply results; on panic, InputDriftReport is reset to nil and a warning is logged.

The function is concurrency-safe with respect to its inputs: result is owned by the helper for the duration of the call and is not shared with the provider or driver implementations.

T3.1 shipped the dispatch skeleton; T3.1.5 added the postcondition above; T3.2/T3.3/T3.4 filled the per-action sub-functions with their full bodies.

ApplyPlanHooks are optional callbacks invoked immediately after a plan action successfully mutates cloud-side state. Hooks let wfctl persist state at the action boundary instead of waiting for the whole plan to finish.

type FSStateStore added in v0.64.8

type FSStateStore struct {
	// contains filtered or unexported fields
}

FSStateStore persists ResourceState records as JSON files under a directory, using the same on-disk format as cmd/wfctl's fsWfctlStateStore so state written by either is mutually readable.

func (*FSStateStore) Close added in v0.64.8

func (s *FSStateStore) Close() error

func (*FSStateStore) DeleteResource added in v0.64.8

func (s *FSStateStore) DeleteResource(_ context.Context, name string) error

func (*FSStateStore) GetPlan added in v0.64.8

func (s *FSStateStore) GetPlan(_ context.Context, _ string) (*interfaces.IaCPlan, error)

func (*FSStateStore) GetResource added in v0.64.8

func (s *FSStateStore) GetResource(ctx context.Context, name string) (*interfaces.ResourceState, error)

func (*FSStateStore) ListResources added in v0.64.8

func (s *FSStateStore) ListResources(_ context.Context) ([]interfaces.ResourceState, error)

func (*FSStateStore) Lock added in v0.64.8

func (*FSStateStore) SaveMetadata added in v0.64.8

func (s *FSStateStore) SaveMetadata(_ context.Context, meta interfaces.GeneratorMetadata) error

SaveMetadata writes the generator metadata.json file alongside the per-resource state files. cmd/wfctl's apply path performs a runtime type assertion against an internal metadataPersister interface; mirroring the method here keeps that assertion working when the store is built through this helper.

func (*FSStateStore) SavePlan added in v0.64.8

func (s *FSStateStore) SavePlan(_ context.Context, _ interfaces.IaCPlan) error

func (*FSStateStore) SaveResource added in v0.64.8

func (s *FSStateStore) SaveResource(_ context.Context, state interfaces.ResourceState) error

type IaCProviderResolverFunc added in v0.64.8

type IaCProviderResolverFunc func(ctx context.Context, providerType string, cfg map[string]any) (interfaces.IaCProvider, io.Closer, error)

IaCProviderResolverFunc loads a live interfaces.IaCProvider from a provider type identifier (e.g. "digitalocean", "aws", "stub") and the expanded module config. Implementations typically scan a plugin directory, spawn a subprocess, build the typed gRPC adapter, and enforce CapabilitiesResponse.compute_plan_version == "v2".

The returned io.Closer (when non-nil) MUST be closed by the caller to shut down the plugin subprocess.

Resolver is the package-level seam used by LoadIaCProviderFromConfig (and LoadAllIaCProvidersFromConfig in Task 3) to spawn a live IaC provider plugin. Production callers register their loader via an init() in cmd/wfctl/provider_resolver_init.go (registers discoverAndLoadIaCProvider); tests substitute fakes with t.Cleanup restore. Per docs/plans/2026-05-27-infra-admin-dynamic.md Task 2.

Production callers other than cmd/wfctl's init() MUST NOT mutate this var; tests substitute fakes with t.Cleanup restore. NOT goroutine-safe — mirrors the T1 loadPluginStateBackendClients seam precedent. Code-reviewer M-1 on commit 63129d65f flagged the export surface; the godoc tightening here keeps the contract explicit without adding a setter that would diverge from T1's pattern.

The cmd/wfctl loader (discoverAndLoadIaCProvider) is ~2800 lines of plugin-manager + typed-adapter machinery (deploy_providers.go + iac_typed_adapter.go). Lifting that wholesale into wfctlhelpers was out of scope for Task 2; this seam decouples the loader from this package without requiring the move. The host-side infra.admin module (T15) resolves providers via app.GetService(<module>) per the modular DI graph rather than calling this function, so the seam principally serves wfctl's CLI codepaths.

var UnregisteredResolver IaCProviderResolverFunc = func(_ context.Context, providerType string, _ map[string]any) (interfaces.IaCProvider, io.Closer, error) {
	return nil, nil, fmt.Errorf("wfctlhelpers: no IaCProviderResolver registered for provider type %q — cmd/wfctl init() should assign wfctlhelpers.Resolver = <loader>", providerType)
}

UnregisteredResolver is the safe default for the Resolver seam: it returns a clear error message naming the missing init-registration so operators can diagnose a missing wiring without a nil-func panic. Exposed so tests can restore the default after swapping Resolver.

type NoopStateStore added in v0.64.8

type NoopStateStore struct{}

NoopStateStore satisfies interfaces.IaCStateStore but silently discards all writes and returns no resources / no plans. Used when no iac.state module is declared so callers get a usable handle without needing to special-case the missing-state case.

func (*NoopStateStore) Close added in v0.64.8

func (n *NoopStateStore) Close() error

func (*NoopStateStore) DeleteResource added in v0.64.8

func (n *NoopStateStore) DeleteResource(_ context.Context, _ string) error

func (*NoopStateStore) GetPlan added in v0.64.8

func (*NoopStateStore) GetResource added in v0.64.8

func (*NoopStateStore) ListResources added in v0.64.8

func (n *NoopStateStore) ListResources(_ context.Context) ([]interfaces.ResourceState, error)

func (*NoopStateStore) Lock added in v0.64.8

func (*NoopStateStore) SavePlan added in v0.64.8

func (*NoopStateStore) SaveResource added in v0.64.8

type StateRecord added in v0.64.8

type StateRecord struct {
	ResourceID   string         `json:"resource_id"`
	ResourceType string         `json:"resource_type"`
	Provider     string         `json:"provider"`
	ProviderRef  string         `json:"provider_ref,omitempty"`
	ProviderID   string         `json:"provider_id,omitempty"`
	ConfigHash   string         `json:"config_hash,omitempty"`
	Status       string         `json:"status"`
	Config       map[string]any `json:"config"`
	Outputs      map[string]any `json:"outputs"`
	Dependencies []string       `json:"dependencies,omitempty"`
	CreatedAt    string         `json:"created_at"`
	UpdatedAt    string         `json:"updated_at"`
}

StateRecord mirrors the JSON schema used by the wfctl filesystem backend. Field names must stay byte-stable with cmd/wfctl's iacStateRecord so state written by either path is mutually readable. See cmd/wfctl/state_compat_test.go for the on-disk-format compatibility matrix.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL