Add the check and deploy-key scripts
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) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b76c76e3b0
commit
ff0a518001
Executable
+83
@@ -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'
|
||||
Executable
+159
@@ -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
|
||||
Reference in New Issue
Block a user