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>` |
|
| Scaffold a new role | `make new-role NAME=<name>` |
|
||||||
| Review repo for drift/cruft | `/review-repo` (Claude command) |
|
| Review repo for drift/cruft | `/review-repo` (Claude command) |
|
||||||
| Review hardware capacity | `/capacity-review` (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>` |
|
| Encrypt a vault file | `make encrypt FILE=<path>` |
|
||||||
| Decrypt a vault file | `make decrypt FILE=<path>` |
|
| Decrypt a vault file | `make decrypt FILE=<path>` |
|
||||||
| Install Python deps | `make setup` |
|
| 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
|
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
|
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.
|
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
|
MOLECULE := $(VENV)/bin/molecule
|
||||||
# Vault password is resolved via ansible.cfg (vault_password_file); no flag needed.
|
# Vault password is resolved via ansible.cfg (vault_password_file); no flag needed.
|
||||||
VAULT_ARGS :=
|
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
|
INVENTORY := -i inventories/production/hosts.yml
|
||||||
|
|
||||||
TF := terraform
|
TF := terraform
|
||||||
|
|
@ -20,7 +22,8 @@ MOLECULE_DOCKERFILE := .docker/molecule-debian13/Dockerfile
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.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 \
|
tf-init tf-plan tf-apply tf-output tf-inventory \
|
||||||
molecule-image molecule-image-push
|
molecule-image molecule-image-push
|
||||||
|
|
||||||
|
|
@ -35,6 +38,8 @@ help:
|
||||||
@echo " make test-all Run Molecule tests for all roles"
|
@echo " make test-all Run Molecule tests for all roles"
|
||||||
@echo " make check PLAYBOOK=<name> Dry-run a playbook (check mode)"
|
@echo " make check PLAYBOOK=<name> Dry-run a playbook (check mode)"
|
||||||
@echo " make deploy PLAYBOOK=<name> Run a playbook against production"
|
@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 encrypt FILE=<path> Encrypt a vault file"
|
||||||
@echo " make decrypt FILE=<path> Decrypt a vault file"
|
@echo " make decrypt FILE=<path> Decrypt a vault file"
|
||||||
@echo " make new-role NAME=<name> Scaffold a new role"
|
@echo " make new-role NAME=<name> Scaffold a new role"
|
||||||
|
|
@ -99,6 +104,16 @@ endif
|
||||||
|
|
||||||
# ── Vault ─────────────────────────────────────────────────────────────────────
|
# ── 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:
|
encrypt:
|
||||||
ifndef FILE
|
ifndef FILE
|
||||||
$(error FILE is required: make encrypt FILE=<path>)
|
$(error FILE is required: make encrypt FILE=<path>)
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,43 @@
|
||||||
$ANSIBLE_VAULT;1.1;AES256
|
$ANSIBLE_VAULT;1.1;AES256
|
||||||
38396433323934623233313463363239316466643961613831383139333930356637633435656230
|
65383139656131616632663463316634636538646432653234623230623764656636346531373531
|
||||||
6331353433376534313335366438376333633062363437340a313230313562343130323563666438
|
3737323364333763633863383539323434616266616266640a666339343233343237373236333532
|
||||||
66303431333365326337313632366336343439323765633034386163353335356630643834653033
|
34643433613566353638313763643564303638396235323335616338353134613138313837383633
|
||||||
3434386462653363340a316463616230323436666161393030386636376233396230653334333631
|
6466386535303466350a656565386239376265333230333261396261363238393031653263366232
|
||||||
36633639623838313563383638653562653133343664613232316532623734386461393762393435
|
34316638646237333966616635393831306230616463303832343061353431396334393137353163
|
||||||
38643835623534663930626265653964393262343862613233336635393130386262316262336366
|
33313835633933396161326133313565303336666330663964646336643965316463393734656439
|
||||||
33356337366566623265636631613830653836643762316433353836393466346332346362623362
|
37303938316563616338346430633961336563643533633132663938323638633930616633623066
|
||||||
37616331366261333161663862376431646166323335656136363733313762313066633465303037
|
61366634663063623133626566663030373662646536633933393730373263346265383063636537
|
||||||
63363231656366306536636166333438346335386534366131333965613161623435653238303564
|
64623338646433663530393836333432383862316431353162346531386231393735313936653666
|
||||||
39656163626166656363393165346534323938663463323465323232393237616434366264353530
|
61616231626138316630383865633365316461666166663364613230316361636232353361653136
|
||||||
31313235326135366264666234663062653239643464306538323565613562653031363135326530
|
62343466353537376164366534666434333066633239613932646634353563653562353664616134
|
||||||
64313034323566396536336464393864313034346532626339653562336663346532303633306334
|
39393533626533656364303439386530343266373634323365306131306535353865666133383863
|
||||||
38386563346530316530316239306131666438616533343061313463313330653235336636663238
|
36373138663063396635333633636162323262636363363566386461383336346562616532613139
|
||||||
36663638363930383763626432303235366338373462633264386564616135666266376166303464
|
30396534363733393764323135326532313234656539303966323538633063653832643361633662
|
||||||
34303864333366303163396131393336303638363534316561633762613537363934323261653233
|
62663563383131323063303365376237303961613363363837653930343433326235376461303939
|
||||||
66383739373833323564366364383963373331653233396564303664326131633539663636316531
|
64316331656466303031353962376436643937653762613266643030333764393763373366386137
|
||||||
36663730363462316633363033376430386537336164343531333036336339623966643764326332
|
63333663343135343735363332303737323834323238666265333166313764376364656166363232
|
||||||
66613066383165333033643863396663323734386335376531646561663731646435366535343264
|
37613932666461303936323465353263383133646238383635373166656561366661346564653637
|
||||||
31396532393639353666646564643334653061616166623536393261656333653134386430643133
|
35383437373661346563616338303962623536353864393033343362346134333265316164316264
|
||||||
30626465663637336166343865663234633439616333616539663666373663383563373632386462
|
63663664666666333631356538303337313862376261633031346161333037366230376363373237
|
||||||
34363532643065396132666436613337663732333030613436343137646365366538373437643861
|
62323934333435366162613361353537376539616163393131386135663837666263313838363131
|
||||||
38346462383264636264653938666339363162633266326262616139643937363232396534316161
|
37303839363565646361303639623933663965623431376134326533383262303261643037343435
|
||||||
34393633396564306165623864343330336539653330306264666632316430663431396130613031
|
64633537656337316539376661636130616466396463663737306666663961336464333033353935
|
||||||
66646466613865383965633966613561646663633265373932306538663534623164323434303936
|
37316164306366323332656462323031363666613533353737393432313462316462646431643730
|
||||||
31353838663638333530363338653962643630626131663066643732326232363163653330386331
|
63306661623939643635653336353562303863636266326635363536626431363864383663386664
|
||||||
33376132393465316533373337643533626530636566353436323636653263623034346139383261
|
38653264323633643133646663326362306632303532393139373365386432323062616262386639
|
||||||
35313065366463633731313362633264343835376339303563323335616365383339333730366231
|
35626463396139336431303839366163326634663963623064396666613263373263663438343335
|
||||||
31376362363162393061376366643935343962356431343137633364623563663564626538316664
|
62633161386238396135316431303737393235616535646331336336326230373366636232346662
|
||||||
35353465653266393661363232353261633437356531323963303433623565386130326265656531
|
34323433666330396463346536616538613965666564663434643266303235666632363031316164
|
||||||
36303264383261366661643064653734383664373232313562636633326530316666643836663537
|
62616132666434653939373639636262663561323935383039313134643730393339633664326563
|
||||||
62306434313064653633336565353464336663616237386665373066366133643537323230323036
|
39373034386630333031623538303238643166353735646130303434303666643436623339343531
|
||||||
36353632373138336639353462306537303965383966303962613062316437343534346531373764
|
30316335636635386563376231623430333434366630313166613733346434353937666233343831
|
||||||
35323933383130656432373530343734323436623366623737346162646166383337353334373738
|
31626537663766653663323633626630663534363031323763656365656431303762626162396239
|
||||||
38613137316665643831353131613165316130623933626636636533653632636561303566653936
|
35616632343233366532386161656336313361353637336263303938633533613433306465363666
|
||||||
33366132383563326664616462346437353764646362363638303138396164616266366662613035
|
64643265366330353665343666313539633134383236356136353231376230303066666135396264
|
||||||
37656565623536373762
|
36663134303436313663386332306164373565666365353338376534303035666330333536356136
|
||||||
|
61383530343132346363633835633833643032303039613637663263653065323531633931616137
|
||||||
|
34373837356665636464393964356537373134613532333338656533656664396134366364656665
|
||||||
|
62343463363761636134333562666439343036613661663639636639616362333734653932626231
|
||||||
|
36653164653262343066663065646631613339303237633265303365613533656462653134616439
|
||||||
|
36366431303337336361313939366335353934326233373131383634623632343739343933316430
|
||||||
|
6530616566366438383034646131336334616265333831356561
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
# scripts/
|
# scripts/
|
||||||
|
|
||||||
Small helper scripts. **Python standard library only** — no third-party
|
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
|
- `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**.
|
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
|
- `vault-pass-client.sh` — fetches the master vault password from Vaultwarden via
|
||||||
`rbw`. Wired as `vault_password_file` (ADR-002).
|
`rbw`. Wired as `vault_password_file` (ADR-002).
|
||||||
- `check-vault-encrypted.sh` — pre-commit guard: fails if a `vault.yml` holds
|
- `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