diff --git a/bin/disinto b/bin/disinto index 2b676a3..d5c43dd 100755 --- a/bin/disinto +++ b/bin/disinto @@ -670,6 +670,7 @@ _disinto_init_nomad() { local import_env="${4:-}" import_sops="${5:-}" age_key="${6:-}" local cluster_up="${FACTORY_ROOT}/lib/init/nomad/cluster-up.sh" local deploy_sh="${FACTORY_ROOT}/lib/init/nomad/deploy.sh" + local vault_engines_sh="${FACTORY_ROOT}/lib/init/nomad/vault-engines.sh" local vault_policies_sh="${FACTORY_ROOT}/tools/vault-apply-policies.sh" local vault_auth_sh="${FACTORY_ROOT}/lib/init/nomad/vault-nomad-auth.sh" local vault_import_sh="${FACTORY_ROOT}/tools/vault-import.sh" @@ -690,15 +691,20 @@ _disinto_init_nomad() { # --empty combined with --with or any --import-* flag, so reaching # this branch with those set is a bug in the caller. # - # On the default (non-empty) path, vault-apply-policies.sh and - # vault-nomad-auth.sh are invoked unconditionally — they are idempotent - # and cheap to re-run, and subsequent --with deployments depend on - # them. vault-import.sh is invoked only when an --import-* flag is set. + # On the default (non-empty) path, vault-engines.sh, vault-apply-policies.sh + # and vault-nomad-auth.sh are invoked unconditionally — they are idempotent + # and cheap to re-run, and subsequent --with deployments depend on them. + # vault-engines.sh enables the kv/ secret engine required by all policies + # and roles. vault-import.sh is invoked only when an --import-* flag is set. local import_any=false if [ -n "$import_env" ] || [ -n "$import_sops" ]; then import_any=true fi if [ "$empty" != "true" ]; then + if [ ! -x "$vault_engines_sh" ]; then + echo "Error: ${vault_engines_sh} not found or not executable" >&2 + exit 1 + fi if [ ! -x "$vault_policies_sh" ]; then echo "Error: ${vault_policies_sh} not found or not executable" >&2 exit 1 @@ -737,10 +743,14 @@ _disinto_init_nomad() { exit 0 fi - # Vault policies + auth are invoked on every nomad real-run path - # regardless of --import-* flags (they're idempotent; S2.1 + S2.3). + # Vault engines + policies + auth are invoked on every nomad real-run path + # regardless of --import-* flags. Vault engines (kv/ mount) is S2.1, + # policies are S2.1, auth is S2.3 — all idempotent and cheap to re-run. # Mirror that ordering in the dry-run plan so the operator sees the # full sequence Step 2 will execute. + echo "── Vault engines dry-run ──────────────────────────────" + echo "[engines] [dry-run] ${vault_engines_sh}" + echo "" echo "── Vault policies dry-run ─────────────────────────────" echo "[policies] [dry-run] ${vault_policies_sh} --dry-run" echo "" @@ -814,6 +824,20 @@ _disinto_init_nomad() { exit 0 fi + # Enable Vault secret engines (S2.1) — kv/ mount required by all policies. + echo "" + echo "── Enabling Vault secret engines ──────────────────────" + local -a engines_cmd=("$vault_engines_sh") + if [ "$(id -u)" -eq 0 ]; then + "${engines_cmd[@]}" || exit $? + else + if ! command -v sudo >/dev/null 2>&1; then + echo "Error: vault-engines.sh must run as root and sudo is not installed" >&2 + exit 1 + fi + sudo -n -- "${engines_cmd[@]}" || exit $? + fi + # Apply Vault policies (S2.1) — idempotent, safe to re-run. echo "" echo "── Applying Vault policies ────────────────────────────" diff --git a/lib/init/nomad/vault-engines.sh b/lib/init/nomad/vault-engines.sh new file mode 100755 index 0000000..0f534ee --- /dev/null +++ b/lib/init/nomad/vault-engines.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# ============================================================================= +# lib/init/nomad/vault-engines.sh — Enable required Vault secret engines +# +# Part of the Nomad+Vault migration (S2.1). Enables the KV v2 secret engine +# at the `kv/` path, which is required by all policies in vault/policies/*.hcl, +# all roles in vault/roles.yaml, and by vault-import.sh and forgejo.hcl +# template stanzas that read from kv/disinto/* paths. +# +# Idempotency contract: +# - If kv/ is already enabled at path=kv (version=2), log "already enabled" +# and exit 0 without any Vault API calls. +# - If kv/ is enabled at a different path or version, log an error and exit 1. +# - Second run on a fully-configured box is a silent no-op. +# +# Preconditions: +# - Vault is unsealed and reachable (VAULT_ADDR + VAULT_TOKEN set). +# - Must run AFTER vault-init.sh (unseal complete) but BEFORE +# vault-apply-policies.sh (policies reference kv/* paths). +# +# Environment: +# VAULT_ADDR — default http://127.0.0.1:8200. +# VAULT_TOKEN — env OR /etc/vault.d/root.token (resolved by lib/hvault.sh). +# +# Usage: +# sudo lib/init/nomad/vault-engines.sh +# +# Exit codes: +# 0 success (kv enabled, or already so) +# 1 precondition / API failure +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" + +# shellcheck source=../../hvault.sh +source "${REPO_ROOT}/lib/hvault.sh" + +log() { printf '[vault-engines] %s\n' "$*"; } +die() { printf '[vault-engines] ERROR: %s\n' "$*" >&2; exit 1; } + +# ── Flag parsing ───────────────────────────────────────────────────────────── +case "${1:-}" in + -h|--help) + cat <<'EOF' +Usage: sudo $(basename "$0") [--dry-run] + +Enables the KV v2 secret engine at path=kv/. Required by all Vault policies, +roles, and Nomad job templates that reference kv/disinto/* paths. + + --dry-run Print the enable command without making changes. +EOF + exit 0 + ;; + --dry-run) + # Dry-run: just echo what would happen + if vault secrets list -format=json | jq -e '."kv/"' >/dev/null 2>&1; then + log "[dry-run] kv-v2 at kv/ already enabled" + else + log "[dry-run] would run: vault secrets enable -path=kv -version=2 kv" + fi + exit 0 + ;; + '') + ;; + *) + die "unknown flag: $1" + ;; +esac + +# ── Preconditions ──────────────────────────────────────────────────────────── +for bin in curl jq; do + command -v "$bin" >/dev/null 2>&1 \ + || die "required binary not found: ${bin}" +done + +# Default VAULT_ADDR if not set (fixes issue #2) +VAULT_ADDR="${VAULT_ADDR:-http://127.0.0.1:8200}" +export VAULT_ADDR + +# Resolve VAULT_TOKEN if not set (fixes issue #2) +if [ -z "${VAULT_TOKEN:-}" ] && [ -f /etc/vault.d/root.token ]; then + VAULT_TOKEN="$(cat /etc/vault.d/root.token)" + export VAULT_TOKEN +fi + +# Check Vault connectivity and unsealed status +hvault_token_lookup >/dev/null \ + || die "Vault auth probe failed — check VAULT_ADDR + VAULT_TOKEN" + +# ── Check if kv/ is already enabled ────────────────────────────────────────── +log "── Checking if kv-v2 is already enabled ──" +secrets_list="$(vault secrets list -format=json)" + +if printf '%s' "$secrets_list" | jq -e '."kv/"' >/dev/null 2>&1; then + # kv/ exists — verify it's v2 + kv_type="$(printf '%s' "$secrets_list" | jq -r '."kv/".type // ""')" + kv_version="$(printf '%s' "$secrets_list" | jq -r '."kv/".options.version // "unknown"')" + + if [ "$kv_type" = "kv" ] && [ "$kv_version" = "2" ]; then + log "kv-v2 at kv/ already enabled (type=${kv_type}, version=${kv_version})" + exit 0 + else + die "kv/ exists but is not kv-v2 (type=${kv_type}, version=${kv_version}) — manual intervention required" + fi +fi + +# ── Enable kv-v2 ────────────────────────────────────────────────────────────── +log "── Enabling kv-v2 at path=kv ──" +vault secrets enable -path=kv -version=2 kv \ + || die "failed to enable kv-v2 secret engine" + +log "kv-v2 enabled at kv/" +log "── done ──" diff --git a/nomad/jobs/forgejo.hcl b/nomad/jobs/forgejo.hcl index ec1d3ae..5ec5e86 100644 --- a/nomad/jobs/forgejo.hcl +++ b/nomad/jobs/forgejo.hcl @@ -158,6 +158,7 @@ job "forgejo" { destination = "secrets/forgejo.env" env = true change_mode = "restart" + error_on_missing_key = false data = < - FORWARD_AUTH_SECRET, CHAT_OAUTH_* → kv/disinto/shared/chat/ - From sops-decrypted .env.vault.enc: + From sops-decrypted .env.vault.enc (if --sops provided): - GITHUB_TOKEN, CODEBERG_TOKEN, CLAWHUB_TOKEN, DEPLOY_KEY, NPM_TOKEN, DOCKER_HUB_TOKEN → kv/disinto/runner//value Examples: + vault-import.sh --env .env # Import .env only (no sops) vault-import.sh --env .env --sops .env.vault.enc --age-key age-keys.txt vault-import.sh --env .env --sops .env.vault.enc --age-key age-keys.txt --dry-run EOF @@ -276,26 +277,28 @@ EOF if [ -z "$env_file" ]; then _die "Missing required argument: --env" fi - if [ -z "$sops_file" ]; then - _die "Missing required argument: --sops" - fi - if [ -z "$age_key_file" ]; then - _die "Missing required argument: --age-key" + # --sops and --age-key are optional: + # - If --sops is provided, --age-key is required + # - If --sops is not provided, --age-key is not needed + if [ -n "$sops_file" ] && [ -z "$age_key_file" ]; then + _die "Missing required argument: --age-key (required when --sops is provided)" fi # Validate files exist if [ ! -f "$env_file" ]; then _die "Environment file not found: $env_file" fi - if [ ! -f "$sops_file" ]; then + if [ -n "$sops_file" ] && [ ! -f "$sops_file" ]; then _die "Sops file not found: $sops_file" fi - if [ ! -f "$age_key_file" ]; then + if [ -n "$age_key_file" ] && [ ! -f "$age_key_file" ]; then _die "Age key file not found: $age_key_file" fi - # Security check: age key permissions - _validate_age_key_perms "$age_key_file" + # Security check: age key permissions (only if age key is provided) + if [ -n "$age_key_file" ]; then + _validate_age_key_perms "$age_key_file" + fi # Security check: VAULT_ADDR must be localhost _check_vault_addr @@ -307,12 +310,16 @@ EOF _log "Loading environment from: $env_file" _load_env_file "$env_file" - # Decrypt sops file - _log "Decrypting sops file: $sops_file" - local sops_env - sops_env="$(_decrypt_sops "$sops_file" "$age_key_file")" - # shellcheck disable=SC2086 - eval "$sops_env" + # Decrypt sops file if --sops was provided + local sops_env="" + if [ -n "$sops_file" ]; then + _log "Decrypting sops file: $sops_file" + sops_env="$(_decrypt_sops "$sops_file" "$age_key_file")" + # shellcheck disable=SC2086 + eval "$sops_env" + else + _log "No --sops provided — skipping sops decryption" + fi # Collect all import operations declare -a operations=() @@ -397,8 +404,12 @@ EOF if $dry_run; then _log "=== DRY-RUN: Import plan ===" _log "Environment file: $env_file" - _log "Sops file: $sops_file" - _log "Age key: $age_key_file" + if [ -n "$sops_file" ]; then + _log "Sops file: $sops_file" + _log "Age key: $age_key_file" + else + _log "Sops file: (not provided)" + fi _log "" _log "Planned operations:" for op in "${operations[@]}"; do @@ -413,8 +424,12 @@ EOF _log "=== Starting Vault import ===" _log "Environment file: $env_file" - _log "Sops file: $sops_file" - _log "Age key: $age_key_file" + if [ -n "$sops_file" ]; then + _log "Sops file: $sops_file" + _log "Age key: $age_key_file" + else + _log "Sops file: (not provided — skipping sops-based imports)" + fi _log "" local created=0