From be5e699b53c09372e96f4a2bedf553c7b72e48ec Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Mon, 10 Aug 2026 16:04:06 -0400 Subject: [PATCH] feat: allow apply to skip secrets --- README.md | 2 +- docs/secrets.md | 10 +++++ integration/secrets_test.go | 11 +++++ internal/application/apply/evaluate.go | 3 +- internal/application/apply/service_test.go | 28 ++++++++++++ internal/application/apply/types.go | 1 + internal/application/evaluation/service.go | 52 +++++++++++++++++++--- internal/application/evaluation/types.go | 5 ++- internal/cli/apply.go | 5 ++- internal/cli/apply_test.go | 4 +- 10 files changed, 108 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 764ff38..38a4bcc 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ configuration directory—in `_routes.toml`. | `cattery version` | Print build information. | Global options are `--repo PATH` and `--verbose`. `apply` supports `--dry-run`, -`--non-interactive`, and `--no-hooks`; `add` supports `--group`, `--platform`, +`--non-interactive`, `--no-hooks`, and `--skip-secrets`; `add` supports `--group`, `--platform`, `--secret`, and `--dry-run`; `forget` supports `--dry-run` and requires `--yes` to remove repository sources. Secret lifecycle commands accept repeatable `--source REPOSITORY_PATH` selectors; `secrets reencrypt` previews by default diff --git a/docs/secrets.md b/docs/secrets.md index e822e51..e4cd7f9 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -46,6 +46,16 @@ repo/app/_darwin/_secrets/.config/example/token Use `--group` or `--platform` with `add` when you need to choose where the source belongs. Run `cattery status` and `cattery apply` as usual afterward. +To deploy ordinary files while leaving every secret target and secret baseline +untouched, run: + +```sh +cattery apply --skip-secrets +``` + +The flag also prevents secret decryption and SOPS dependency checks. Group +selection still applies normally to the remaining ordinary files and aliases. + `cattery add --secret` is the adoption workflow. The lifecycle commands below inventory, verify, or rotate sources that are already managed; they do not create, edit, or delete plaintext secrets. diff --git a/integration/secrets_test.go b/integration/secrets_test.go index fabc551..971c798 100644 --- a/integration/secrets_test.go +++ b/integration/secrets_test.go @@ -205,6 +205,7 @@ func testSecretsDependency(t *testing.T) { env := newExecEnv(t) env.initRepository(t) writeFile(t, filepath.Join(env.repo, "_secrets", "token"), []byte(`{"data":"eA==","sops":{"version":"3.9.0"}}`)) + writeFile(t, filepath.Join(env.repo, ".ordinary"), []byte("ordinary source")) writeFile(t, filepath.Join(env.home, "token"), []byte("plaintext")) env.extraEnv = append(env.extraEnv, "PATH="+sopsFreePath()) result := env.secretRun(t, nil, "apply") @@ -218,6 +219,16 @@ func testSecretsDependency(t *testing.T) { if string(readTargetFile(t, env.home, "token")) != "plaintext" { t.Fatal("verification changed the target") } + skippedSecrets := env.secretRun(t, nil, "apply", "--skip-secrets") + if skippedSecrets.Code != 0 { + t.Fatalf("apply --skip-secrets = %+v", skippedSecrets) + } + if string(readTargetFile(t, env.home, ".ordinary")) != "ordinary source" { + t.Fatal("ordinary target was not applied") + } + if string(readTargetFile(t, env.home, "token")) != "plaintext" { + t.Fatal("apply --skip-secrets changed the secret target") + } } // installIdentity copies the fixture identity into one home so sops can diff --git a/internal/application/apply/evaluate.go b/internal/application/apply/evaluate.go index 3c66914..6aded47 100644 --- a/internal/application/apply/evaluate.go +++ b/internal/application/apply/evaluate.go @@ -57,8 +57,7 @@ func (service *Service) Evaluate(ctx context.Context, request Request) (Candidat func (service *Service) evaluate(ctx context.Context, request Request) (Candidates, error) { shared, err := service.evaluator.Evaluate(ctx, evaluation.Request{ - Repository: request.Repository, - Groups: request.Groups, + Repository: request.Repository, Groups: request.Groups, ExcludeSecrets: request.SkipSecrets, }) if err != nil { return Candidates{}, err diff --git a/internal/application/apply/service_test.go b/internal/application/apply/service_test.go index 7dbd66e..9ce24a5 100644 --- a/internal/application/apply/service_test.go +++ b/internal/application/apply/service_test.go @@ -24,6 +24,7 @@ func TestApplyService(t *testing.T) { {"partial results preserved", testServicePartialPreserved}, {"verification downgrades", testServiceVerifyDowngrade}, {"cancellation", testServiceCancellation}, + {"secrets skipped", testServiceSkipSecrets}, } for _, scenario := range scenarios { t.Run(scenario.name, scenario.run) @@ -197,3 +198,30 @@ func testServiceCancellation(t *testing.T) { t.Fatal("cancelled apply must fail") } } + +func testServiceSkipSecrets(t *testing.T) { + repo, home := t.TempDir(), t.TempDir() + ordinary := ordinarySource(t, fileSpec{Repo: repo, Target: "ordinary", Relative: "files/ordinary"}, []byte("ordinary source")) + secret := secretSource(t, fileSpec{Repo: repo, Target: "secret", Relative: "files/secret"}) + writeTarget(t, targetPath(home, "secret"), []byte("keep secret target")) + probe := &probeFake{err: failure.New(failure.Dependency, "sops unavailable", nil)} + baselines, replacer := &baselineFake{}, &replacerFake{} + service := evalFixture(t, evalInput{ + repo: repo, home: home, plan: evalPlan(t, repo, ordinary, secret), + probe: probe, baselines: baselines, replacer: replacer, + }) + + result, err := service.Apply(context.Background(), Request{SkipSecrets: true}) + if err != nil { + t.Fatalf("apply: %v", err) + } + if result.Summary.Completed != 1 || len(result.Items) != 1 || result.Items[0].TargetPath != "ordinary" { + t.Fatalf("result = %+v", result) + } + if probe.calls != 0 || replacer.calls != 1 || baselines.calls != 1 { + t.Fatalf("calls: probe=%d replacer=%d baselines=%d", probe.calls, replacer.calls, baselines.calls) + } + if got := string(targetContent(t, home, "secret")); got != "keep secret target" { + t.Fatalf("secret target = %q", got) + } +} diff --git a/internal/application/apply/types.go b/internal/application/apply/types.go index 62af512..7662e43 100644 --- a/internal/application/apply/types.go +++ b/internal/application/apply/types.go @@ -130,6 +130,7 @@ type Request struct { DryRun bool NonInteractive bool NoHooks bool + SkipSecrets bool } // DecisionChoice is the application-owned choice vocabulary one prompt may diff --git a/internal/application/evaluation/service.go b/internal/application/evaluation/service.go index c078003..c909199 100644 --- a/internal/application/evaluation/service.go +++ b/internal/application/evaluation/service.go @@ -73,13 +73,16 @@ func (service *Service) Evaluate(ctx context.Context, request Request) (Result, if err != nil { return Result{}, err } - return service.evaluateRows(ctx, evaluationInput{identity: identity, rows: rows, groups: request.Groups}) + return service.evaluateRows(ctx, evaluationInput{ + identity: identity, rows: rows, groups: request.Groups, excludeSecrets: request.ExcludeSecrets, + }) } type evaluationInput struct { - identity RepositoryIdentity - rows stateRows - groups []string + identity RepositoryIdentity + rows stateRows + groups []string + excludeSecrets bool } func (service *Service) evaluateRows(ctx context.Context, input evaluationInput) (Result, error) { @@ -125,13 +128,52 @@ func (service *Service) selected(input evaluationInput, full deployment.Plan, ch if err != nil { return deployment.Plan{}, reconcile.StateSnapshot{}, err } - snapshot, err := reconcile.NewStateSnapshot(selectedRows(input.identity, input.rows, chosen)) + rows := selectedRows(input.identity, input.rows, chosen) + if input.excludeSecrets { + plan, rows, err = excludeSecrets(plan, rows) + if err != nil { + return deployment.Plan{}, reconcile.StateSnapshot{}, failure.New(failure.InvalidInput, service.commandLabel+": exclude secrets", err) + } + } + snapshot, err := reconcile.NewStateSnapshot(rows) if err != nil { return deployment.Plan{}, reconcile.StateSnapshot{}, failure.New(failure.Operational, service.commandLabel+": snapshot state", err) } return plan, snapshot, nil } +func excludeSecrets(plan deployment.Plan, rows reconcile.StateRows) (deployment.Plan, reconcile.StateRows, error) { + secretTargets := make(map[string]bool) + for _, file := range plan.Files() { + if file.Kind == deployment.FileSecret { + secretTargets[file.TargetRelativePath] = true + } + } + for _, row := range rows.Files { + if row.SourceKind == deployment.FileSecret { + secretTargets[row.TargetPath] = true + } + } + + files := slices.DeleteFunc(plan.Files(), func(file deployment.ManagedFile) bool { + return file.Kind == deployment.FileSecret + }) + aliases := slices.DeleteFunc(plan.Aliases(), func(alias deployment.Alias) bool { + return secretTargets[alias.CanonicalTargetRelativePath] || secretTargets[alias.AliasRelativePath] + }) + rows.Files = slices.DeleteFunc(rows.Files, func(row state.FileBaseline) bool { + return row.SourceKind == deployment.FileSecret + }) + rows.Aliases = slices.DeleteFunc(rows.Aliases, func(row state.AliasBaseline) bool { + return secretTargets[row.CanonicalTargetPath] || secretTargets[row.AliasPath] + }) + filtered, err := deployment.NewPlan(deployment.PlanInput{ + RepositoryRoot: plan.RepositoryRoot(), Platform: plan.Platform(), Groups: plan.Groups(), + Files: files, Aliases: aliases, Hooks: plan.Hooks(), + }) + return filtered, rows, err +} + func (service *Service) resolve(input RepositoryInput) (RepositoryIdentity, error) { identity, err := service.source.Resolve(selection.RepositoryRequest{ RawExplicit: input.RawExplicit, diff --git a/internal/application/evaluation/types.go b/internal/application/evaluation/types.go index 4311bbf..6423e48 100644 --- a/internal/application/evaluation/types.go +++ b/internal/application/evaluation/types.go @@ -42,8 +42,9 @@ type RepositoryInput = applicationrepository.RepositoryInput // Request is the shared input of one evaluation. type Request struct { - Repository RepositoryInput - Groups []string + Repository RepositoryInput + Groups []string + ExcludeSecrets bool } // Record joins one immutable reconciliation evaluation with all classifications diff --git a/internal/cli/apply.go b/internal/cli/apply.go index e6386bc..6a7a8c5 100644 --- a/internal/cli/apply.go +++ b/internal/cli/apply.go @@ -14,7 +14,7 @@ type ApplyService interface { } // newApplyCommand declares the apply syntax and mechanically maps the raw -// repository fields, group arguments, and dry-run/noninteractive/no-hooks +// repository fields, group arguments, and dry-run/noninteractive/no-hooks/skip-secrets // policy into one apply call. The service carries // the prompt resolver; no decision policy or hook order appears here. func newApplyCommand(service ApplyService, runtime Runtime, options *Options) *cobra.Command { @@ -37,6 +37,7 @@ func newApplyCommand(service ApplyService, runtime Runtime, options *Options) *c command.Flags().Bool("dry-run", false, "show the plan without writing") command.Flags().Bool("non-interactive", false, "refuse unresolved decisions") command.Flags().Bool("no-hooks", false, "skip trusted hooks") + command.Flags().Bool("skip-secrets", false, "skip encrypted secret targets") return command } @@ -55,12 +56,14 @@ func applyRequest(command *cobra.Command, input applyInput) apply.Request { dryRun, _ := command.Flags().GetBool("dry-run") nonInteractive, _ := command.Flags().GetBool("non-interactive") noHooks, _ := command.Flags().GetBool("no-hooks") + skipSecrets, _ := command.Flags().GetBool("skip-secrets") return apply.Request{ Repository: applyRepository(options, input.runtime), Groups: append([]string(nil), input.groups...), DryRun: dryRun, NonInteractive: nonInteractive, NoHooks: noHooks, + SkipSecrets: skipSecrets, } } diff --git a/internal/cli/apply_test.go b/internal/cli/apply_test.go index c6c5816..67d1379 100644 --- a/internal/cli/apply_test.go +++ b/internal/cli/apply_test.go @@ -51,7 +51,7 @@ func applyFixture(t *testing.T, service *applyServiceFake, options Options) (*co func testApplyFlags(t *testing.T) { service := &applyServiceFake{result: applyResult()} command, _ := applyFixture(t, service, Options{}) - command.SetArgs([]string{"-r", "repo", "--non-interactive", "--no-hooks", "apps", "tools"}) + command.SetArgs([]string{"-r", "repo", "--non-interactive", "--no-hooks", "--skip-secrets", "apps", "tools"}) if err := command.Execute(); err != nil { t.Fatalf("run: %v", err) } @@ -59,7 +59,7 @@ func testApplyFlags(t *testing.T) { if request.Repository.RawExplicit != "repo" || !request.Repository.ExplicitSet { t.Fatalf("repository = %+v, want the flag value", request.Repository) } - if !request.NonInteractive || !request.NoHooks { + if !request.NonInteractive || !request.NoHooks || !request.SkipSecrets { t.Fatalf("policy = %+v, want the explicit flags", request) } if len(request.Groups) != 2 || request.Groups[0] != "apps" || request.Groups[1] != "tools" { -- 2.51.2