feat(make): add edit-vault + check-vault targets

`make edit-vault` runs `ansible-vault edit` (decrypt → nvim → re-encrypt on :wq,
abort on :cq) so editing the vault is one step with no plaintext left in the work
tree, then validates structure. `make check-vault` runs scripts/check-vault.py:
decrypts in-memory, asserts valid YAML with secrets under the nested `vault:` map
and no empty leaves, and prints a values-masked structure view (comments visible,
secrets never printed). Both default to the production all-vault; override VAULT=.

Update the vault header comment, CLAUDE.md (command table + Secrets section), and
scripts/README to point at edit-vault (note check-vault.py is the one venv-
dependent helper, by design).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sjat 2026-06-14 09:36:15 +02:00
parent 43e5a4aa53
commit 79f2315eee
5 changed files with 174 additions and 39 deletions

View file

@ -33,6 +33,8 @@ Full design rationale: `docs/decisions/`
| Scaffold a new role | `make new-role NAME=<name>` |
| Review repo for drift/cruft | `/review-repo` (Claude command) |
| Review hardware capacity | `/capacity-review` (Claude command) |
| Edit the vault (nvim, auto re-encrypt) | `make edit-vault [VAULT=<path>]` |
| Validate vault structure | `make check-vault [VAULT=<path>]` |
| Encrypt a vault file | `make encrypt FILE=<path>` |
| Decrypt a vault file | `make decrypt FILE=<path>` |
| Install Python deps | `make setup` |
@ -76,7 +78,13 @@ Full design rationale: `docs/decisions/`
git commit** — the pre-commit ansible-lint hook decrypts `vault.yml`), run `rbw
unlocked`; if it exits non-zero, ask the user to `rbw unlock` and wait rather than
starting and failing partway. The agent stays unlocked 5h.
- To edit a vault file: `make decrypt FILE=<path>`, edit, `make encrypt FILE=<path>`
- To edit the vault: `make edit-vault` — decrypts → opens nvim → re-encrypts on `:wq`
(abort with `:cq`), then `check-vault` validates structure. No plaintext lands in the
work tree. Override the file with `VAULT=<path>`. (The lower-level `make decrypt`/
`encrypt FILE=<path>` still exist for scripted/non-interactive edits.)
- `make check-vault` validates the vault decrypts, is valid YAML, keeps secrets under the
nested `vault:` map, and has no empty leaves — printing a structure view with values
masked. Needs `rbw` unlocked.
---

View file

@ -11,6 +11,8 @@ LINT := $(VENV)/bin/ansible-lint
MOLECULE := $(VENV)/bin/molecule
# Vault password is resolved via ansible.cfg (vault_password_file); no flag needed.
VAULT_ARGS :=
# Default vault file for edit-vault / check-vault (override with VAULT=<path>).
VAULT ?= inventories/production/group_vars/all/vault.yml
INVENTORY := -i inventories/production/hosts.yml
TF := terraform
@ -20,7 +22,8 @@ MOLECULE_DOCKERFILE := .docker/molecule-debian13/Dockerfile
.DEFAULT_GOAL := help
.PHONY: help setup collections lint test test-all check deploy encrypt decrypt new-role \
.PHONY: help setup collections lint test test-all check deploy encrypt decrypt \
edit-vault check-vault new-role \
tf-init tf-plan tf-apply tf-output tf-inventory \
molecule-image molecule-image-push
@ -35,6 +38,8 @@ help:
@echo " make test-all Run Molecule tests for all roles"
@echo " make check PLAYBOOK=<name> Dry-run a playbook (check mode)"
@echo " make deploy PLAYBOOK=<name> Run a playbook against production"
@echo " make edit-vault [VAULT=<path>] Edit the vault in nvim (auto re-encrypts + checks)"
@echo " make check-vault [VAULT=<path>] Validate vault structure (values masked)"
@echo " make encrypt FILE=<path> Encrypt a vault file"
@echo " make decrypt FILE=<path> Decrypt a vault file"
@echo " make new-role NAME=<name> Scaffold a new role"
@ -99,6 +104,16 @@ endif
# ── Vault ─────────────────────────────────────────────────────────────────────
# Streamlined edit: ansible-vault edit decrypts to a temp file, opens nvim, and
# re-encrypts on :wq (abort with :cq) — no plaintext ever lands in the work tree.
# Then validate structure. Override the file with VAULT=<path>.
edit-vault:
EDITOR=nvim $(ANSIBLE)-vault edit $(VAULT)
@$(PYTHON) scripts/check-vault.py $(VAULT)
check-vault:
@$(PYTHON) scripts/check-vault.py $(VAULT)
encrypt:
ifndef FILE
$(error FILE is required: make encrypt FILE=<path>)

View file

