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
59 changes: 16 additions & 43 deletions cmd/wfctl/deploy_providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
Expand Down Expand Up @@ -82,16 +81,8 @@ var resolveIaCProvider = discoverAndLoadIaCProvider
// same seam pattern used by currentApplyIncludeFlag and applyAllowReplaceSet.
var currentInfraPluginDir string

// iacPluginManifest is the minimal shape needed to read both:
// - capabilities.iacProvider.name — used by findIaCPluginDir to
// match a plugin to a desired provider name; AND
// - iacProvider.computePlanVersion — used by W-3b T3.7 to decide
// between v1 (legacy provider.Apply) and v2
// (wfctlhelpers.ApplyPlan) dispatch at apply time.
//
// Both fields are unmarshaled from the same plugin.json bytes — no
// double parse — and either may be empty without affecting the
// other.
// iacPluginManifest remains the legacy metadata shape consumed by the
// generator. Provider selection uses checked, normalized manifests below.
type iacPluginManifest struct {
Name string `json:"name"`
Version string `json:"version"`
Expand All @@ -106,7 +97,8 @@ type iacPluginManifest struct {
}

// findIaCPluginDir scans pluginDir subdirectories for a plugin.json that
// declares capabilities.iacProvider.name == providerName.
// declares the selected IaC provider, using the same checked/normalized
// metadata as resource discovery.
// Returns ("", "", false, nil) when not found; ("name", "computePlanVersion",
// true/false, nil) when the manifest matches (hasBinary indicates whether
// the executable is present).
Expand All @@ -129,29 +121,16 @@ type iacPluginManifest struct {
// MUST NOT assume. Per workflow#699, the authoritative gate is the
// typed CapabilitiesResponse check in discoverAndLoadIaCProvider.
func findIaCPluginDir(pluginDir, providerName string) (name, computePlanVersion string, hasBinary bool, err error) {
entries, err := os.ReadDir(pluginDir)
catalog, err := scanInstalledIaCProviders(pluginDir)
if err != nil {
if os.IsNotExist(err) {
return "", "", false, nil
}
return "", "", false, fmt.Errorf("scan plugin directory %q: %w", pluginDir, err)
return "", "", false, err
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
pluginName := entry.Name()
data, readErr := os.ReadFile(filepath.Join(pluginDir, pluginName, "plugin.json"))
if readErr != nil {
continue
}
var m iacPluginManifest
if jsonErr := json.Unmarshal(data, &m); jsonErr != nil {
continue
}
if m.Capabilities.IaCProvider.Name != providerName {
continue
}
selected, found, err := catalog.selectOwner(providerName)
if err != nil {
return "", "", false, err
}
if found {
pluginName := selected.directory
// Per workflow#693 (Phase 2.1 follow-up to #640): validate
// iacProvider.computePlanVersion ∈ {"", "v1", "v2"} on the
// matching plugin manifest. A typo (e.g. "V2", "v2.0", "two")
Expand All @@ -161,18 +140,18 @@ func findIaCPluginDir(pluginDir, providerName string) (name, computePlanVersion
// is the typed CapabilitiesResponse gate in
// discoverAndLoadIaCProvider; this parse-time check is the
// pre-load schema sanity (defense-in-depth).
switch m.IaCProvider.ComputePlanVersion {
switch selected.capability.ComputePlanVersion {
case "", "v1", "v2":
// valid
default:
return "", "", false, fmt.Errorf(
"plugin %q manifest has invalid iacProvider.computePlanVersion %q (must be \"\", \"v1\", or \"v2\")",
pluginName, m.IaCProvider.ComputePlanVersion,
pluginName, selected.capability.ComputePlanVersion,
)
}
binaryPath := filepath.Join(pluginDir, pluginName, pluginName)
_, statErr := os.Stat(binaryPath)
return pluginName, m.IaCProvider.ComputePlanVersion, statErr == nil, nil
return pluginName, selected.capability.ComputePlanVersion, statErr == nil, nil
}
return "", "", false, nil
}
Expand Down Expand Up @@ -200,13 +179,7 @@ func findIaCPluginDir(pluginDir, providerName string) (name, computePlanVersion
// CapabilitiesWithContext) so a transient handshake failure does not
// poison the adapter for the rest of the wfctl invocation.
func discoverAndLoadIaCProvider(ctx context.Context, providerName string, cfg map[string]any) (interfaces.IaCProvider, io.Closer, error) {
pluginDir := currentInfraPluginDir
if pluginDir == "" {
pluginDir = os.Getenv("WFCTL_PLUGIN_DIR")
}
if pluginDir == "" {
pluginDir = "./data/plugins"
}
pluginDir := infraPluginDirectory()

pName, manifestCPV, hasBinary, findErr := findIaCPluginDir(pluginDir, providerName)
if findErr != nil {
Expand Down
55 changes: 25 additions & 30 deletions cmd/wfctl/infra.go
Original file line number Diff line number Diff line change
Expand Up @@ -540,32 +540,16 @@ func secretModuleKeys(moduleCfg map[string]any, field string) []string {
}

// parseInfraResourceSpecs reads an infra config (resolving imports:) and
// returns ResourceSpecs for all infra.* and platform.* (e.g., platform.kubernetes, platform.ecs) modules.
// returns canonical and explicitly selected provider-declared ResourceSpecs.
func parseInfraResourceSpecs(cfgFile string) ([]interfaces.ResourceSpec, error) {
cfg, err := config.LoadFromFile(cfgFile)
if err != nil {
return nil, fmt.Errorf("load %s: %w", cfgFile, err)
}
secretVars := declaredSecretKeys(cfg)
var specs []interfaces.ResourceSpec
for _, m := range cfg.Modules {
if !isInfraType(m.Type) {
continue
}
r := &config.ResolvedModule{Name: m.Name, Type: m.Type, Protected: m.Protected, Config: config.ExpandEnvInMapPreservingVars(m.Config, infraPreserveKeys, secretVars)}
specs = append(specs, resourceSpecFromResolvedModule(r))
}
return specs, nil
return parseInfraResourceSpecsForEnv(cfgFile, "")
}

// parseInfraResourceSpecsForEnv returns ResourceSpecs for plan computation,
// applying per-environment resolution when envName is non-empty. Both the
// --env and no-env paths produce the same ResourceSpec shape so callers never
// need to duplicate the ResolvedModule->ResourceSpec mapping.
func parseInfraResourceSpecsForEnv(cfgFile, envName string) ([]interfaces.ResourceSpec, error) {
if envName == "" {
return parseInfraResourceSpecs(cfgFile)
}
resolved, err := planResourcesForEnv(cfgFile, envName)
if err != nil {
return nil, err
Expand All @@ -587,6 +571,10 @@ func planResourcesForEnv(path, envName string) ([]*config.ResolvedModule, error)
if err != nil {
return nil, fmt.Errorf("load %s: %w", path, err)
}
discovery, err := newIaCResourceDiscovery(cfg, envName)
if err != nil {
return nil, err
}
var topEnv *config.EnvironmentConfig
if envName != "" && cfg.Environments != nil {
topEnv = cfg.Environments[envName]
Expand All @@ -595,15 +583,15 @@ func planResourcesForEnv(path, envName string) ([]*config.ResolvedModule, error)
var out []*config.ResolvedModule
for i := range cfg.Modules {
m := &cfg.Modules[i]
if !isInfraType(m.Type) {
resolved, ok := resolveDiscoveryModule(m, envName)
if !ok {
continue
}
if envName == "" {
out = append(out, &config.ResolvedModule{Name: m.Name, Type: m.Type, Protected: m.Protected, Config: config.ExpandEnvInMapPreservingVars(m.Config, infraPreserveKeys, secretVars)})
continue
resource, err := discovery.classify(resolved)
if err != nil {
return nil, err
}
resolved, ok := m.ResolveForEnv(envName)
if !ok {
if !resource {
continue
}
if topEnv != nil {
Expand Down Expand Up @@ -1397,6 +1385,13 @@ func runInfraApply(args []string) error {
if dryRun {
return runInfraApplyDryRun(cfgFile, envName, dryRunFormat, showSensitiveVal)
}
directResources, err := hasDirectIaCResources(cfgFile, envName)
if err != nil {
return fmt.Errorf("classify IaC resources: %w", err)
}
if planFile == "" && directResources && hasPlatformModules(cfgFile) {
return fmt.Errorf("config %q mixes direct IaC and platform.* module types; use one style per config file", cfgFile)
}

if !*autoApprove {
ok, err := confirmAction("Apply infrastructure changes from "+cfgFile+"?", false, os.Stderr, nil)
Expand Down Expand Up @@ -1455,7 +1450,7 @@ func runInfraApply(args []string) error {
// change to --refresh.
refreshOutputsRan := false
if refreshOutputsFlag {
if hasInfraModules(cfgFile) {
if directResources {
if err := applyPreStepRefreshOutputs(ctx, cfgFile, envName, os.Stdout); err != nil {
return fmt.Errorf("--refresh-outputs: %w", err)
}
Expand All @@ -1469,7 +1464,7 @@ func runInfraApply(args []string) error {
// before running the normal plan + apply. Only applicable for infra.* configs;
// silently skipped for legacy platform.* configs. Runs AFTER --refresh-outputs
// so the drift check sees the freshest possible Outputs.
if refreshFlag && hasInfraModules(cfgFile) {
if refreshFlag && directResources {
fmt.Println("Refreshing state (detecting drift)...")
store, storeErr := resolveStateStore(cfgFile, envName)
if storeErr != nil {
Expand Down Expand Up @@ -1519,7 +1514,7 @@ func runInfraApply(args []string) error {
// applicable for infra.* configs (legacy platform.* path doesn't
// flow through iac/refreshoutputs). Skipped when --refresh-outputs
// already ran (refreshOutputsRan guard prevents double-trigger).
if !refreshOutputsRan && applyPreStepRefreshEnabled(skipRefreshFlag) && hasInfraModules(cfgFile) {
if !refreshOutputsRan && applyPreStepRefreshEnabled(skipRefreshFlag) && directResources {
if err := applyPreStepRefreshOutputs(ctx, cfgFile, envName, os.Stdout); err != nil {
return fmt.Errorf("apply pre-step refresh-outputs: %w", err)
}
Expand Down Expand Up @@ -1564,7 +1559,7 @@ func runInfraApply(args []string) error {
// *StaleError: Error() yields the canonical FormatStaleError
// output (no sentinel prefix); Unwrap() yields ErrEnvVarChanged
// so errors.Is(err, inputsnapshot.ErrEnvVarChanged) still matches.
return inputsnapshot.NewStaleError(drift)
return contextualCLIError{err: inputsnapshot.NewStaleError(drift)}
}
}
// Stale checks hash the same declarative inputs recorded at plan time.
Expand Down Expand Up @@ -1596,14 +1591,14 @@ func runInfraApply(args []string) error {
// platform.* configs fall back to the pipeline runner (pipelines.apply).
// Mixing both types in the same config is not supported — fail fast with a
// descriptive error rather than silently skipping one class of modules.
if hasInfraModules(cfgFile) && hasPlatformModules(cfgFile) {
if directResources && hasPlatformModules(cfgFile) {
return fmt.Errorf(
"config %q mixes infra.* and platform.* module types — "+
"use one style per config file, or split into separate configs",
cfgFile,
)
}
if hasInfraModules(cfgFile) {
if directResources {
h, err := applyInfraModules(ctx, cfgFile, envName)
if err != nil {
return err
Expand Down
7 changes: 4 additions & 3 deletions cmd/wfctl/infra_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,15 +166,16 @@ func applyInfraModules(ctx context.Context, cfgFile, envName string) (map[string
return nil, fmt.Errorf("parse infra resource specs: %w", err)
}

// Keep only infra.* specs for the direct path.
// The parser already classified custom specs against their selected
// provider. Only legacy platform.* specs belong to the pipeline path.
var infraSpecs []interfaces.ResourceSpec
for _, s := range specs {
if strings.HasPrefix(s.Type, "infra.") {
if !strings.HasPrefix(s.Type, "platform.") {
infraSpecs = append(infraSpecs, s)
}
}
if len(infraSpecs) == 0 {
fmt.Println("No infra.* modules to apply.")
fmt.Println("No direct IaC resources to apply.")
return nil, nil
}
if err := validateUniqueInfraResourceNames(infraSpecs); err != nil {
Expand Down
2 changes: 1 addition & 1 deletion cmd/wfctl/infra_apply_dryrun.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ func collectProviderGroups(cfgFile, envName string, specs []interfaces.ResourceS
groups := map[string]*groupAcc{}
var order []string
for _, spec := range specs {
if !strings.HasPrefix(spec.Type, "infra.") {
if strings.HasPrefix(spec.Type, "platform.") {
continue
}
moduleRef := resolveIaCProviderRef(spec.Config)
Expand Down
25 changes: 15 additions & 10 deletions cmd/wfctl/infra_env_resolve.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
package main

import "github.com/GoCodeAlone/workflow/iac/wfctlhelpers"
import (
"github.com/GoCodeAlone/workflow/config"
"github.com/GoCodeAlone/workflow/iac/wfctlhelpers"
)

// writeEnvResolvedConfig is a one-line delegating shim onto
// wfctlhelpers.WriteEnvResolvedConfig. The implementation moved into the
// shared helper package per docs/plans/2026-05-27-infra-admin-dynamic.md
// Task 1 (consolidation follow-up addressing spec-reviewer F2 on commit
// 7a064b824) so the wfctl CLI and the host-side infra.admin module share
// one path and cannot drift. New code should call the wfctlhelpers symbol
// directly; this wrapper exists only to keep existing cmd/wfctl callsites
// untouched.
// The CLI supplies provider-bound discovery; the shared writer retains
// canonical-only behavior for callers that do not supply a classifier.
func writeEnvResolvedConfig(cfgFile, envName string) (tmpPath string, err error) {
return wfctlhelpers.WriteEnvResolvedConfig(cfgFile, envName)
cfg, err := config.LoadFromFile(cfgFile)
if err != nil {
return "", err
}
discovery, err := newIaCResourceDiscovery(cfg, envName)
if err != nil {
return "", err
}
return wfctlhelpers.WriteEnvResolvedConfigWithClassifier(cfgFile, envName, discovery.classify)
}
16 changes: 10 additions & 6 deletions cmd/wfctl/infra_inputsnapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ func collectInfraEnvVarRefs(cfgFile, envName string) ([]string, error) {
if err != nil {
return nil, err
}
discovery, err := newIaCResourceDiscovery(cfg, envName)
if err != nil {
return nil, err
}
seen := map[string]struct{}{}
record := func(name string) string {
if name != "" {
Expand All @@ -44,15 +48,15 @@ func collectInfraEnvVarRefs(cfgFile, envName string) ([]string, error) {
}
for i := range cfg.Modules {
m := &cfg.Modules[i]
if !isInfraType(m.Type) {
resolved, ok := resolveDiscoveryModule(m, envName)
if !ok {
continue
}
if envName == "" {
walkValueForEnvRefs(m.Config, record)
continue
resource, err := discovery.classify(resolved)
if err != nil {
return nil, err
}
resolved, ok := m.ResolveForEnv(envName)
if !ok {
if !resource {
continue
}
walkValueForEnvRefs(resolved.Config, record)
Expand Down
Loading
Loading