diff --git a/cmd/wfctl/deploy_providers.go b/cmd/wfctl/deploy_providers.go index 7e274b49..395c96a9 100644 --- a/cmd/wfctl/deploy_providers.go +++ b/cmd/wfctl/deploy_providers.go @@ -86,6 +86,8 @@ var resolveIaCProvider = discoverAndLoadIaCProvider // double parse — and either may be empty without affecting the // other. type iacPluginManifest struct { + Name string `json:"name"` + Version string `json:"version"` Capabilities struct { IaCProvider struct { Name string `json:"name"` diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index 62e476b6..644ebc40 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -373,6 +373,10 @@ func runInfraPlan(args []string) error { // Embed a hash of the desired-state inputs so wfctl infra apply --plan // can detect stale plans when the config changes after plan generation. plan.DesiredHash = desiredStateHash(desired) + // Stamp generator metadata (wfctl version + IaC plugin versions) so + // operators can inspect what toolchain version produced this plan. + meta := buildGeneratorMetadata() + plan.GeneratorMetadata = &meta if err := writePlanJSON(plan, *output); err != nil { return fmt.Errorf("write plan: %w", err) } @@ -1461,6 +1465,22 @@ func runInfraApply(args []string) error { } } + // Post-apply: persist generator metadata (wfctl version + plugin versions) + // to the state directory so it is available for future audit without + // requiring the original plan.json. Best-effort: failures here are logged + // as warnings and do not roll back the apply. + { + metaStore, metaErr := resolveStateStore(cfgFile, envName) + if metaErr != nil { + fmt.Fprintf(os.Stderr, "warning: open state store for metadata: %v\n", metaErr) + } else if mp, ok := metaStore.(metadataPersister); ok { + meta := buildGeneratorMetadata() + if saveErr := mp.SaveMetadata(ctx, meta); saveErr != nil { + fmt.Fprintf(os.Stderr, "warning: save generator metadata: %v\n", saveErr) + } + } + } + // Post-apply: sync infra_output secrets from the now-written state. secretsCfg, err := parseSecretsConfig(cfgFile) if err != nil || secretsCfg == nil { diff --git a/cmd/wfctl/infra_generator_metadata.go b/cmd/wfctl/infra_generator_metadata.go new file mode 100644 index 00000000..3570f891 --- /dev/null +++ b/cmd/wfctl/infra_generator_metadata.go @@ -0,0 +1,78 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// buildGeneratorMetadata constructs a GeneratorMetadata snapshot describing +// the wfctl binary version and the versions of all IaC provider plugins +// installed in the plugin directory. It is called just before a plan or +// apply result is persisted so that operators can later inspect what +// toolchain version produced the stored state. +// +// Plugin discovery is best-effort: unreadable or malformed plugin.json files +// are silently skipped rather than causing plan/apply to fail. The wfctl +// version always appears even when no plugins are installed. +func buildGeneratorMetadata() interfaces.GeneratorMetadata { + return interfaces.GeneratorMetadata{ + WfctlVersion: version, + Plugins: collectInstalledIaCPluginVersions(), + } +} + +// collectInstalledIaCPluginVersions scans the plugin directory for +// subdirectories that contain a plugin.json declaring an iacProvider +// capability, and returns the name and version of each such installed plugin. +// +// Note: this reflects the set of *installed* IaC plugins (those present on +// disk in WFCTL_PLUGIN_DIR), not strictly the subset that was loaded for the +// current plan/apply. In practice these are identical for single-run wfctl +// invocations, but operators should be aware that extra installed-but-not-used +// plugins may appear in the list. +// +// The plugin directory is resolved using the same WFCTL_PLUGIN_DIR env var +// that discoverAndLoadIaCProvider uses, defaulting to ./data/plugins. +func collectInstalledIaCPluginVersions() []interfaces.PluginVersionInfo { + pluginDir := os.Getenv("WFCTL_PLUGIN_DIR") + if pluginDir == "" { + pluginDir = "./data/plugins" + } + + entries, err := os.ReadDir(pluginDir) + if err != nil { + // Plugin dir absent or unreadable — return empty list without error. + return nil + } + + var infos []interfaces.PluginVersionInfo + for _, entry := range entries { + if !entry.IsDir() { + continue + } + data, err := os.ReadFile(filepath.Join(pluginDir, entry.Name(), "plugin.json")) + if err != nil { + continue + } + var m iacPluginManifest + if err := json.Unmarshal(data, &m); err != nil { + continue + } + // Only include plugins that declare an IaC provider capability. + if m.Capabilities.IaCProvider.Name == "" { + continue + } + name := m.Name + if name == "" { + name = entry.Name() + } + infos = append(infos, interfaces.PluginVersionInfo{ + Name: name, + Version: m.Version, + }) + } + return infos +} diff --git a/cmd/wfctl/infra_generator_metadata_test.go b/cmd/wfctl/infra_generator_metadata_test.go new file mode 100644 index 00000000..dcf503e2 --- /dev/null +++ b/cmd/wfctl/infra_generator_metadata_test.go @@ -0,0 +1,274 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// TestCollectInstalledIaCPluginVersions_EmptyDir verifies that an absent or +// empty plugin directory returns a nil (not empty) slice without error. +func TestCollectInstalledIaCPluginVersions_EmptyDir(t *testing.T) { + t.Setenv("WFCTL_PLUGIN_DIR", t.TempDir()) + infos := collectInstalledIaCPluginVersions() + if len(infos) != 0 { + t.Errorf("expected no plugins, got %v", infos) + } +} + +// TestCollectInstalledIaCPluginVersions_NoIaCProvider verifies that plugins +// without an iacProvider capability are excluded from the result. +func TestCollectInstalledIaCPluginVersions_NoIaCProvider(t *testing.T) { + dir := t.TempDir() + t.Setenv("WFCTL_PLUGIN_DIR", dir) + + pluginDir := filepath.Join(dir, "workflow-plugin-auth") + if err := os.MkdirAll(pluginDir, 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + manifest := `{"name":"workflow-plugin-auth","version":"1.2.0","capabilities":{}}` + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(manifest), 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + + infos := collectInstalledIaCPluginVersions() + if len(infos) != 0 { + t.Errorf("expected no IaC plugins, got %v", infos) + } +} + +// TestCollectInstalledIaCPluginVersions_WithIaCProvider verifies that a plugin +// declaring an iacProvider capability is included in the result with its +// correct name and version. +func TestCollectInstalledIaCPluginVersions_WithIaCProvider(t *testing.T) { + dir := t.TempDir() + t.Setenv("WFCTL_PLUGIN_DIR", dir) + + pluginDir := filepath.Join(dir, "workflow-plugin-aws") + if err := os.MkdirAll(pluginDir, 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + manifest := `{ + "name": "workflow-plugin-aws", + "version": "2.3.1", + "capabilities": { + "iacProvider": {"name": "aws"} + } + }` + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(manifest), 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + + infos := collectInstalledIaCPluginVersions() + if len(infos) != 1 { + t.Fatalf("expected 1 plugin, got %d: %v", len(infos), infos) + } + if infos[0].Name != "workflow-plugin-aws" { + t.Errorf("unexpected name %q", infos[0].Name) + } + if infos[0].Version != "2.3.1" { + t.Errorf("unexpected version %q", infos[0].Version) + } +} + +// TestCollectInstalledIaCPluginVersions_MixedPlugins verifies that only +// IaC-provider plugins are included when multiple plugin types are installed. +func TestCollectInstalledIaCPluginVersions_MixedPlugins(t *testing.T) { + dir := t.TempDir() + t.Setenv("WFCTL_PLUGIN_DIR", dir) + + writePlugin := func(subdir, name, version, iacProviderName string) { + t.Helper() + pd := filepath.Join(dir, subdir) + if err := os.MkdirAll(pd, 0o750); err != nil { + t.Fatalf("mkdir %s: %v", subdir, err) + } + iacCap := "" + if iacProviderName != "" { + iacCap = `"iacProvider": {"name": "` + iacProviderName + `"}` + } + m := `{"name":"` + name + `","version":"` + version + `","capabilities":{` + iacCap + `}}` + if err := os.WriteFile(filepath.Join(pd, "plugin.json"), []byte(m), 0o600); err != nil { + t.Fatalf("write %s manifest: %v", subdir, err) + } + } + + writePlugin("plugin-aws", "workflow-plugin-aws", "1.0.0", "aws") + writePlugin("plugin-auth", "workflow-plugin-auth", "0.5.0", "") + writePlugin("plugin-gcp", "workflow-plugin-gcp", "3.1.0", "gcp") + + infos := collectInstalledIaCPluginVersions() + if len(infos) != 2 { + t.Fatalf("expected 2 IaC plugins, got %d: %v", len(infos), infos) + } + names := map[string]string{} + for _, p := range infos { + names[p.Name] = p.Version + } + if names["workflow-plugin-aws"] != "1.0.0" { + t.Errorf("aws version mismatch: %q", names["workflow-plugin-aws"]) + } + if names["workflow-plugin-gcp"] != "3.1.0" { + t.Errorf("gcp version mismatch: %q", names["workflow-plugin-gcp"]) + } + if _, ok := names["workflow-plugin-auth"]; ok { + t.Error("non-IaC plugin should not be included") + } +} + +// TestBuildGeneratorMetadata_WfctlVersion verifies that buildGeneratorMetadata +// always populates WfctlVersion with the binary's version string. +func TestBuildGeneratorMetadata_WfctlVersion(t *testing.T) { + // Point to an empty plugin dir so plugin scanning is deterministic. + t.Setenv("WFCTL_PLUGIN_DIR", t.TempDir()) + meta := buildGeneratorMetadata() + if meta.WfctlVersion == "" { + t.Error("WfctlVersion must not be empty") + } +} + +// TestFsWfctlStateStore_SaveAndReadMetadata verifies that SaveMetadata writes +// a metadata.json file wrapped under "generator_metadata" and that +// ListResources does not mistake it for a resource state record. +func TestFsWfctlStateStore_SaveAndReadMetadata(t *testing.T) { + dir := t.TempDir() + store := &fsWfctlStateStore{dir: dir} + + meta := interfaces.GeneratorMetadata{ + WfctlVersion: "v1.2.3", + Plugins: []interfaces.PluginVersionInfo{ + {Name: "workflow-plugin-aws", Version: "2.0.0"}, + }, + } + if err := store.SaveMetadata(context.Background(), meta); err != nil { + t.Fatalf("SaveMetadata: %v", err) + } + + // Verify the file was written with the "generator_metadata" wrapper. + data, err := os.ReadFile(filepath.Join(dir, "metadata.json")) + if err != nil { + t.Fatalf("read metadata.json: %v", err) + } + var wrapper struct { + GeneratorMetadata interfaces.GeneratorMetadata `json:"generator_metadata"` + } + if err := json.Unmarshal(data, &wrapper); err != nil { + t.Fatalf("unmarshal metadata.json: %v", err) + } + got := wrapper.GeneratorMetadata + if got.WfctlVersion != "v1.2.3" { + t.Errorf("WfctlVersion: got %q, want %q", got.WfctlVersion, "v1.2.3") + } + if len(got.Plugins) != 1 || got.Plugins[0].Name != "workflow-plugin-aws" { + t.Errorf("unexpected Plugins: %v", got.Plugins) + } + + // The "generator_metadata" key must be present at the top level. + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal to map: %v", err) + } + if _, ok := raw["generator_metadata"]; !ok { + t.Error("metadata.json must have a top-level generator_metadata key") + } + + // ListResources must not return the metadata file as a resource. + states, err := store.ListResources(context.Background()) + if err != nil { + t.Fatalf("ListResources: %v", err) + } + if len(states) != 0 { + t.Errorf("expected no resources, got %d (metadata.json must be skipped)", len(states)) + } +} + +// TestFsWfctlStateStore_MetadataOverwritten verifies that calling SaveMetadata +// twice overwrites the previous file (the file reflects the most-recent run). +func TestFsWfctlStateStore_MetadataOverwritten(t *testing.T) { + dir := t.TempDir() + store := &fsWfctlStateStore{dir: dir} + + first := interfaces.GeneratorMetadata{WfctlVersion: "v1.0.0"} + if err := store.SaveMetadata(context.Background(), first); err != nil { + t.Fatalf("SaveMetadata (first): %v", err) + } + second := interfaces.GeneratorMetadata{WfctlVersion: "v2.0.0"} + if err := store.SaveMetadata(context.Background(), second); err != nil { + t.Fatalf("SaveMetadata (second): %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "metadata.json")) + if err != nil { + t.Fatalf("read metadata.json: %v", err) + } + var wrapper struct { + GeneratorMetadata interfaces.GeneratorMetadata `json:"generator_metadata"` + } + if err := json.Unmarshal(data, &wrapper); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if wrapper.GeneratorMetadata.WfctlVersion != "v2.0.0" { + t.Errorf("expected v2.0.0 (most-recent), got %q", wrapper.GeneratorMetadata.WfctlVersion) + } +} + +// TestIaCPlanGeneratorMetadata_RoundTrip verifies that GeneratorMetadata +// is preserved across JSON marshal/unmarshal of an IaCPlan (plan.json format). +func TestIaCPlanGeneratorMetadata_RoundTrip(t *testing.T) { + meta := &interfaces.GeneratorMetadata{ + WfctlVersion: "v0.42.1", + Plugins: []interfaces.PluginVersionInfo{ + {Name: "workflow-plugin-aws", Version: "3.1.0"}, + {Name: "workflow-plugin-gcp", Version: "1.0.5"}, + }, + } + plan := interfaces.IaCPlan{ + ID: "plan-123", + GeneratorMetadata: meta, + } + + data, err := json.MarshalIndent(plan, "", " ") + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got interfaces.IaCPlan + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.GeneratorMetadata == nil { + t.Fatal("GeneratorMetadata was nil after round-trip") + } + if got.GeneratorMetadata.WfctlVersion != "v0.42.1" { + t.Errorf("WfctlVersion: got %q", got.GeneratorMetadata.WfctlVersion) + } + if len(got.GeneratorMetadata.Plugins) != 2 { + t.Errorf("Plugins len: got %d", len(got.GeneratorMetadata.Plugins)) + } +} + +// TestIaCPlanGeneratorMetadata_OmitEmpty verifies that when GeneratorMetadata +// is nil (e.g., a plan loaded from an older wfctl version), the JSON output +// does not include the "generator_metadata" key. +func TestIaCPlanGeneratorMetadata_OmitEmpty(t *testing.T) { + plan := interfaces.IaCPlan{ID: "plan-456"} + data, err := json.Marshal(plan) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(data) == "" { + t.Fatal("expected non-empty JSON") + } + // The key must be absent, not null, when GeneratorMetadata is nil. + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("unmarshal to map: %v", err) + } + if _, ok := m["generator_metadata"]; ok { + t.Error("generator_metadata must be absent when nil (omitempty)") + } +} diff --git a/cmd/wfctl/infra_state_store.go b/cmd/wfctl/infra_state_store.go index 54781697..1db82ed1 100644 --- a/cmd/wfctl/infra_state_store.go +++ b/cmd/wfctl/infra_state_store.go @@ -23,6 +23,14 @@ type infraStateStore interface { DeleteResource(ctx context.Context, name string) error } +// metadataPersister is an optional extension of infraStateStore implemented +// by backends that can persist GeneratorMetadata alongside resource state. +// Call-sites use a type assertion so implementations that do not support +// metadata (noopStateStore, remote backends) are silently skipped. +type metadataPersister interface { + SaveMetadata(ctx context.Context, meta interfaces.GeneratorMetadata) error +} + // noopStateStore is an infraStateStore that silently discards all writes. // It is used when no iac.state backend is configured or when an optional // store is passed as nil. @@ -138,7 +146,7 @@ func (s *fsWfctlStateStore) ListResources(_ context.Context) ([]interfaces.Resou } var states []interfaces.ResourceState for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") || strings.HasSuffix(e.Name(), ".lock.json") { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") || strings.HasSuffix(e.Name(), ".lock.json") || e.Name() == "metadata.json" { continue } data, err := os.ReadFile(filepath.Join(s.dir, e.Name())) @@ -195,6 +203,29 @@ func (s *fsWfctlStateStore) DeleteResource(_ context.Context, name string) error return nil } +// SaveMetadata implements metadataPersister by writing a metadata.json file +// into the state directory alongside the per-resource state files. The file +// is overwritten on every apply so it always reflects the most-recent +// operation. The JSON content wraps the GeneratorMetadata under a +// "generator_metadata" key for consistency with the plan.json format. +func (s *fsWfctlStateStore) SaveMetadata(_ context.Context, meta interfaces.GeneratorMetadata) error { + if err := os.MkdirAll(s.dir, 0o750); err != nil { + return fmt.Errorf("save metadata: mkdir: %w", err) + } + wrapper := struct { + GeneratorMetadata interfaces.GeneratorMetadata `json:"generator_metadata"` + }{GeneratorMetadata: meta} + data, err := json.MarshalIndent(wrapper, "", " ") + if err != nil { + return fmt.Errorf("save metadata: marshal: %w", err) + } + fname := filepath.Join(s.dir, "metadata.json") + if err := os.WriteFile(fname, data, 0o600); err != nil { + return fmt.Errorf("save metadata: write: %w", err) + } + return nil +} + // ── Spaces backend ───────────────────────────────────────────────────────────── // resolveSpacesStateStore builds a Spaces-backed state store from the expanded diff --git a/docs/WFCTL.md b/docs/WFCTL.md index 26b6cf20..aadccaba 100644 --- a/docs/WFCTL.md +++ b/docs/WFCTL.md @@ -1383,6 +1383,58 @@ wfctl infra apply --auto-approve -c infra.yaml --env prod \ --allow-replace=coredump-prod-vpc,coredump-prod-pg ``` +#### Generator metadata + +Every plan file (`plan.json`) produced by `wfctl infra plan -o` and the `metadata.json` sidecar written by `wfctl infra apply` (filesystem state backend) record the exact toolchain versions in use at generation time. + +**`plan.json`** — the `generator_metadata` field is nested inside the plan object: + +```json +{ + "id": "plan-1234567890", + "actions": [...], + "generator_metadata": { + "wfctl_version": "v0.42.0", + "plugins": [ + { "name": "workflow-plugin-aws", "version": "2.3.1" }, + { "name": "workflow-plugin-gcp", "version": "1.0.5" } + ] + } +} +``` + +**`/metadata.json`** — the file contains only the `generator_metadata` wrapper (no surrounding plan object): + +```json +{ + "generator_metadata": { + "wfctl_version": "v0.42.0", + "plugins": [ + { "name": "workflow-plugin-aws", "version": "2.3.1" }, + { "name": "workflow-plugin-gcp", "version": "1.0.5" } + ] + } +} +``` + +| Field | Description | +|-------|-------------| +| `wfctl_version` | The wfctl binary version (from `debug.ReadBuildInfo`; `"dev"` for local builds) | +| `plugins[].name` | Plugin name from the plugin's `plugin.json` manifest | +| `plugins[].version` | Plugin version from the plugin's `plugin.json` manifest | + +> **Note:** `plugins` lists all IaC provider plugins *installed* in `WFCTL_PLUGIN_DIR` at generation time (those whose `plugin.json` declares `capabilities.iacProvider`). In normal usage this is equivalent to the set that was loaded for the run; extra installed-but-not-used plugins may appear if the directory contains multiple providers. + +**Where it is stored:** + +- **`plan.json`** — embedded in the `generator_metadata` field when `wfctl infra plan -o plan.json` is used. +- **`/metadata.json`** — written (and overwritten) by `wfctl infra apply` for the filesystem state backend. This persists the toolchain version even when no plan file is requested. + +This metadata is useful for: +- Knowing which wfctl and plugin versions produced a given state artifact. +- Identifying version mismatches when re-applying stored plans. +- Understanding what upgrades may be required if behavior has changed between versions. + #### `infra refresh-outputs` Read live outputs from each `iac.provider` for resources already in state and persist any field-level changes back to the state backend. The contract is strictly read-only at the cloud level — `refresh-outputs` never invokes Update or Replace. diff --git a/interfaces/iac_state.go b/interfaces/iac_state.go index bf9fa382..7d2b78f8 100644 --- a/interfaces/iac_state.go +++ b/interfaces/iac_state.go @@ -52,6 +52,28 @@ type ResourceState struct { LastDriftCheck time.Time `json:"last_drift_check,omitempty"` } +// PluginVersionInfo captures the name and version of an installed IaC provider +// plugin found in the plugin directory at the time an IaC plan or apply was run. +type PluginVersionInfo struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` +} + +// GeneratorMetadata records the wfctl version and installed IaC plugin versions +// that were present when an IaC plan or state operation was produced. Storing +// this information makes it possible to reason about compatibility between +// recorded state and the current toolchain, and to identify upgrades that may +// be required when behavior has changed between versions. +// +// Note: Plugins lists all IaC provider plugins installed in WFCTL_PLUGIN_DIR at +// generation time. In single-run invocations this is equivalent to the loaded +// set, but operators should be aware that extra installed-but-not-used plugins +// may appear. +type GeneratorMetadata struct { + WfctlVersion string `json:"wfctl_version"` + Plugins []PluginVersionInfo `json:"plugins,omitempty"` +} + // IaCPlan is the complete execution plan for a set of infrastructure changes. type IaCPlan struct { ID string `json:"id"` @@ -78,6 +100,13 @@ type IaCPlan struct { // top-level envVars default may therefore be absent from the snapshot; // closing this gap is tracked as a follow-up to W-1. InputSnapshot map[string]string `json:"input_snapshot,omitempty"` + + // GeneratorMetadata records the wfctl version and installed IaC plugin + // versions present when this plan was produced. Populated when the plan + // is serialised to disk (wfctl infra plan -o). Consumers can inspect + // this to assess whether upgrades are needed before re-applying a stored + // plan. + GeneratorMetadata *GeneratorMetadata `json:"generator_metadata,omitempty"` } // PlanAction is a single planned change within an IaCPlan.