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:
parent
43e5a4aa53
commit
79f2315eee
5 changed files with 174 additions and 39 deletions
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
17
Makefile
17
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=<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>)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
101
scripts/check-vault.py
Executable 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())
|
||||
Loading…
Add table
Reference in a new issue