-
Notifications
You must be signed in to change notification settings - Fork 1
feat(secrets,iac): fail-safe secret reachability gate (infra-admin P2 PR4/12) #843
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
e9e4df1
feat(secrets): fail-safe Reachability gate f(backend × exec-env)
intel352 f7d5e67
feat(iac): step.iac_secret_reachability pre-flight gate
intel352 7a6d11d
fix(secrets,iac): ctx propagation + host-local fail-safe for remote +…
intel352 f0b898c
fix(secrets,iac): register step schema + nil-provider + specs_from fa…
intel352 eaf8dad
test(platform): add step.iac_secret_reachability to expectedSteps fac…
intel352 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,218 @@ | ||
| package module | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "sort" | ||
| "strings" | ||
|
|
||
| "github.com/GoCodeAlone/modular" | ||
| "github.com/GoCodeAlone/workflow/iac/specparse" | ||
| "github.com/GoCodeAlone/workflow/interfaces" | ||
| "github.com/GoCodeAlone/workflow/secrets" | ||
| ) | ||
|
|
||
| // ─── step.iac_secret_reachability ──────────────────────────────────────────── | ||
|
|
||
| // IaCSecretReachabilityStep pre-flights whether the secrets referenced in a set | ||
| // of IaC resource specs can be read from the chosen execution environment. | ||
| // | ||
| // It resolves the secrets provider from the app service registry (mirrors the | ||
| // resolveProvider pattern in SecretSetStep), gathers all distinct secret:// | ||
| // references from the spec configs (including nested maps and slices), calls | ||
| // secrets.Reachability once for the provider, and reports per-ref results. | ||
| // | ||
| // Output shape: | ||
| // | ||
| // { | ||
| // "secrets": [ {ref, reachable, reason}, ... ], | ||
| // "all_reachable": bool, | ||
| // } | ||
| // | ||
| // When there are zero secret:// refs, all_reachable is true with an empty list. | ||
| type IaCSecretReachabilityStep struct { | ||
| name string | ||
| provider string // secrets service name in the app registry | ||
| execEnv string | ||
| specs []interfaces.ResourceSpec | ||
| specsFrom string // dotted context path; mutually exclusive with specs | ||
| app modular.Application | ||
| } | ||
|
|
||
| // NewIaCSecretReachabilityStepFactory returns a StepFactory for | ||
| // step.iac_secret_reachability. | ||
| func NewIaCSecretReachabilityStepFactory() StepFactory { | ||
| return func(name string, cfg map[string]any, app modular.Application) (PipelineStep, error) { | ||
| providerName, _ := cfg["provider"].(string) | ||
| if providerName == "" { | ||
| return nil, fmt.Errorf("iac_secret_reachability step %q: 'provider' is required", name) | ||
| } | ||
|
|
||
| execEnv, _ := cfg["exec_env"].(string) | ||
|
|
||
| specsFrom, _ := cfg["specs_from"].(string) | ||
| _, hasStaticSpecs := cfg["specs"] | ||
| if specsFrom != "" && hasStaticSpecs { | ||
| return nil, fmt.Errorf("iac_secret_reachability step %q: 'specs' and 'specs_from' are mutually exclusive", name) | ||
| } | ||
|
|
||
| var specs []interfaces.ResourceSpec | ||
| if hasStaticSpecs { | ||
| var err error | ||
| specs, err = specparse.ParseResourceSpecs(cfg["specs"]) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("iac_secret_reachability step %q: parse specs: %w", name, err) | ||
| } | ||
| } | ||
|
|
||
| return &IaCSecretReachabilityStep{ | ||
| name: name, | ||
| provider: providerName, | ||
| execEnv: execEnv, | ||
| specs: specs, | ||
| specsFrom: specsFrom, | ||
| app: app, | ||
| }, nil | ||
| } | ||
| } | ||
|
|
||
| func (s *IaCSecretReachabilityStep) Name() string { return s.name } | ||
|
|
||
| func (s *IaCSecretReachabilityStep) 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_secret_reachability step %q: resolve specs_from %q: %w", s.name, s.specsFrom, err) | ||
| } | ||
| // Fail-safe: a specs_from that resolves to nil/empty (missing/misspelled | ||
| // context path, or a body lacking specs) must NOT silently bypass the | ||
| // gate with all_reachable=true. Error, matching iac_provider_plan/apply. | ||
| if len(specs) == 0 { | ||
| return nil, fmt.Errorf("iac_secret_reachability step %q: specs_from %q resolved to empty/zero specs", s.name, s.specsFrom) | ||
| } | ||
| } | ||
|
|
||
| // Resolve the secrets provider from the service registry. | ||
| p, err := resolveSecretsProvider(s.app, s.provider, s.name) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| // Gather distinct secret:// refs across all spec configs. | ||
| refs := collectSecretRefs(specs) | ||
|
|
||
| // Short-path: no secret refs → trivially reachable. | ||
| if len(refs) == 0 { | ||
| return &StepResult{Output: map[string]any{ | ||
| "secrets": []map[string]any{}, | ||
| "all_reachable": true, | ||
| }}, nil | ||
| } | ||
|
|
||
| // Call Reachability once — the verdict is provider-level, not per-ref. | ||
| // Propagate the Execute ctx so a slow/unreachable backend probe is bounded | ||
| // by the pipeline/route deadline rather than hanging the pre-flight. | ||
| verdict := secrets.Reachability(ctx, p, s.execEnv) | ||
|
|
||
| secretEntries := make([]map[string]any, 0, len(refs)) | ||
| for _, ref := range refs { | ||
| entry := map[string]any{ | ||
| "ref": ref, | ||
| "reachable": verdict.Reachable, | ||
| } | ||
| if !verdict.Reachable { | ||
| entry["reason"] = verdict.Reason | ||
| } | ||
| secretEntries = append(secretEntries, entry) | ||
| } | ||
|
|
||
| allReachable := verdict.Reachable | ||
|
|
||
| return &StepResult{Output: map[string]any{ | ||
| "secrets": secretEntries, | ||
| "all_reachable": allReachable, | ||
| }}, nil | ||
| } | ||
|
|
||
| // resolveSecretsProvider looks up a secrets.Provider from the application | ||
| // service registry by name. It mirrors the resolveProvider pattern in | ||
| // SecretSetStep: first checks if the service directly implements secrets.Provider; | ||
| // if not, checks for a Provider() accessor (used by SecretsAWSModule, | ||
| // SecretsVaultModule, etc.). | ||
| func resolveSecretsProvider(app modular.Application, providerName, stepName string) (secrets.Provider, error) { | ||
| if app == nil { | ||
| return nil, fmt.Errorf("iac_secret_reachability step %q: no application context", stepName) | ||
| } | ||
| svc, ok := app.SvcRegistry()[providerName] | ||
| if !ok { | ||
| return nil, fmt.Errorf("iac_secret_reachability step %q: secrets service %q not found in registry", stepName, providerName) | ||
| } | ||
|
|
||
| // Direct: service itself implements secrets.Provider. | ||
| if p, ok := svc.(secrets.Provider); ok { | ||
| return p, nil | ||
| } | ||
|
|
||
| // Indirect: service exposes a Provider() accessor (e.g. SecretsAWSModule, | ||
| // SecretsVaultModule). | ||
| type providerAccessor interface { | ||
| Provider() secrets.Provider | ||
| } | ||
| if acc, ok := svc.(providerAccessor); ok { | ||
| p := acc.Provider() | ||
| if p == nil { | ||
| return nil, fmt.Errorf("iac_secret_reachability step %q: service %q exposes Provider() accessor but returned nil; module may not be started", stepName, providerName) | ||
| } | ||
| return p, nil | ||
| } | ||
|
|
||
| return nil, fmt.Errorf("iac_secret_reachability step %q: service %q does not implement secrets.Provider directly or via Provider() accessor", stepName, providerName) | ||
| } | ||
|
|
||
| // collectSecretRefs walks the Config map of each ResourceSpec and returns a | ||
| // sorted, deduplicated slice of all string values that start with secrets.SecretPrefix. | ||
| // It recurses into nested map[string]any, []any, and typed []string values so | ||
| // both YAML-decoded and programmatically-built spec configs are fully scanned. | ||
| func collectSecretRefs(specs []interfaces.ResourceSpec) []string { | ||
| seen := make(map[string]struct{}) | ||
| for _, spec := range specs { | ||
| collectFromValue(spec.Config, seen) | ||
| } | ||
| refs := make([]string, 0, len(seen)) | ||
| for ref := range seen { | ||
| refs = append(refs, ref) | ||
| } | ||
| sort.Strings(refs) | ||
| return refs | ||
| } | ||
|
|
||
| // collectFromValue recursively extracts secret:// refs from an arbitrary value. | ||
| // It handles both YAML-decoded shapes (map[string]any, []any) and typed Go | ||
| // shapes built programmatically (notably []string), since ResourceSpec.Config | ||
| // may carry either when specs are constructed in code rather than parsed. | ||
| func collectFromValue(v any, seen map[string]struct{}) { | ||
| switch val := v.(type) { | ||
| case string: | ||
| if strings.HasPrefix(val, secrets.SecretPrefix) { | ||
| seen[val] = struct{}{} | ||
| } | ||
| case []string: | ||
| for _, item := range val { | ||
| if strings.HasPrefix(item, secrets.SecretPrefix) { | ||
| seen[item] = struct{}{} | ||
| } | ||
| } | ||
| case map[string]any: | ||
| for _, child := range val { | ||
| collectFromValue(child, seen) | ||
| } | ||
| case []any: | ||
| for _, item := range val { | ||
| collectFromValue(item, seen) | ||
| } | ||
| } | ||
| } | ||
93 changes: 93 additions & 0 deletions
93
module/pipeline_step_iac_secret_reachability_internal_test.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| package module | ||
|
|
||
| import ( | ||
| "reflect" | ||
| "testing" | ||
|
|
||
| "github.com/GoCodeAlone/workflow/interfaces" | ||
| ) | ||
|
|
||
| // TestCollectSecretRefs exercises collectSecretRefs/collectFromValue across every | ||
| // container shape a ResourceSpec.Config can carry: a flat string ref, a ref | ||
| // nested inside a map[string]any, a ref inside an []any of strings, a ref inside | ||
| // an []any of maps, a ref inside a typed []string (programmatically-built specs), | ||
| // and double-nesting. It also asserts dedup + sorted order. | ||
| func TestCollectSecretRefs(t *testing.T) { | ||
| specs := []interfaces.ResourceSpec{ | ||
| { | ||
| Name: "r1", | ||
| Type: "infra.database", | ||
| Config: map[string]any{ | ||
| // flat string ref | ||
| "password": "secret://vault/db-pass", | ||
| // non-ref string (ignored) | ||
| "region": "us-east-1", | ||
| // ref nested in a map[string]any | ||
| "nested": map[string]any{ | ||
| "token": "secret://vault/nested-token", | ||
| }, | ||
| // ref in an []any of strings | ||
| "list_any_strings": []any{ | ||
| "plain", | ||
| "secret://vault/from-anylist", | ||
| }, | ||
| // ref in an []any of maps | ||
| "list_any_maps": []any{ | ||
| map[string]any{"key": "secret://vault/from-anymap"}, | ||
| }, | ||
| // ref in a typed []string (programmatically-built) | ||
| "list_typed_strings": []string{ | ||
| "not-a-ref", | ||
| "secret://vault/from-typedlist", | ||
| }, | ||
| // double-nesting: map → slice → map → string | ||
| "deep": map[string]any{ | ||
| "items": []any{ | ||
| map[string]any{ | ||
| "deeper": map[string]any{ | ||
| "v": "secret://vault/deep-ref", | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| { | ||
| Name: "r2", | ||
| Type: "infra.database", | ||
| Config: map[string]any{ | ||
| // duplicate of r1's password — must be deduped | ||
| "password": "secret://vault/db-pass", | ||
| // a unique ref in r2 | ||
| "api": "secret://vault/r2-api", | ||
| }, | ||
| }, | ||
| } | ||
|
|
||
| got := collectSecretRefs(specs) | ||
|
|
||
| want := []string{ | ||
| "secret://vault/db-pass", | ||
| "secret://vault/deep-ref", | ||
| "secret://vault/from-anylist", | ||
| "secret://vault/from-anymap", | ||
| "secret://vault/from-typedlist", | ||
| "secret://vault/nested-token", | ||
| "secret://vault/r2-api", | ||
| } | ||
|
|
||
| if !reflect.DeepEqual(got, want) { | ||
| t.Errorf("collectSecretRefs mismatch:\n got: %#v\n want: %#v", got, want) | ||
| } | ||
| } | ||
|
|
||
| // TestCollectSecretRefs_NoRefs asserts an empty (non-nil) slice when no refs exist. | ||
| func TestCollectSecretRefs_NoRefs(t *testing.T) { | ||
| specs := []interfaces.ResourceSpec{ | ||
| {Name: "r1", Type: "infra.database", Config: map[string]any{"size": "small"}}, | ||
| } | ||
| got := collectSecretRefs(specs) | ||
| if len(got) != 0 { | ||
| t.Errorf("expected no refs, got %#v", got) | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.