@ -1,37 +1,43 @@
$ANSIBLE_VAULT;1.1;AES256
38396433323934623233313463363239316466643961613831383139333930356637633435656230
6331353433376534313335366438376333633062363437340a313230313562343130323563666438
66303431333365326337313632366336343439323765633034386163353335356630643834653033
3434386462653363340a316463616230323436666161393030386636376233396230653334333631
36633639623838313563383638653562653133343664613232316532623734386461393762393435
38643835623534663930626265653964393262343862613233336635393130386262316262336366
33356337366566623265636631613830653836643762316433353836393466346332346362623362
37616331366261333161663862376431646166323335656136363733313762313066633465303037
63363231656366306536636166333438346335386534366131333965613161623435653238303564
39656163626166656363393165346534323938663463323465323232393237616434366264353530
31313235326135366264666234663062653239643464306538323565613562653031363135326530
64313034323566396536336464393864313034346532626339653562336663346532303633306334
38386563346530316530316239306131666438616533343061313463313330653235336636663238
36663638363930383763626432303235366338373462633264386564616135666266376166303464
34303864333366303163396131393336303638363534316561633762613537363934323261653233
66383739373833323564366364383963373331653233396564303664326131633539663636316531
36663730363462316633363033376430386537336164343531333036336339623966643764326332
66613066383165333033643863396663323734386335376531646561663731646435366535343264
31396532393639353666646564643334653061616166623536393261656333653134386430643133
30626465663637336166343865663234633439616333616539663666373663383563373632386462
34363532643065396132666436613337663732333030613436343137646365366538373437643861
38346462383264636264653938666339363162633266326262616139643937363232396534316161
34393633396564306165623864343330336539653330306264666632316430663431396130613031
66646466613865383965633966613561646663633265373932306538663534623164323434303936
31353838663638333530363338653962643630626131663066643732326232363163653330386331
33376132393465316533373337643533626530636566353436323636653263623034346139383261
35313065366463633731313362633264343835376339303563323335616365383339333730366231
31376362363162393061376366643935343962356431343137633364623563663564626538316664
35353465653266393661363232353261633437356531323963303433623565386130326265656531
36303264383261366661643064653734383664373232313562636633326530316666643836663537
62306434313064653633336565353464336663616237386665373066366133643537323230323036
36353632373138336639353462306537303965383966303962613062316437343534346531373764
35323933383130656432373530343734323436623366623737346162646166383337353334373738
38613137316665643831353131613165316130623933626636636533653632636561303566653936
33366132383563326664616462346437353764646362363638303138396164616266366662613035
37656565623536373762
65383139656131616632663463316634636538646432653234623230623764656636346531373531
3737323364333763633863383539323434616266616266640a666339343233343237373236333532
34643433613566353638313763643564303638396235323335616338353134613138313837383633
6466386535303466350a656565386239376265333230333261396261363238393031653263366232
34316638646237333966616635393831306230616463303832343061353431396334393137353163
33313835633933396161326133313565303336666330663964646336643965316463393734656439
37303938316563616338346430633961336563643533633132663938323638633930616633623066
61366634663063623133626566663030373662646536633933393730373263346265383063636537
64623338646433663530393836333432383862316431353162346531386231393735313936653666
61616231626138316630383865633365316461666166663364613230316361636232353361653136
62343466353537376164366534666434333066633239613932646634353563653562353664616134
39393533626533656364303439386530343266373634323365306131306535353865666133383863
36373138663063396635333633636162323262636363363566386461383336346562616532613139
30396534363733393764323135326532313234656539303966323538633063653832643361633662
62663563383131323063303365376237303961613363363837653930343433326235376461303939
64316331656466303031353962376436643937653762613266643030333764393763373366386137
63333663343135343735363332303737323834323238666265333166313764376364656166363232
37613932666461303936323465353263383133646238383635373166656561366661346564653637
35383437373661346563616338303962623536353864393033343362346134333265316164316264
63663664666666333631356538303337313862376261633031346161333037366230376363373237
62323934333435366162613361353537376539616163393131386135663837666263313838363131
37303839363565646361303639623933663965623431376134326533383262303261643037343435
64633537656337316539376661636130616466396463663737306666663961336464333033353935
37316164306366323332656462323031363666613533353737393432313462316462646431643730
63306661623939643635653336353562303863636266326635363536626431363864383663386664
38653264323633643133646663326362306632303532393139373365386432323062616262386639
35626463396139336431303839366163326634663963623064396666613263373263663438343335
62633161386238396135316431303737393235616535646331336336326230373366636232346662
34323433666330396463346536616538613965666564663434643266303235666632363031316164
62616132666434653939373639636262663561323935383039313134643730393339633664326563
39373034386630333031623538303238643166353735646130303434303666643436623339343531
30316335636635386563376231623430333434366630313166613733346434353937666233343831
31626537663766653663323633626630663534363031323763656365656431303762626162396239
35616632343233366532386161656336313361353637336263303938633533613433306465363666
64643265366330353665343666313539633134383236356136353231376230303066666135396264
36663134303436313663386332306164373565666365353338376534303035666330333536356136
61383530343132346363633835633833643032303039613637663263653065323531633931616137
34373837356665636464393964356537373134613532333338656533656664396134366364656665
62343463363761636134333562666439343036613661663639636639616362333734653932626231
36653164653262343066663065646631613339303237633265303365613533656462653134616439
36366431303337336361313939366335353934326233373131383634623632343739343933316430
6530616566366438383034646131336334616265333831356561

