diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml new file mode 100644 index 000000000..be14369a2 --- /dev/null +++ b/.github/workflows/e2e-test.yml @@ -0,0 +1,23 @@ +--- +name: E2E Tests + +on: + pull_request: + branches: [main] + workflow_dispatch: + inputs: + test-suite: + description: "Test suite (vmaas, caas, or empty for all)" + required: false + default: "vmaas" + test-filter: + description: "pytest -k filter" + required: false + default: "" + +jobs: + e2e-vmaas: + uses: ./.github/workflows/e2e-vault-secrets.yml + with: + test-suite: ${{ github.event.inputs.test-suite || 'vmaas' }} + test-filter: ${{ github.event.inputs.test-filter || '' }} diff --git a/.github/workflows/e2e-vault-secrets.yml b/.github/workflows/e2e-vault-secrets.yml new file mode 100644 index 000000000..1ffbcee91 --- /dev/null +++ b/.github/workflows/e2e-vault-secrets.yml @@ -0,0 +1,108 @@ +--- +name: OSAC E2E — Vault Secrets + +on: + workflow_call: + inputs: + test-suite: + description: "Test suite to run: vmaas, caas, or empty string for all" + required: false + type: string + default: "vmaas" + test-filter: + description: "pytest -k filter expression (optional)" + required: false + type: string + default: "" + namespace: + description: "OSAC namespace on the cluster" + required: false + type: string + default: "osac-e2e-ci" + vm-template: + description: "VM template for compute instances" + required: false + type: string + default: "osac.templates.ocp_virt_vm" + test-image: + description: "Test container image override" + required: false + type: string + default: "ghcr.io/osac-project/osac-test-infra:latest" + outputs: + result: + description: "Test result: PASSED or FAILED" + value: ${{ jobs.e2e.outputs.result }} + +jobs: + e2e: + runs-on: osac-ci + environment: e2e-test + permissions: + contents: read + id-token: write + outputs: + result: ${{ steps.test.outcome == 'success' && 'PASSED' || 'FAILED' }} + + steps: + - name: Retrieve secrets from Vault + id: vault + uses: hashicorp/vault-action@892a26828f195e65540a40b4768ae4571f51ebfc # v4 + with: + url: http://127.0.0.1:8200 + method: jwt + role: osac-e2e + jwtGithubAudience: https://github.com/osac-project + exportEnv: false + secrets: | + secret/data/osac/e2e/pull-secret dockerconfigjson | PULL_SECRET ; + secret/data/osac/e2e/aap-license license | AAP_LICENSE + + - name: Write secrets to files + env: + PULL_SECRET: ${{ steps.vault.outputs.PULL_SECRET }} + AAP_LICENSE: ${{ steps.vault.outputs.AAP_LICENSE }} + run: | + printf '%s' "${PULL_SECRET}" > "$RUNNER_TEMP/pull-secret.json" + chmod 600 "$RUNNER_TEMP/pull-secret.json" + + printf '%s' "${AAP_LICENSE}" | base64 -d > "$RUNNER_TEMP/aap-license.zip" + chmod 600 "$RUNNER_TEMP/aap-license.zip" + + - name: Run E2E tests + id: test + env: + TEST_SUITE: ${{ inputs.test-suite }} + TEST_FILTER: ${{ inputs.test-filter }} + OSAC_NAMESPACE: ${{ inputs.namespace }} + OSAC_VM_TEMPLATE: ${{ inputs.vm-template }} + TEST_IMAGE: ${{ inputs.test-image }} + run: | + TEST_ARGS="tests/" + if [ -n "${TEST_SUITE}" ]; then + TEST_ARGS="tests/${TEST_SUITE}/" + fi + + FILTER_ARGS="" + if [ -n "${TEST_FILTER}" ]; then + FILTER_ARGS="-k ${TEST_FILTER}" + fi + + podman run --rm \ + --network host \ + -v "${HOME}/.kube/config:/root/.kube/config:ro,Z" \ + -v "${RUNNER_TEMP}/pull-secret.json:/root/pull-secret:ro,Z" \ + -v "${RUNNER_TEMP}/aap-license.zip:/root/aap-license.zip:ro,Z" \ + -e KUBECONFIG=/root/.kube/config \ + -e OSAC_VM_KUBECONFIG=/root/.kube/config \ + -e OSAC_NAMESPACE="${OSAC_NAMESPACE}" \ + -e OSAC_VM_TEMPLATE="${OSAC_VM_TEMPLATE}" \ + -e OSAC_PULL_SECRET_PATH=/root/pull-secret \ + "${TEST_IMAGE}" \ + pytest ${TEST_ARGS} ${FILTER_ARGS} \ + --junitxml=/tmp/junit.xml -v + + - name: Clean up secrets + if: always() + run: | + rm -f "$RUNNER_TEMP/pull-secret.json" "$RUNNER_TEMP/aap-license.zip" diff --git a/vault/config/vault.hcl b/vault/config/vault.hcl new file mode 100644 index 000000000..443c587d0 --- /dev/null +++ b/vault/config/vault.hcl @@ -0,0 +1,20 @@ +# Vault server configuration for OSAC CI runner +# Deployed by vault-setup.sh to ~/.vault-server/config/vault.hcl + +storage "file" { + path = "/vault/data" +} + +listener "tcp" { + # Bind to all interfaces inside the container so Podman's port-forward + # works. Host-side exposure is restricted to 127.0.0.1 by the Quadlet + # PublishPort directive. + address = "0.0.0.0:8200" + tls_disable = 1 +} + +# Vault 2.0.2 removed IPC_LOCK from the official container image; +# disabling mlock avoids the need for the capability in rootless Podman. +disable_mlock = true + +ui = true diff --git a/vault/quadlet/vault.container b/vault/quadlet/vault.container new file mode 100644 index 000000000..e34934b64 --- /dev/null +++ b/vault/quadlet/vault.container @@ -0,0 +1,43 @@ +# Podman Quadlet unit file for HashiCorp Vault +# Installed by vault-setup.sh to ~/.config/containers/systemd/vault.container +# +# Quadlet generates a systemd unit (vault.service) from this file. +# See: man podman-systemd.unit(5) + +[Unit] +Description=HashiCorp Vault (OSAC CI) +After=network-online.target +Wants=network-online.target + +[Container] +Image=docker.io/hashicorp/vault:2.0.3 +ContainerName=vault + +# Localhost-only binding -- no external exposure +PublishPort=127.0.0.1:8200:8200 + +# Run the vault server with our config +Exec=server + +# Mount host directories into the container. +# :Z applies the correct SELinux context for rootless Podman. +Volume=%h/.vault-server/config:/vault/config:Z +Volume=%h/.vault-server/data:/vault/data:Z +Volume=%h/.vault-server/logs:/vault/logs:Z + +# Environment for rootless operation +Environment=SKIP_CHOWN=true +Environment=SKIP_SETCAP=true +Environment=VAULT_ADDR=http://127.0.0.1:8200 + +# Version pinned for CI stability -- update deliberately after testing + +[Service] +Restart=always +RestartSec=10 +TimeoutStartSec=120 +# Auto-unseal after the container starts +ExecStartPost=%h/.vault-server/scripts/vault-unseal.sh + +[Install] +WantedBy=default.target diff --git a/vault/scripts/vault-backup.sh b/vault/scripts/vault-backup.sh new file mode 100755 index 000000000..c8254e5f9 --- /dev/null +++ b/vault/scripts/vault-backup.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# vault-backup.sh -- Backup Vault's encrypted data directory. +# Called by vault-backup.service (daily via vault-backup.timer). +set -euo pipefail + +VAULT_HOME="${HOME}/.vault-server" +BACKUP_DIR="${VAULT_HOME}/backups" +DATA_DIR="${VAULT_HOME}/data" +RETENTION_DAYS=30 + +mkdir -p "${BACKUP_DIR}" + +############################################################################### +# Create backup +############################################################################### +timestamp=$(date +%Y%m%d-%H%M%S) +backup_file="${BACKUP_DIR}/vault-data-${timestamp}.tar.gz" + +echo "[vault-backup] Backing up ${DATA_DIR} ..." +tar -czf "${backup_file}" -C "${VAULT_HOME}" data +chmod 0600 "${backup_file}" +echo "[vault-backup] Backup created: ${backup_file}" + +############################################################################### +# Prune old backups +############################################################################### +echo "[vault-backup] Pruning backups older than ${RETENTION_DAYS} days ..." +pruned=$(find "${BACKUP_DIR}" -name "vault-data-*.tar.gz" -mtime "+${RETENTION_DAYS}" -delete -print | wc -l) +echo "[vault-backup] Pruned ${pruned} old backup(s)." diff --git a/vault/scripts/vault-health-check.sh b/vault/scripts/vault-health-check.sh new file mode 100755 index 000000000..5885ef6f1 --- /dev/null +++ b/vault/scripts/vault-health-check.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# vault-health-check.sh -- 10-point health check for the OSAC Vault instance. +set -euo pipefail + +VAULT_ADDR="${VAULT_ADDR:-http://127.0.0.1:8200}" +export VAULT_ADDR + +# Checks 5-8 query authenticated Vault endpoints; source root/admin token. +INIT_JSON="${HOME}/.vault-server/.vault-init.json" +if [[ -f "${INIT_JSON}" ]]; then + export VAULT_TOKEN + VAULT_TOKEN=$(jq -r '.root_token' "${INIT_JSON}") +elif [[ -z "${VAULT_TOKEN:-}" ]]; then + echo "WARNING: No VAULT_TOKEN set and ${INIT_JSON} not found." + echo " Authenticated checks (5-8) will fail." +fi + +passed=0 +failed=0 + +check() { + local num="$1" name="$2" + shift 2 + if "$@" >/dev/null 2>&1; then + echo " [PASS] ${num}. ${name}" + passed=$(( passed + 1 )) + else + echo " [FAIL] ${num}. ${name}" + failed=$(( failed + 1 )) + fi +} + +echo "=== Vault Health Check ===" +echo "" + +# 1. Vault reachable +check 1 "Vault is reachable" vault status -format=json + +# 2. Vault unsealed +check 2 "Vault is unsealed" \ + bash -c 'test "$(vault status -format=json | jq -r .sealed)" = "false"' + +# 3. Vault initialized +check 3 "Vault is initialized" \ + bash -c 'test "$(vault status -format=json | jq -r .initialized)" = "true"' + +# 4. Vault version reported +check 4 "Vault version reported" \ + bash -c 'vault status -format=json | jq -e .version' + +# 5. JWT auth enabled +check 5 "JWT auth method enabled" \ + bash -c 'vault auth list -format=json | jq -e ".\"jwt/\""' + +# 6. osac-e2e role exists +check 6 "osac-e2e role exists" \ + vault read auth/jwt/role/osac-e2e + +# 7. osac-e2e policy exists +check 7 "osac-e2e policy exists" \ + vault policy read osac-e2e + +# 8. KV v2 at secret/ +check 8 "KV v2 secrets engine at secret/" \ + bash -c 'vault secrets list -format=json | jq -e ".\"secret/\""' + +# 9. systemd vault.service active +check 9 "vault.service is active" \ + systemctl --user is-active vault.service + +# 10. backup timer active +check 10 "vault-backup.timer is active" \ + systemctl --user is-active vault-backup.timer + +echo "" +echo "=== Results: ${passed} passed, ${failed} failed ===" + +if (( failed > 0 )); then + exit 1 +fi diff --git a/vault/scripts/vault-prerequisites.sh b/vault/scripts/vault-prerequisites.sh new file mode 100755 index 000000000..672fb11ee --- /dev/null +++ b/vault/scripts/vault-prerequisites.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# vault-prerequisites.sh -- Install required packages for the OSAC Vault instance. +# Run as root (or with sudo) on a RHEL/CentOS/Fedora host. +set -euo pipefail + +if (( EUID != 0 )); then + echo "ERROR: This script must be run as root (or via sudo)." >&2 + exit 1 +fi + +############################################################################### +# Detect package manager +############################################################################### +if command -v dnf &>/dev/null; then + PKG_MGR="dnf" +elif command -v yum &>/dev/null; then + PKG_MGR="yum" +else + echo "ERROR: Neither dnf nor yum found." >&2 + exit 1 +fi + +############################################################################### +# Install system packages +############################################################################### +if [[ "${PKG_MGR}" == "dnf" ]]; then + CONFIG_MGR_PKG="dnf-plugins-core" +else + CONFIG_MGR_PKG="yum-utils" +fi + +echo "==> Installing podman, jq, and ${CONFIG_MGR_PKG} ..." +${PKG_MGR} install -y podman jq "${CONFIG_MGR_PKG}" + +############################################################################### +# Add HashiCorp repo and install Vault CLI +############################################################################### +HASHICORP_REPO="/etc/yum.repos.d/hashicorp.repo" +if [[ -f "${HASHICORP_REPO}" ]]; then + echo "==> HashiCorp repo already configured." +else + echo "==> Adding HashiCorp RPM repository ..." + if [[ "${PKG_MGR}" == "dnf" ]]; then + dnf config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo + else + yum-config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo + fi +fi + +echo "==> Installing Vault CLI ..." +${PKG_MGR} install -y vault + +############################################################################### +# Verify +############################################################################### +echo "" +echo "=== Installed versions ===" +echo " podman : $(podman --version)" +echo " vault : $(vault --version)" +echo " jq : $(jq --version)" +echo "" +echo "Prerequisites satisfied. Run vault-setup.sh next." diff --git a/vault/scripts/vault-setup.sh b/vault/scripts/vault-setup.sh new file mode 100755 index 000000000..917e84f76 --- /dev/null +++ b/vault/scripts/vault-setup.sh @@ -0,0 +1,237 @@ +#!/usr/bin/env bash +# vault-setup.sh -- One-time Vault deployment on the OSAC CI runner. +# +# Phases: +# 1. Create directory layout under ~/.vault-server/ +# 2. Copy config files from this repo +# 3. Install Quadlet unit + systemd services, start Vault +# 4. Initialize Vault (3 key shares, threshold 2) +# 5. Extract unseal keys +# 6. Unseal Vault +# 7. Enable KV v2 secrets engine at secret/ +# 8. Enable and configure JWT auth (GitHub OIDC) +# 9. Create osac-e2e policy and role +# 10. Enable loginctl linger, enable vault.service + backup timer +# +# Prerequisites: +# - podman, jq, vault CLI installed +# - This script is run from the repo root (or REPO_ROOT is set) +set -euo pipefail + +VAULT_ADDR="${VAULT_ADDR:-http://127.0.0.1:8200}" +export VAULT_ADDR + +VAULT_HOME="${HOME}/.vault-server" +# Resolve to the vault/ directory: scripts live at vault/scripts/ +VAULT_REPO_DIR="${VAULT_REPO_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" + +QUADLET_DIR="${HOME}/.config/containers/systemd" +SYSTEMD_USER_DIR="${HOME}/.config/systemd/user" + +############################################################################### +phase() { echo -e "\n==> Phase $1: $2"; } + +# Helper: get a field from vault status JSON. +# vault status exits 0 (unsealed), 1 (error), or 2 (sealed/uninitialized), +# so we must suppress its exit code to avoid triggering pipefail. +vault_status_field() { + (vault status -format=json 2>/dev/null || true) | jq -r ".$1" 2>/dev/null +} +############################################################################### + +############################################################################### +# Phase 1: Create directory layout +############################################################################### +phase 1 "Creating directory layout under ${VAULT_HOME}" +mkdir -p "${VAULT_HOME}"/{config,data,logs,scripts,backups} +chmod 700 "${VAULT_HOME}" + +# The Vault container runs as uid=100(vault) gid=1000(vault). +# In rootless Podman the user namespace handles UID mapping, so chown +# is only needed (and only works) when running as root. +if [[ "${EUID}" -eq 0 ]]; then + VAULT_UID=100 + VAULT_GID=1000 + chown "${VAULT_UID}:${VAULT_GID}" "${VAULT_HOME}/data" "${VAULT_HOME}/logs" +fi + +############################################################################### +# Phase 2: Copy config files from repo +############################################################################### +phase 2 "Copying config files from repo" +cp "${VAULT_REPO_DIR}/config/vault.hcl" "${VAULT_HOME}/config/vault.hcl" +cp "${VAULT_REPO_DIR}/scripts/vault-unseal.sh" "${VAULT_HOME}/scripts/vault-unseal.sh" +cp "${VAULT_REPO_DIR}/scripts/vault-backup.sh" "${VAULT_HOME}/scripts/vault-backup.sh" +chmod +x "${VAULT_HOME}/scripts/"*.sh + +############################################################################### +# Phase 3: Install Quadlet unit + systemd services, start Vault +############################################################################### +phase 3 "Installing Quadlet and systemd units" +mkdir -p "${QUADLET_DIR}" "${SYSTEMD_USER_DIR}" + +cp "${VAULT_REPO_DIR}/quadlet/vault.container" "${QUADLET_DIR}/vault.container" +cp "${VAULT_REPO_DIR}/vault-unseal.service" "${SYSTEMD_USER_DIR}/vault-unseal.service" +cp "${VAULT_REPO_DIR}/vault-backup.service" "${SYSTEMD_USER_DIR}/vault-backup.service" +cp "${VAULT_REPO_DIR}/vault-backup.timer" "${SYSTEMD_USER_DIR}/vault-backup.timer" + +systemctl --user daemon-reload + +echo "Starting vault.service ..." +# On first run, ExecStartPost (unseal) will exit 0 gracefully because Vault +# is not yet initialized. If it fails for another reason, continue anyway -- +# the setup script handles init + unseal in later phases. +systemctl --user start vault.service || true + +# Wait for the Vault API to become reachable. +# vault status exits 0 (unsealed), 1 (error), or 2 (sealed/uninitialized). +# Any JSON response means the server is up. +echo "Waiting for Vault to be reachable ..." +vault_reachable=false +for _ in $(seq 1 30); do + if [[ "$(vault_status_field 'type')" != "" && "$(vault_status_field 'type')" != "null" ]]; then + vault_reachable=true + break + fi + sleep 1 +done + +if [[ "${vault_reachable}" != "true" ]]; then + echo "ERROR: Vault not reachable after 30 seconds." >&2 + echo "Check 'systemctl --user status vault.service' and 'podman logs systemd-vault'." >&2 + exit 1 +fi + +############################################################################### +# Phase 4: Initialize Vault +############################################################################### +phase 4 "Initializing Vault (3 key shares, threshold 2)" + +init_status=$(vault_status_field 'initialized') +if [[ "${init_status}" == "true" ]]; then + echo "Vault is already initialized -- skipping." +else + vault operator init \ + -key-shares=3 \ + -key-threshold=2 \ + -format=json > "${VAULT_HOME}/.vault-init.json" + chmod 0600 "${VAULT_HOME}/.vault-init.json" + echo "Init output saved to ${VAULT_HOME}/.vault-init.json (mode 0600)" + echo "IMPORTANT: Back up this file securely, then delete it from the runner." +fi + +############################################################################### +# Phase 5: Extract unseal keys +############################################################################### +phase 5 "Extracting unseal keys" + +if [[ -f "${VAULT_HOME}/.vault-init.json" ]]; then + jq -r '.unseal_keys_b64[]' "${VAULT_HOME}/.vault-init.json" \ + > "${VAULT_HOME}/.unseal-keys" + chmod 0600 "${VAULT_HOME}/.unseal-keys" + echo "Unseal keys written to ${VAULT_HOME}/.unseal-keys (mode 0600)" +else + echo "No init JSON found -- assuming unseal keys already exist." +fi + +############################################################################### +# Phase 6: Unseal Vault +############################################################################### +phase 6 "Unsealing Vault" +"${VAULT_HOME}/scripts/vault-unseal.sh" + +############################################################################### +# Phase 7: Enable KV v2 secrets engine +############################################################################### +phase 7 "Enabling KV v2 secrets engine at secret/" + +# Authenticate with the root token from init +if [[ -f "${VAULT_HOME}/.vault-init.json" ]]; then + export VAULT_TOKEN + VAULT_TOKEN=$(jq -r '.root_token' "${VAULT_HOME}/.vault-init.json") +elif [[ -z "${VAULT_TOKEN:-}" ]]; then + echo "ERROR: No VAULT_TOKEN set and ${VAULT_HOME}/.vault-init.json not found." >&2 + echo "Export VAULT_TOKEN before re-running, or provide the init JSON." >&2 + exit 1 +fi + +# Check if already enabled +if vault secrets list -format=json 2>/dev/null | jq -e '."secret/"' >/dev/null 2>&1; then + echo "KV v2 at secret/ already enabled -- skipping." +else + vault secrets enable -path=secret kv-v2 + echo "KV v2 enabled at secret/" +fi + +############################################################################### +# Phase 8: Enable and configure JWT auth (GitHub OIDC) +############################################################################### +phase 8 "Configuring JWT auth for GitHub OIDC" + +if vault auth list -format=json 2>/dev/null | jq -e '."jwt/"' >/dev/null 2>&1; then + echo "JWT auth already enabled -- reconfiguring." +else + vault auth enable jwt + echo "JWT auth enabled." +fi + +vault write auth/jwt/config \ + oidc_discovery_url="https://token.actions.githubusercontent.com" \ + bound_issuer="https://token.actions.githubusercontent.com" + +echo "JWT auth configured with GitHub Actions OIDC provider." + +############################################################################### +# Phase 9: Create osac-e2e policy and role +############################################################################### +phase 9 "Creating osac-e2e policy and role" + +vault policy write osac-e2e - <<'POLICY' +# Allow reading e2e test secrets +path "secret/data/osac/e2e/*" { + capabilities = ["read"] +} +POLICY +echo "Policy 'osac-e2e' created." + +vault write auth/jwt/role/osac-e2e - <<'ROLE' +{ + "role_type": "jwt", + "bound_claims": { + "repository_owner": "osac-project", + "environment": "e2e-test" + }, + "bound_audiences": ["https://github.com/osac-project"], + "user_claim": "repository", + "token_policies": ["osac-e2e"], + "token_ttl": "60m", + "token_max_ttl": "120m" +} +ROLE + +echo "Role 'osac-e2e' created (bound to osac-project org + e2e-test environment, 60m TTL)." + +############################################################################### +# Phase 10: Enable linger, enable services +############################################################################### +phase 10 "Enabling loginctl linger and systemd services" + +loginctl enable-linger "$(whoami)" 2>/dev/null || true + +# vault.service is a Quadlet-generated unit; it is enabled automatically +# via its [Install] WantedBy in the .container file and cannot be +# enabled with systemctl. We only need to enable the backup timer. +systemctl --user enable vault-backup.timer +systemctl --user start vault-backup.timer + +echo "" +echo "============================================" +echo " Vault setup complete!" +echo "============================================" +echo "" +echo "Next steps:" +echo " 1. Back up ${VAULT_HOME}/.vault-init.json securely" +echo " 2. Delete the init JSON from this machine" +echo " 3. Run vault-health-check.sh to verify" +echo " 4. Populate e2e secrets at secret/osac/e2e/" +echo "" diff --git a/vault/scripts/vault-unseal.sh b/vault/scripts/vault-unseal.sh new file mode 100755 index 000000000..da81c4d19 --- /dev/null +++ b/vault/scripts/vault-unseal.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# vault-unseal.sh -- Unseal Vault using stored keys. +# Called by ExecStartPost in the Quadlet unit and by vault-unseal.service. +set -euo pipefail + +VAULT_ADDR="${VAULT_ADDR:-http://127.0.0.1:8200}" +export VAULT_ADDR + +UNSEAL_KEYS_FILE="${HOME}/.vault-server/.unseal-keys" +MAX_WAIT=30 # seconds to wait for Vault to become reachable + +# Helper: get a field from vault status JSON. +# vault status exits 0 (unsealed), 1 (error), or 2 (sealed/uninitialized), +# so we must ignore its exit code. +vault_status_field() { + local field="$1" + (vault status -format=json 2>/dev/null || true) | jq -r ".${field}" 2>/dev/null +} + +############################################################################### +# Wait for Vault to be reachable +############################################################################### +echo "[vault-unseal] Waiting for Vault at ${VAULT_ADDR} ..." +elapsed=0 +while true; do + if [[ "$(vault_status_field 'type')" != "" && "$(vault_status_field 'type')" != "null" ]]; then + break + fi + if (( elapsed >= MAX_WAIT )); then + echo "[vault-unseal] ERROR: Vault not reachable after ${MAX_WAIT}s" >&2 + exit 1 + fi + sleep 1 + (( elapsed++ )) +done + +############################################################################### +# If not yet initialized, exit gracefully (vault-setup.sh will handle init) +############################################################################### +if [[ "$(vault_status_field 'initialized')" != "true" ]]; then + echo "[vault-unseal] Vault is not yet initialized -- skipping unseal." + exit 0 +fi + +############################################################################### +# Check if already unsealed +############################################################################### +if [[ "$(vault_status_field 'sealed')" == "false" ]]; then + echo "[vault-unseal] Vault is already unsealed." + exit 0 +fi + +############################################################################### +# Read unseal keys and unseal +############################################################################### +if [[ ! -f "${UNSEAL_KEYS_FILE}" ]]; then + echo "[vault-unseal] Unseal keys file not found: ${UNSEAL_KEYS_FILE}" >&2 + echo "[vault-unseal] Vault remains sealed -- unseal manually or run vault-setup.sh." >&2 + exit 1 +fi + +echo "[vault-unseal] Unsealing Vault ..." +# threshold must match -key-threshold in vault-setup.sh Phase 4 +threshold=2 +applied=0 +while IFS= read -r key; do + [[ -z "${key}" ]] && continue + vault operator unseal "${key}" >/dev/null + (( ++applied )) + if (( applied >= threshold )); then + break + fi +done < "${UNSEAL_KEYS_FILE}" + +############################################################################### +# Verify +############################################################################### +if [[ "$(vault_status_field 'sealed')" == "false" ]]; then + echo "[vault-unseal] Vault unsealed successfully." +else + echo "[vault-unseal] ERROR: Vault is still sealed after applying ${applied} keys." >&2 + exit 1 +fi diff --git a/vault/vault-backup.service b/vault/vault-backup.service new file mode 100644 index 000000000..bb1a077ca --- /dev/null +++ b/vault/vault-backup.service @@ -0,0 +1,8 @@ +# Vault backup service -- triggered by vault-backup.timer. + +[Unit] +Description=Backup HashiCorp Vault data (OSAC CI) + +[Service] +Type=oneshot +ExecStart=%h/.vault-server/scripts/vault-backup.sh diff --git a/vault/vault-backup.timer b/vault/vault-backup.timer new file mode 100644 index 000000000..cdd047c45 --- /dev/null +++ b/vault/vault-backup.timer @@ -0,0 +1,13 @@ +# Daily Vault backup timer -- fires at 03:00 with up to 10min random delay. + +[Unit] +Description=Daily Vault backup timer (OSAC CI) + +[Timer] +OnCalendar=*-*-* 03:00:00 +RandomizedDelaySec=600 +Timezone=UTC +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/vault/vault-unseal.service b/vault/vault-unseal.service new file mode 100644 index 000000000..12e92a24a --- /dev/null +++ b/vault/vault-unseal.service @@ -0,0 +1,16 @@ +# Fallback unseal service for Vault. +# The Quadlet ExecStartPost handles normal unsealing; this service +# is a manual fallback (e.g., systemctl --user start vault-unseal.service). + +[Unit] +Description=Unseal HashiCorp Vault (OSAC CI) +After=vault.service +Requires=vault.service + +[Service] +Type=oneshot +ExecStart=%h/.vault-server/scripts/vault-unseal.sh +Environment=VAULT_ADDR=http://127.0.0.1:8200 + +[Install] +WantedBy=default.target