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
9 changes: 9 additions & 0 deletions cmd/wfctl/infra.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ func runInfra(args []string) error {
return fmt.Errorf("audit-secrets exited with code %d", rc)
}
return nil
case "audit-keys":
return runInfraAuditKeysCmd(args[1:])
case "prune":
return runInfraPruneCmd(args[1:])
Comment on lines +93 to +96
case "rotate-and-prune":
return runInfraRotateAndPruneCmd(args[1:])
default:
return infraUsage()
Comment on lines 90 to 100
}
Expand All @@ -114,6 +120,9 @@ Actions:
security-check Scan plan.json for security policy violations
cleanup Tag-based force-cleanup across providers (--tag NAME [--fix])
audit-secrets Report provider_credential anti-patterns in secrets.generate
audit-keys List cloud-side resources of --type via the provider's EnumeratorAll
prune Destructively delete cloud resources by --created-before / --exclude-access-key (two-key opt-in)
rotate-and-prune All-in-one: rotate canonical credential, then prune older keys with the new key as exclusion target

Options:
--config <file> Config file (default: infra.yaml or config/infra.yaml)
Expand Down
148 changes: 148 additions & 0 deletions cmd/wfctl/infra_audit_keys.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package main

import (
"context"
"flag"
"fmt"
"io"
"os"

"github.com/GoCodeAlone/workflow/interfaces"
)

// auditKeysStdout / auditKeysStderr are seam variables tests override to
// capture the subcommand's structured output without redirecting os.Stdout
// globally. Mirrors the cleanupStdout / cleanupStderr pattern.
var (
auditKeysStdout io.Writer = os.Stdout
auditKeysStderr io.Writer = os.Stderr
)

// auditKeysLoadProviders is the seam used by runInfraAuditKeysCmd to obtain
// the IaCProvider instances declared in the config's iac.provider modules.
// The default implementation defers to defaultCleanupLoadProviders so
// audit-keys inherits the same env-resolution + plugin-discovery contract
// established by `wfctl infra cleanup` (and R-A10). Tests override this var
// to inject fake providers without spinning up real plugin subprocesses.
var auditKeysLoadProviders = defaultCleanupLoadProviders

// runInfraAuditKeys lists every cloud-side resource of `--type <T>` via the
// provider's interfaces.EnumeratorAll. This is the read-only surface for
// drift correction before the destructive `wfctl infra prune` (Task 19).
//
// Signature note: this function takes interfaces.EnumeratorAll directly
// (not the broader IaCProvider) so unit tests can pass a minimal fake
// without implementing every IaCProvider method. The dispatcher in
// runInfraAuditKeysCmd performs the IaCProvider → EnumeratorAll
// type-assertion at the boundary; providers that don't implement the
// optional interface are surfaced as a structured error there, not here.
//
// Exit codes:
//
// - 0: enumeration succeeded (zero or more resources rendered)
// - 1: enumeration failed (provider error)
// - 2: argument parse error or missing required --type
//
// Output goes to w as a fixed-width table — `audit-keys` is intended for
// human + CI consumption (the prune subcommand consumes the same shape).
func runInfraAuditKeys(args []string, enumerator interfaces.EnumeratorAll, w io.Writer) int {
fs := flag.NewFlagSet("infra audit-keys", flag.ContinueOnError)
fs.SetOutput(w)
var resourceType string
fs.StringVar(&resourceType, "type", "", "Resource type to enumerate (e.g. infra.spaces_key)")
if err := fs.Parse(args); err != nil {
return 2
}
if resourceType == "" {
fmt.Fprintln(w, "audit-keys: --type is required")
return 2
}

// EnumerateAll returns []*ResourceOutput (full metadata) per the
// workflow contract, so audit-keys can render without a second Read.
outs, err := enumerator.EnumerateAll(context.Background(), resourceType)
if err != nil {
fmt.Fprintf(w, "audit-keys: %v\n", err)
return 1
}

fmt.Fprintf(w, "Found %d %s resource(s):\n\n", len(outs), resourceType)
fmt.Fprintf(w, "%-30s %-30s %s\n", "NAME", "ACCESS_KEY", "CREATED_AT")
for _, o := range outs {
// Prefer typed fields — the EnumeratorAll contract guarantees
// ProviderID + Name. Outputs is for additional metadata
// (created_at). Fall back to Outputs[*] for backward compat with
// providers that populate both, but typed fields take priority so
// audit-keys renders correctly even for providers that follow the
// strict contract without redundant Outputs writes.
name := o.Name
if name == "" {
name, _ = o.Outputs["name"].(string)
}
ak := o.ProviderID
if ak == "" {
ak, _ = o.Outputs["access_key"].(string)
}
ca, _ := o.Outputs["created_at"].(string) // metadata; legitimately Outputs-only
fmt.Fprintf(w, "%-30s %-30s %s\n", name, ak, ca)
}
return 0
}

