From ff0a5180014fc18dea0d0d319522fa5baf0ad470 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 16 Sep 2026 03:58:01 +0200 Subject: [PATCH] Add the check and deploy-key scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check.sh is the full static gate and the single source of the tool version pins, which CI reads with --print-specs so a workflow cannot drift from what runs locally. load-ssh-key.sh is the only thing that knows where the deploy key comes from — file, CI secret, or Vault — and prints a path, never key material. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/check.sh | 83 +++++++++++++++++++++ scripts/load-ssh-key.sh | 159 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 242 insertions(+) create mode 100755 scripts/check.sh create mode 100755 scripts/load-ssh-key.sh diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..8ab8063 --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# +# The full static gate: yamllint, ansible-lint, and a syntax check of every +# playbook. This is the definition of "done" for a change to this repository. +# +# scripts/check.sh # or: make check +# +# CI runs this same script, so a green run here means a green pipeline. Keep it +# that way: a check that only exists in a workflow file cannot be run before +# pushing, and one that only exists here does not gate anything. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +# Version specifications, single-sourced here and read by both CI configurations. +# +# Pinned to a minor series so that a deploy does not change because PyPI did, and +# a lint result does not depend on the morning it ran, while patch releases still +# arrive. Verified against ansible-core 2.21.2, ansible-lint 26.6.0 and yamllint +# 1.38.0. Raise a ceiling deliberately, after running this script -- not to chase +# a release. The Galaxy collections are floored separately in +# ansible/requirements.yml. +ANSIBLE_CORE_SPEC="${ANSIBLE_CORE_SPEC:-ansible-core>=2.21,<2.22}" +ANSIBLE_LINT_SPEC="${ANSIBLE_LINT_SPEC:-ansible-lint>=26.6,<27}" +YAMLLINT_SPEC="${YAMLLINT_SPEC:-yamllint>=1.38,<2}" +export ANSIBLE_CORE_SPEC ANSIBLE_LINT_SPEC YAMLLINT_SPEC + +# CI installs the toolchain itself and then re-enters this script with +# RUNNER=installed. It asks for the pins here so that they are defined in exactly +# one place and a workflow can never drift from what runs locally. +if [ "${1:-}" = "--print-specs" ]; then + printf '%s\n%s\n%s\n' \ + "$ANSIBLE_CORE_SPEC" "$ANSIBLE_LINT_SPEC" "$YAMLLINT_SPEC" + exit 0 +fi + +# In CI the tools are already installed and on PATH. Locally, run them through +# uvx so this repository needs no virtualenv of its own. Set RUNNER=pip if you +# would rather install them yourself. +if [ "${RUNNER:-}" = "installed" ] || command -v ansible-lint >/dev/null 2>&1; then + yamllint_cmd=(yamllint) + ansible_lint_cmd=(ansible-lint) + galaxy_cmd=(ansible-galaxy) + syntax_cmd=(ansible-playbook) +elif command -v uvx >/dev/null 2>&1; then + yamllint_cmd=(uvx --from "$YAMLLINT_SPEC" yamllint) + ansible_lint_cmd=(uvx --from "$ANSIBLE_LINT_SPEC" --with "$ANSIBLE_CORE_SPEC" ansible-lint) + galaxy_cmd=(uvx --from "$ANSIBLE_CORE_SPEC" ansible-galaxy) + syntax_cmd=(uvx --from "$ANSIBLE_CORE_SPEC" ansible-playbook) +else + echo "check: need either the tools on PATH or uv installed (https://docs.astral.sh/uv/)" >&2 + exit 1 +fi + +step() { printf '\n== %s ==\n' "$*"; } + +# From the repository root, where .yamllint and its ignore list live. ansible-lint +# runs yamllint too, but only over files it recognises as Ansible content; this +# covers the workflows, the CI configuration and everything else. +step "yamllint" +"${yamllint_cmd[@]}" . + +# Every ansible command runs from ansible/: ansible.cfg is only discovered in the +# current directory, and it is what puts roles/ on the roles path. From the +# repository root you get "the role 'compose_stack' was not found". +cd ansible + +# Install the collections before linting, or community.docker and ansible.posix +# resolve to nothing and the fully-qualified-name checks pass vacuously. +step "ansible-galaxy install" +"${galaxy_cmd[@]}" install -r requirements.yml + +step "ansible-lint" +"${ansible_lint_cmd[@]}" . + +step "syntax check" +for playbook in playbooks/*.yml; do + printf ' %s\n' "$playbook" + "${syntax_cmd[@]}" --syntax-check "$playbook" >/dev/null +done + +printf '\nAll checks passed.\n' diff --git a/scripts/load-ssh-key.sh b/scripts/load-ssh-key.sh new file mode 100755 index 0000000..5ad7b94 --- /dev/null +++ b/scripts/load-ssh-key.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# +# Materialise the Ansible deploy SSH key and print its path on stdout. +# +# export SSH_KEY_PATH="$(scripts/load-ssh-key.sh)" +# ansible-playbook ansible/playbooks/static-site.yml +# +# Ansible itself knows exactly one thing about credentials: SSH_KEY_PATH, a path +# to a 0600 private key. This script is the only thing that knows where that key +# comes from, so adding a backend never touches the inventory or a playbook. +# +# env (default) the key is already on disk at $SSH_KEY_PATH +# ci from $SSH_PRIVATE_KEY_B64 or $SSH_PRIVATE_KEY +# (aliases: github, gitlab) +# vault from HashiCorp Vault or OpenBao, KV v2 +# +# Select with the first argument or $SSH_KEY_BACKEND. Only the path is printed; +# key material never reaches stdout, a CI log, or the process list. +# +# See docs/secrets.md. + +set -euo pipefail +umask 077 + +backend="${1:-${SSH_KEY_BACKEND:-env}}" + +# Vault and OpenBao share the KV v2 API, so either works. VAULT_* is checked +# first; BAO_* is accepted so an OpenBao deployment needs no translation. +VAULT_ADDR="${VAULT_ADDR:-${BAO_ADDR:-}}" +VAULT_TOKEN="${VAULT_TOKEN:-${BAO_TOKEN:-}}" +VAULT_KV_MOUNT="${VAULT_KV_MOUNT:-${BAO_KV_MOUNT:-kv}}" +VAULT_SECRET_PATH="${VAULT_SECRET_PATH:-${BAO_SECRET_PATH:-infra/ansible/deploy-key}}" +VAULT_SECRET_FIELD="${VAULT_SECRET_FIELD:-${BAO_SECRET_FIELD:-private_key}}" + +log() { printf 'load-ssh-key: %s\n' "$*" >&2; } +die() { log "$*"; exit 1; } + +# GNU coreutils spells it --decode; BSD and macOS spell it -D. +decode_base64() { + if printf '' | base64 --decode >/dev/null 2>&1; then + base64 --decode + else + base64 -D + fi +} + +# Write stdin to a fresh 0600 file in a private directory, validate it, print the +# path. Never echoes what it wrote. +install_key() { + local dir dest + dir="$(mktemp -d "${TMPDIR:-/tmp}/ansible-ssh.XXXXXX")" + chmod 700 "$dir" + dest="$dir/id_deploy" + + cat >"$dest" + chmod 600 "$dest" + + [ -s "$dest" ] || die "no key material was produced (backend: $backend)" + grep -q 'PRIVATE KEY' "$dest" \ + || die "the value fetched from $backend does not look like a private key" + + # OpenSSH rejects a key whose file lacks a trailing newline. + [ -z "$(tail -c 1 "$dest")" ] || printf '\n' >>"$dest" + + printf '%s\n' "$dest" +} + +case "$backend" in + env) + [ -n "${SSH_KEY_PATH:-}" ] || die "SSH_KEY_PATH is not set" + [ -r "${SSH_KEY_PATH}" ] \ + || die "SSH_KEY_PATH does not point at a readable file: $SSH_KEY_PATH" + perms="$(stat -c '%a' "$SSH_KEY_PATH" 2>/dev/null || stat -f '%Lp' "$SSH_KEY_PATH")" + [ "$perms" = "600" ] || log "warning: $SSH_KEY_PATH is mode $perms; ssh wants 600" + printf '%s\n' "$SSH_KEY_PATH" + ;; + + ci | github | gitlab) + # Store the key base64-encoded. GitHub Actions redacts secrets from logs but + # mangles multi-line values passed through some contexts; GitLab can only + # *mask* a value that is a single line with no whitespace, which an OpenSSH + # private key never is. One encoding satisfies both: + # + # base64 -w0 < ~/.ssh/deploy_key # Linux + # base64 < ~/.ssh/deploy_key | tr -d '\n' # macOS + # [Convert]::ToBase64String( + # [IO.File]::ReadAllBytes("$HOME\.ssh\deploy_key")) # PowerShell + # + # On Windows use exactly that. certutil -encode wraps its output in a header + # and line breaks, and Get-Content re-encodes the bytes — both produce + # something that looks like base64 and decodes to an unusable key. + # + # SSH_PRIVATE_KEY is still accepted for a raw or file-backed value, and is + # treated as base64 if it is neither a path nor a PEM block. + if [ -n "${SSH_PRIVATE_KEY_B64:-}" ]; then + printf '%s' "$SSH_PRIVATE_KEY_B64" | decode_base64 | install_key + + elif [ -n "${SSH_PRIVATE_KEY:-}" ]; then + if [ -f "$SSH_PRIVATE_KEY" ] && [ -r "$SSH_PRIVATE_KEY" ]; then + install_key <"$SSH_PRIVATE_KEY" + elif case "$SSH_PRIVATE_KEY" in *"PRIVATE KEY"*) true ;; *) false ;; esac; then + printf '%s' "$SSH_PRIVATE_KEY" | install_key + else + printf '%s' "$SSH_PRIVATE_KEY" | decode_base64 | install_key + fi + + else + die "set SSH_PRIVATE_KEY_B64, or SSH_PRIVATE_KEY to a raw key or a path" + fi + ;; + + vault | openbao) + [ -n "$VAULT_ADDR" ] || die "VAULT_ADDR (or BAO_ADDR) is not set" + [ -n "$VAULT_TOKEN" ] || die "VAULT_TOKEN (or BAO_TOKEN) is not set" + + cli="" + command -v vault >/dev/null 2>&1 && cli=vault + [ -z "$cli" ] && command -v bao >/dev/null 2>&1 && cli=bao + + if [ -n "$cli" ]; then + # `env` rather than bare NAME=value prefixes: in `A=1 B=$A cmd` the $A + # expands to the *outer* A, which reads as a bug even when it is not. Both + # spellings are set for the command only, never exported to this shell. + material="$( + env VAULT_ADDR="$VAULT_ADDR" VAULT_TOKEN="$VAULT_TOKEN" \ + BAO_ADDR="$VAULT_ADDR" BAO_TOKEN="$VAULT_TOKEN" \ + "$cli" kv get -mount="$VAULT_KV_MOUNT" \ + -field="$VAULT_SECRET_FIELD" "$VAULT_SECRET_PATH" + )" || die "'$cli kv get' failed for $VAULT_KV_MOUNT/$VAULT_SECRET_PATH" + + elif command -v curl >/dev/null 2>&1 && command -v python3 >/dev/null 2>&1; then + # -H keeps the token out of the process list. This is the KV v2 data + # endpoint; KV v1 has no /data/ segment. + response="$( + curl --fail --silent --show-error \ + -H "X-Vault-Token: $VAULT_TOKEN" \ + "$VAULT_ADDR/v1/$VAULT_KV_MOUNT/data/$VAULT_SECRET_PATH" + )" || die "request to $VAULT_ADDR failed — check the token, and that the vault is unsealed" + + # The field name goes in as argv, not interpolated into the program text. + material="$(printf '%s' "$response" | python3 -c 'import json, sys +try: + print(json.load(sys.stdin)["data"]["data"][sys.argv[1]], end="") +except (ValueError, KeyError, TypeError): + sys.exit(1) +' "$VAULT_SECRET_FIELD")" \ + || die "no field '$VAULT_SECRET_FIELD' in $VAULT_KV_MOUNT/$VAULT_SECRET_PATH" + + else + die "need either the 'vault' or 'bao' CLI, or curl + python3" + fi + + printf '%s' "$material" | install_key + ;; + + *) + die "unknown backend '$backend' (expected: env, ci, vault)" + ;; +esac