From 79f2315eee9e04e16220e456d8242cb638a021e8 Mon Sep 17 00:00:00 2001 From: sjat Date: Sun, 14 Jun 2026 09:36:15 +0200 Subject: [PATCH] feat(make): add edit-vault + check-vault targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- CLAUDE.md | 10 +- Makefile | 17 ++- .../production/group_vars/all/vault.yml | 78 +++++++------- scripts/README.md | 7 +- scripts/check-vault.py | 101 ++++++++++++++++++ 5 files changed, 174 insertions(+), 39 deletions(-) create mode 100755 scripts/check-vault.py diff --git a/CLAUDE.md b/CLAUDE.md index 8e6b265..5efc2f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,6 +33,8 @@ Full design rationale: `docs/decisions/` | Scaffold a new role | `make new-role 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=]` | +| Validate vault structure | `make check-vault [VAULT=]` | | Encrypt a vault file | `make encrypt FILE=` | | Decrypt a vault file | `make decrypt FILE=` | | 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=`, edit, `make encrypt FILE=` +- 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=`. (The lower-level `make decrypt`/ + `encrypt FILE=` 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. --- diff --git a/Makefile b/Makefile index 27b6b86..c1e9eb8 100644 --- a/Makefile +++ b/Makefile @@ -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=). +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= Dry-run a playbook (check mode)" @echo " make deploy PLAYBOOK= Run a playbook against production" + @echo " make edit-vault [VAULT=] Edit the vault in nvim (auto re-encrypts + checks)" + @echo " make check-vault [VAULT=] Validate vault structure (values masked)" @echo " make encrypt FILE= Encrypt a vault file" @echo " make decrypt FILE= Decrypt a vault file" @echo " make new-role 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=. +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=) diff --git a/inventories/production/group_vars/all/vault.yml b/inventories/production/group_vars/all/vault.yml index caab9fd..b1b9fa9 100644 --- a/inventories/production/group_vars/all/vault.yml +++ b/inventories/production/group_vars/all/vault.yml @@ -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 diff --git a/scripts/README.md b/scripts/README.md index 931191c..b61acaa 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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 diff --git a/scripts/check-vault.py b/scripts/check-vault.py new file mode 100755 index 0000000..295a813 --- /dev/null +++ b/scripts/check-vault.py @@ -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..`` 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 : ") + 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())