// runInfraAuditKeysCmd is the production entry point for `wfctl infra
// audit-keys`. It loads iac.provider modules from the config (honoring
// --config / --env), finds the first one that implements
// interfaces.EnumeratorAll for the requested --type, and dispatches to
// runInfraAuditKeys.
Comment on lines +95 to +96
//
// Splitting the dispatcher from runInfraAuditKeys keeps the testable
// function pure (no config / plugin I/O) while still presenting a single
// CLI surface to operators.
//
// Args-passing contract: this dispatcher captures EVERY flag it parses
// (including --type) and synthesizes a clean inner-args slice with only
// the flags runInfraAuditKeys understands. Forwarding the raw args slice
// would error inside runInfraAuditKeys with "flag provided but not
// defined: -config" because its inner FlagSet only declares --type.
func runInfraAuditKeysCmd(args []string) error {
fs := flag.NewFlagSet("infra audit-keys", flag.ContinueOnError)
fs.SetOutput(auditKeysStderr)
var configFile, envName, resourceType string
fs.StringVar(&configFile, "config", "", "Config file (default: infra.yaml or config/infra.yaml)")
fs.StringVar(&configFile, "c", "", "Config file (short for --config)")
fs.StringVar(&envName, "env", "", "Environment name for config resolution")
fs.StringVar(&resourceType, "type", "", "Resource type to enumerate (e.g. infra.spaces_key)")
if err := fs.Parse(args); err != nil {
return err
}

ctx := context.Background()
providers, closers, err := auditKeysLoadProviders(ctx, fs, configFile, envName)
if err != nil {
return fmt.Errorf("load providers: %w", err)
}
defer func() {
for _, c := range closers {
if c == nil {
continue
}
if cerr := c.Close(); cerr != nil {
fmt.Fprintf(auditKeysStderr, "warning: provider shutdown: %v\n", cerr)
}
}
}()

// Synthesize a clean inner-args slice — only flags runInfraAuditKeys
// declares. resourceType may be empty; runInfraAuditKeys handles the
// "--type required" error itself with its own structured message.
inner := []string{"--type", resourceType}
for _, p := range providers {
if enum, ok := p.(interfaces.EnumeratorAll); ok {
if rc := runInfraAuditKeys(inner, enum, auditKeysStdout); rc != 0 {
return fmt.Errorf("audit-keys exited with code %d", rc)
}
Comment on lines +139 to +143
return nil
}
}
return fmt.Errorf("audit-keys: no loaded provider implements EnumeratorAll")
}
173 changes: 173 additions & 0 deletions cmd/wfctl/infra_audit_keys_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package main

import (
"bytes"
"context"
"flag"
"io"
"strings"
"testing"

"github.com/GoCodeAlone/workflow/interfaces"
)