View file

@ -1,10 +1,15 @@
# scripts/
Small helper scripts. **Python standard library only** — no third-party
dependencies (keeps them runnable anywhere without a venv).
dependencies (keeps them runnable anywhere without a venv). One deliberate
exception: `check-vault.py` is a vault tool that needs the ansible venv (PyYAML +
`ansible-vault`) and `rbw`, so it is not run-anywhere by design.
- `tf_to_inventory.py` — reads `terraform output -json` on stdin and writes an
Ansible `hosts.yml`. Invoked by `make tf-inventory`. Data contract: **ADR-009**.
- `check-vault.py` — validates a vault file's structure (decrypts in-memory; valid
YAML; secrets under the nested `vault:` map; no empty leaves) and prints a
values-masked view. Invoked by `make check-vault` and after `make edit-vault`.
- `vault-pass-client.sh` — fetches the master vault password from Vaultwarden via
`rbw`. Wired as `vault_password_file` (ADR-002).
- `check-vault-encrypted.sh` — pre-commit guard: fails if a `vault.yml` holds

101
scripts/check-vault.py Executable file
View file

@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Validate an ansible-vault file's structure without exposing secret values.
Decrypts in-memory via ``ansible-vault view`` (using the configured
``vault_password_file`` from ansible.cfg so rbw must be unlocked), parses the
YAML, and checks:
- the file is ansible-vault encrypted on disk;
- it decrypts to valid YAML that is a mapping;
- top-level keys are within the allowed set (``vault`` + the ``vault__confirm``
canary) secrets belong under the nested ``vault:`` map (CLAUDE.md);
- ``vault.<service>.<key>`` leaves are all non-empty strings.
Prints a REDACTED view (comments + key tree, values masked) so a human can eyeball
format and comments. Secret values are never printed.
Unlike the stdlib-only utility scripts (TODO 14), this one deliberately depends on
the ansible venv (PyYAML) + ``ansible-vault`` + rbw it is a vault tool, not a
run-anywhere helper. Invoked by ``make check-vault`` / ``make edit-vault``.
Usage: check-vault.py [VAULT_FILE]
"""
import pathlib
import subprocess
import sys
try:
import yaml
except ImportError:
sys.exit("check-vault: needs PyYAML — run inside the ansible venv (make check-vault)")
DEFAULT = "inventories/production/group_vars/all/vault.yml"
ALLOWED_TOPLEVEL = {"vault", "vault__confirm"}
def main() -> int:
path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT
p = pathlib.Path(path)
if not p.exists():
sys.exit(f"check-vault: {path} not found")
if not p.read_text(errors="replace").startswith("$ANSIBLE_VAULT"):
sys.exit(f"check-vault: {path} is not ansible-vault encrypted")
# Decrypt in-memory via the venv's ansible-vault (picks up vault_password_file).
av = pathlib.Path(sys.executable).parent / "ansible-vault"
av = str(av) if av.exists() else "ansible-vault"
r = subprocess.run([av, "view", path], capture_output=True, text=True)
if r.returncode != 0:
sys.exit(f"check-vault: cannot decrypt {path} (is rbw unlocked?)\n{r.stderr.strip()[:300]}")
text = r.stdout
try:
data = yaml.safe_load(text)
except yaml.YAMLError as e:
sys.exit(f"check-vault: invalid YAML after decrypt: {e}")
if not isinstance(data, dict):
sys.exit("check-vault: vault root is not a mapping")
errors = []
extra = set(data) - ALLOWED_TOPLEVEL
if extra:
errors.append(
f"unexpected top-level key(s) {sorted(extra)} — secrets belong under "
f"the `vault:` map (CLAUDE.md)")
if "vault" not in data:
errors.append("missing top-level `vault:` map")
elif not isinstance(data["vault"], dict):
errors.append("`vault:` is not a mapping")
else:
for svc, kv in data["vault"].items():
if not isinstance(kv, dict):
errors.append(f"vault.{svc} is not a mapping of <key>: <secret>")
continue
for k, v in kv.items():
if not isinstance(v, str) or not v.strip():
errors.append(f"vault.{svc}.{k} is empty or not a string")
# Redacted structure (comments + masked values) for human review.
print(f"# {path} — redacted structure (secret values masked)")
for line in text.splitlines():
s = line.strip()
if not s or s.startswith("#") or s.endswith(":"):
print(line)
elif ":" in line:
print(line.split(":", 1)[0] + ': "***"')
else:
print(" ***")
if isinstance(data.get("vault"), dict):
print("\n# services under vault: " + ", ".join(sorted(data["vault"])))
if errors:
print("\ncheck-vault: FAIL", file=sys.stderr)
for e in errors:
print(f" - {e}", file=sys.stderr)
return 1
print("\ncheck-vault: OK")
return 0
if __name__ == "__main__":
sys.exit(main())