feat(cli): declare secret requiredness at add time, extracting manifest_edit - #356
feat(cli): declare secret requiredness at add time, extracting manifest_edit#356djbclark wants to merge 2 commits into
Conversation
add_secret_to_manifest (and its validate_add_secret_name helper) lived in the cli module behind the cli feature, which also pulls in clap_complete, clap_complete_nushell, and is_executable for shell completions. A caller that only wants to add a secret declaration to a manifest has no way to take that capability without the completion tooling too. Moved both functions into a new manifest_edit module behind its own manifest-edit feature (which cli continues to enable, so cli's behavior is unchanged). manifest-edit depends on toml_edit alone.
`add` could only ever write a required declaration. It emits a `description` key and nothing else, and a declaration with no `required` key resolves to required -- so a secret that only some hosts need had no CLI path to being optional short of hand-editing the manifest. Downstream that manifest is root-owned, which puts it outside the companion's mediated surface entirely. This is not cosmetic: `check` fails on a required-but-unset secret and passes on an optional-but-unset one, so the gap decided whether `check` (and anything gating on it) goes red on hosts that legitimately do not set the secret. `--optional` writes an explicit `required = false`, the same shape `generate_toml_with_comments` already emits for an optional secret in `init`. Omitting the flag writes exactly the bytes `add` wrote before, so no existing manifest edit changes shape. The flag is a bool rather than `--required <bool>` because omission already means required; a value-taking flag would make the common case noisier without adding a state. There is deliberately no flag for the `at_least_one`/`exactly_one` presence-group form of `required`: those span several secrets at once and do not fit a single-secret declare. Threaded through all three layers -- `add_secret_to_manifest`, the engine's `secretspec add`, and downstream `sudo-secretspec add` -> broker `source-add`. Tests: the optional path is asserted to round-trip byte-identically through `undeclare`, not just to parse. That is the property `template-check` rests on, and `--optional` writes a longer inline table than the path the existing round-trip test covered, so it needed its own proof. Verified against a clean-tree baseline: 1206 -> 1210 passing (the 4 new tests), with an identical 21-failure set both times, all of them `provider::sops::*` from the sops CLI not being installed here. fmt clean; clippy reports nothing new (the two `cli/mod.rs` findings are pre-existing orphaned doc comments on sudo-main, absent from upstream PR cachix#356).
Amends the `--optional` work in 1139f1a. Two independent AI reviews (gemini via agy, grok via cursor-agent) were run on the upstream-bound subset; both found the same real defect, from opposite directions. The premise "omitting the flag means required" was FALSE. A profile carrying `defaults = { required = false }` makes an omitted `required` key resolve to *optional* (config.rs:2500 -> manifest.rs:57). So with only `--optional`, both paths produced an optional secret and there was no way to declare a required one in such a profile at all. Verified empirically, not just by reading: a secret added there came back `(optional)` from a real `check`, and now comes back `(required)`. So requiredness is tri-state, and the library takes `Option<bool>`: None omits the key (byte-identical to what `add` wrote before), Some(v) writes it explicitly. The CLI keeps `--optional` as the common case and adds `--required` for the defaults-optional profile, marked `conflicts_with` so the two cannot both be set and the handler never has to silently pick a winner. Reviewer claims verified rather than taken: agy called the signature change "a hard SemVer break for external consumers", which is false here -- `add_secret_to_manifest` is a PRIVATE fn on upstream/main (cli/mod.rs:619) and only becomes `pub` in our own unmerged PR cachix#356, so no published signature exists to break. cursor stated the correct conditional (fine "if this lands in the same PR that introduces manifest_edit upstream"), which is satisfiable by folding this into cachix#356. No options struct is needed on those grounds. Also from the reviews: - docs: the `# 0.18+` synopsis was carrying a 0.20 flag; split it. - docs: the `check` bullet overclaimed ("passes regardless") -- false when another secret is missing, or when profile defaults already made it optional. Reworded to what is actually true. - test: `contains("required")` over the whole document was a weak assertion under an overclaiming name; now asserts the emitted declaration. - test: dropped a duplicate in cli/mod.rs that the manifest_edit test already covers more strongly. - test: added the product claim nothing proved -- that an optional declaration actually RESOLVES as optional through the engine, not merely that the text says so. - test: added a round-trip fixture mixing presence groups, a full table and a dotted key, since those are what toml_edit is likeliest to reserialize and the byte-exact claim rested on inline tables only. Full suite vs a stashed clean-tree baseline: 1206 -> 1214 passing, zero non-sops failures both runs, identical 21-failure sops set (sops CLI not installed here). fmt clean; no new clippy findings.
…uired`
`add` emitted only a `description` key, so every declaration it wrote
inherited `[defaults] required` from its profile with no way to say
otherwise. A secret only some environments need had no CLI path to being
optional short of editing the manifest by hand.
That matters beyond cosmetics: `check` fails on a required-but-unset
secret and passes on an optional-but-unset one, so the missing flag
decided whether `check` -- and anything gating on it -- goes red in an
environment that legitimately does not set the secret.
Requiredness is tri-state rather than a bool, because "no flag" is a
distinct and useful state from either explicit value:
None omit the key, inherit `[defaults] required` (unchanged)
Some(true) write `required = true`
Some(false) write `required = false`
Collapsing that to "omitting means required" would be wrong. A profile
carrying `defaults = { required = false }` makes an omitted key resolve
to *optional* (config.rs `merge_secret` -> manifest.rs `CompiledSecret`),
so `--required` is the only way to declare a required secret there.
`--optional` alone would leave that case unreachable.
Omitting both flags emits byte-identical output to before, so no
existing manifest edit changes shape. The flags are `conflicts_with` so
the handler never has to silently pick a winner between them.
No flag is offered for the `at_least_one`/`exactly_one` presence-group
form of `required`: it spans several secrets at once and does not fit a
single-secret declaration.
`add_secret_to_manifest` gains the parameter in the same PR that first
makes it `pub`, so no published signature changes.
Tests cover the emitted text for all three states, that an explicit
requiredness survives into the loaded `Config` (the actual product
claim, rather than just the bytes), that a document mixing a presence
group, a full table and a quoted dotted key is left untouched, and that
the two flags are refused together.
|
@domenkozar when you get a chance, could someone approve the workflow runs on this PR? Every run is sitting in For context on what's here now: this carries two commits, kept separate so the refactor stays reviewable on its own.
They're together deliberately. The second changes the signature of Locally: |
Summary
secretspec addcould only ever write a declaration that inherits[defaults] requiredfrom its profile — it emits adescriptionkey andnothing else. A secret that only some environments need had no CLI path to
being declared optional, short of editing the manifest by hand.
That is not cosmetic:
checkfails on a required-but-unset secret and passeson an optional-but-unset one, so the missing flag decided whether
check, andanything gating on it, goes red in an environment that legitimately does not
set the secret.
Two commits, kept separate so the refactor stays readable:
refactor(cli): extract manifest_edit module— movesadd_secret_to_manifest(andvalidate_add_secret_name) out ofcliintoa new
manifest_editmodule behind its ownmanifest-editfeature, whichclistill enables. Behavior unchanged; a caller that only needs to add adeclaration can take that feature alone, without
cli's shell-completiontooling (
clap_complete,clap_complete_nushell,is_executable).feat(cli): declare secret requiredness— adds--optionaland--required.They are in one PR deliberately: the second commit adds a parameter to
add_secret_to_manifest, and the first is what first makes that functionpub. Landing them together means no published signature ever changes.Requiredness is tri-state
Noneomits the key entirely (unchanged behavior, byte-identical output),Some(true)/Some(false)write it explicitly.Collapsing that to "omitting the flag means required" would be wrong, and this
is the part worth checking: a profile carrying
defaults = { required = false }makes an omitted key resolve to optional. So
--optionalon its own wouldleave "declare a required secret in that profile" unreachable — hence
--requiredas well. The two areconflicts_with, so the handler never has tosilently pick a winner.
No flag is offered for the
at_least_one/exactly_onepresence-group form ofrequired: it spans several secrets at once and does not fit a single-secretdeclaration.
Validation
cargo test --package secretspec --all-features -- manifest_edit— 7 passedcargo test --package secretspec --all-features cli::tests::add— 5 passedcargo test --package secretspec --all-features --lib— 1256 passed, 26pre-existing failures (21
provider::sops::*from the sops CLI not beinginstalled, 5
cli::completion::teststhat depend on the working directory).Both sets fail identically on this branch with the second commit reverted.
cargo fmt --all -- --checkcargo clippy -p secretspec --no-default-features --features cli— no newwarnings
Tests cover the emitted text for all three states, that an explicit
requiredness survives into the loaded
Configrather than only into the bytes,that a document mixing a presence group, a full table and a quoted dotted key
is left untouched, and that the two flags are refused together.