// fakeProviderEnumeratorAll is a test double implementing
// interfaces.EnumeratorAll. It returns a fixed list of *ResourceOutput per
// the workflow contract — full metadata so the audit-keys CLI can render
// without re-reading from the cloud.
type fakeProviderEnumeratorAll struct {
keys []*interfaces.ResourceOutput
// lastType records the resourceType passed to EnumerateAll so tests can
// assert the CLI forwarded the --type flag correctly.
lastType string
}

func (f *fakeProviderEnumeratorAll) EnumerateAll(_ context.Context, resourceType string) ([]*interfaces.ResourceOutput, error) {
f.lastType = resourceType
return f.keys, nil
}

// fakeIaCProviderForAuditKeys is the minimal IaCProvider implementation
// required to exercise runInfraAuditKeysCmd's dispatcher path. It
// implements interfaces.EnumeratorAll (so the dispatcher's type-assert
// succeeds) plus stubs of every other IaCProvider method (returning nil/
// zero values) so go's type system is satisfied. Real cloud calls are
// never reached because audit-keys only invokes EnumerateAll.
type fakeIaCProviderForAuditKeys struct {
keys []*interfaces.ResourceOutput
}

func (f *fakeIaCProviderForAuditKeys) Name() string { return "fake-do" }
func (f *fakeIaCProviderForAuditKeys) Version() string { return "0.0.0-test" }
func (f *fakeIaCProviderForAuditKeys) Initialize(_ context.Context, _ map[string]any) error {
return nil
}
func (f *fakeIaCProviderForAuditKeys) Capabilities() []interfaces.IaCCapabilityDeclaration {
return nil
}
func (f *fakeIaCProviderForAuditKeys) Plan(_ context.Context, _ []interfaces.ResourceSpec, _ []interfaces.ResourceState) (*interfaces.IaCPlan, error) {
return nil, nil
}
func (f *fakeIaCProviderForAuditKeys) Apply(_ context.Context, _ *interfaces.IaCPlan) (*interfaces.ApplyResult, error) {
return nil, nil
}
func (f *fakeIaCProviderForAuditKeys) Destroy(_ context.Context, _ []interfaces.ResourceRef) (*interfaces.DestroyResult, error) {
return nil, nil
}
func (f *fakeIaCProviderForAuditKeys) Status(_ context.Context, _ []interfaces.ResourceRef) ([]interfaces.ResourceStatus, error) {
return nil, nil
}
func (f *fakeIaCProviderForAuditKeys) DetectDrift(_ context.Context, _ []interfaces.ResourceRef) ([]interfaces.DriftResult, error) {
return nil, nil
}
func (f *fakeIaCProviderForAuditKeys) Import(_ context.Context, _, _ string) (*interfaces.ResourceState, error) {
return nil, nil
}
func (f *fakeIaCProviderForAuditKeys) ResolveSizing(_ string, _ interfaces.Size, _ *interfaces.ResourceHints) (*interfaces.ProviderSizing, error) {
return nil, nil
}
func (f *fakeIaCProviderForAuditKeys) ResourceDriver(_ string) (interfaces.ResourceDriver, error) {
return nil, nil
}
func (f *fakeIaCProviderForAuditKeys) SupportedCanonicalKeys() []string { return nil }
func (f *fakeIaCProviderForAuditKeys) BootstrapStateBackend(_ context.Context, _ map[string]any) (*interfaces.BootstrapResult, error) {
return nil, nil
}
func (f *fakeIaCProviderForAuditKeys) Close() error { return nil }
func (f *fakeIaCProviderForAuditKeys) EnumerateAll(_ context.Context, _ string) ([]*interfaces.ResourceOutput, error) {
return f.keys, nil
}

// TestInfraAuditKeysCmd_AcceptsConfigFlag is the smoke-test sentinel for
// the args-passing contract documented on runInfraAuditKeysCmd: the
// dispatcher MUST synthesize a clean inner-args slice with only flags
// runInfraAuditKeys understands. Forwarding the raw args slice (which
// includes --config / --env) would error inside runInfraAuditKeys with
// "flag provided but not defined: -config".
//
// Regression guard for the bug where unit tests of runInfraAuditKeys
// passed only synthetic []string{"--type", ...} and never exercised the
// dispatcher's args-passing path — real CLI invocations would error.
func TestInfraAuditKeysCmd_AcceptsConfigFlag(t *testing.T) {
// Stub the provider loader so we don't need a real iac.provider in a
// real infra.yaml — any path is fine since the seam returns our fake.
origLoad := auditKeysLoadProviders
t.Cleanup(func() { auditKeysLoadProviders = origLoad })
auditKeysLoadProviders = func(_ context.Context, _ *flag.FlagSet, _, _ string) ([]interfaces.IaCProvider, []io.Closer, error) {
return []interfaces.IaCProvider{&fakeIaCProviderForAuditKeys{
keys: []*interfaces.ResourceOutput{
{Name: "k", Type: "infra.spaces_key", ProviderID: "AK", Outputs: map[string]any{"name": "k", "access_key": "AK"}},
},
}}, nil, nil
}

// Capture stdout via the seam so we can assert the CLI rendered output.
origStdout := auditKeysStdout
t.Cleanup(func() { auditKeysStdout = origStdout })
var out bytes.Buffer
auditKeysStdout = &out

// The fix-under-test: --config + --env are in args. Pre-fix behavior
// errored with "flag provided but not defined: -config" because the
// inner FlagSet only declared --type.
if err := runInfraAuditKeysCmd([]string{
"--type", "infra.spaces_key",
"--config", "/tmp/some-infra.yaml",
"--env", "staging",
}); err != nil {
t.Fatalf("runInfraAuditKeysCmd failed; --config / --env should be accepted by the dispatcher: %v", err)
}
if !strings.Contains(out.String(), "AK") {
t.Errorf("expected dispatcher to reach EnumerateAll + render output; got: %s", out.String())
}
}

// TestInfraAuditKeys_ListsAll verifies that `wfctl infra audit-keys --type
// <T>` delegates to the provider's EnumeratorAll, then renders every
// returned key's identifying fields (Name, ProviderID/access_key) into the
// writer. This is the failing test for Task 16 of the spaces-key-iac-resource
// plan (PR5). Until Task 17 implements runInfraAuditKeys + the registration
// of `wfctl infra audit-keys`, this test fails with `undefined:
// runInfraAuditKeys`.
Comment on lines +128 to +131
Comment on lines +126 to +131
func TestInfraAuditKeys_ListsAll(t *testing.T) {
fakeProv := &fakeProviderEnumeratorAll{
keys: []*interfaces.ResourceOutput{
{
Name: "key-a",
Type: "infra.spaces_key",
ProviderID: "AK_A",
Outputs: map[string]any{
"name": "key-a",
"access_key": "AK_A",
"created_at": "2026-05-01T00:00:00Z",
},
},
{
Name: "key-b",
Type: "infra.spaces_key",
ProviderID: "AK_B",
Outputs: map[string]any{
"name": "key-b",
"access_key": "AK_B",
"created_at": "2026-05-02T00:00:00Z",
},
},
},
}

var out bytes.Buffer
exitCode := runInfraAuditKeys([]string{"--type", "infra.spaces_key"}, fakeProv, &out)
if exitCode != 0 {
Comment on lines +158 to +160
t.Fatalf("expected zero exit; got %d\nout=%s", exitCode, out.String())
}
if !strings.Contains(out.String(), "key-a") || !strings.Contains(out.String(), "key-b") {
t.Errorf("expected both keys in output; got: %s", out.String())
}
if !strings.Contains(out.String(), "AK_A") || !strings.Contains(out.String(), "AK_B") {
t.Errorf("expected access_keys in output; got: %s", out.String())
}
// CLI must have forwarded the --type flag to the enumerator.
if fakeProv.lastType != "infra.spaces_key" {
t.Errorf("EnumerateAll resourceType = %q, want %q", fakeProv.lastType, "infra.spaces_key")
}
}
Loading
Loading