diff --git a/VERSIONS b/VERSIONS new file mode 100644 index 0000000..db17172 --- /dev/null +++ b/VERSIONS @@ -0,0 +1,104 @@ +# shellcheck shell=sh disable=SC2034 +# ============================================================================= +# foundation/VERSIONS — determinism pin-file (PLAN-002 §Determinism, baseline D5) +# ============================================================================= +# +# WHAT THIS IS +# The single source of truth for every container image and every host tool the +# foundation egg depends on. preflight/ and CI both read this file. An upgrade +# is a one-line diff here (PLAN-002 §7.1). +# +# FORMAT +# Plain `KEY=value`, one per line. This file is BOTH: +# * shell-`source`-able: `set -a; . ./VERSIONS; set +a` +# * greppable: `grep '^IMAGE_FORGEJO=' VERSIONS` +# No spaces around `=`. Values are unquoted simple tokens. Comments start `#`. +# Keys are UPPER_SNAKE_CASE. Do not use shell expansion in values. +# +# IMAGE PINNING (determinism — D5: "No floating tags") +# The DESIRED end state is to pin every image by DIGEST: +# IMAGE_=:@sha256:<64-hex-digest> +# A tag alone is mutable; the digest is the immutable content address. +# +# Where a real sha256 digest could NOT be resolved at authoring time (offline / +# no registry access), the value carries the stable version TAG plus the literal +# placeholder token `PIN_DIGEST` in the digest position: +# IMAGE_=:@sha256:PIN_DIGEST +# preflight treats `PIN_DIGEST` as "not yet pinned" and emits a WARNING (not a +# failure) so the scaffold is usable before digests are resolved. CI for a real +# `pulumi up` SHOULD fail-closed on any remaining PIN_DIGEST (gate that later). +# +# HOW TO PIN A DIGEST (`pin-digests` procedure — run when online, per image) +# For each IMAGE_* below, resolve the multi-arch (or linux/amd64) digest and +# replace `PIN_DIGEST` with the real hex, e.g.: +# +# # whole image index digest (recommended — arch-independent): +# docker manifest inspect caddy:2.10 \ +# --verbose 2>/dev/null | jq -r '.[0].Descriptor.digest // .Descriptor.digest' | head -1 +# +# # OR, simpler, pull then read the repo digest docker recorded: +# docker pull caddy:2.10 +# docker inspect --format '{{index .RepoDigests 0}}' caddy:2.10 +# # -> caddy@sha256: ; copy the after sha256: +# +# # OR with skopeo (no pull): +# skopeo inspect docker://caddy:2.10 | jq -r '.Digest' +# +# Then set: IMAGE_CADDY=caddy:2.10@sha256: +# Commit the VERSIONS diff (conventional commit, e.g. `chore(versions): pin caddy digest`). +# +# HOST TOOL VERSIONS are MINIMUMS (>=). preflight compares the installed tool +# version against TOOL__MIN using a numeric dotted-version comparison. +# ============================================================================= + + +# ----------------------------------------------------------------------------- +# CONTAINER IMAGES (CONTRACT_003 §3.2 — every container the egg runs) +# Format: IMAGE_=:@sha256: +# ----------------------------------------------------------------------------- +IMAGE_CADDY=caddy:2.10@sha256:PIN_DIGEST +IMAGE_FORGEJO=codeberg.org/forgejo/forgejo:11@sha256:PIN_DIGEST +IMAGE_POSTGRES=postgres:17@sha256:PIN_DIGEST +IMAGE_VAULT=hashicorp/vault:1.18@sha256:PIN_DIGEST +IMAGE_RUSTFS=rustfs/rustfs:latest@sha256:PIN_DIGEST +IMAGE_ACT_RUNNER=code.forgejo.org/forgejo/runner:6@sha256:PIN_DIGEST +IMAGE_REGISTRY=registry:2@sha256:PIN_DIGEST + +# NOTE on specific images: +# IMAGE_RUSTFS uses `latest` because RustFS does not (yet) publish stable +# semver tags reliably (PLAN-002 R3 — RustFS is young). MUST be pinned by +# digest before any real deploy; the digest is what gives determinism here. +# IMAGE_FORGEJO / IMAGE_ACT_RUNNER pull from Forgejo's own registries +# (codeberg.org / code.forgejo.org), not Docker Hub. + + +# ----------------------------------------------------------------------------- +# HOST TOOLS (PLAN-002 §9.1 "binary installation" / "host validation") +# Format: TOOL__MIN= (preflight enforces installed >= MIN) +# The matching CLI binary name preflight looks for is in checks/tools.sh. +# ----------------------------------------------------------------------------- + +# --- orchestration toolchain --- +TOOL_PULUMI_MIN=3.140.0 +TOOL_BUN_MIN=1.1.0 +TOOL_NODE_MIN=20.0.0 + +# --- container + scm --- +TOOL_DOCKER_MIN=24.0.0 +TOOL_GIT_MIN=2.34.0 + +# --- crypto / compression / json (backup + secrets handling) --- +TOOL_AGE_MIN=1.1.0 +TOOL_ZSTD_MIN=1.5.0 +TOOL_JQ_MIN=1.6 + +# --- service CLIs used by bootstrap / backup / DR --- +TOOL_VAULT_MIN=1.15.0 +TOOL_PSQL_MIN=15.0 +TOOL_PG_DUMP_MIN=15.0 + +# --- ssh client (Pulumi docker-over-SSH provider, git-over-ssh) --- +TOOL_OPENSSH_MIN=8.0 + +# --- S3 / RustFS client (bucket ops, backup put/get). MinIO client `mc`. --- +TOOL_MC_MIN=2023.01.01 diff --git a/documentation/agents/task_001_preflight/000_subtask_outline.md b/documentation/agents/task_001_preflight/000_subtask_outline.md new file mode 100644 index 0000000..585403a --- /dev/null +++ b/documentation/agents/task_001_preflight/000_subtask_outline.md @@ -0,0 +1,45 @@ +# Task T01 — Pre-flight tooling + `VERSIONS` pin-file — Subtask Outline + +**Mode:** BUILD (scaffolding only; no live VM touched — not HIGH-RISK/INFRA). +**Realizes:** PLAN-002 §10 T01, §9.1 (binary installation / host validation), baseline D5 (determinism). +**Contracts read:** CONTRACT_001 (config schema §1.1/§1.3), CONTRACT_003 (container/DNS §3.2). + +## Restated task +Build the pre-flight validation tooling and the `VERSIONS` determinism pin-file for `olsitec-foundation`: +1. `foundation/VERSIONS` — pin every CONTRACT_003 §3.2 container image + every required host tool; `KEY=value`, + source-able + greppable; digest-pin form `image:tag@sha256:` with a documented `pin-digests` + procedure and a `PIN_DIGEST` placeholder where digests can't be resolved offline. +2. `foundation/preflight/preflight.sh` — orchestrates checks, PASS/FAIL summary, **exits non-zero on any + failure**; `set -euo pipefail`; macOS bash 3.2 + Linux compatible. +3. `foundation/preflight/checks/*.sh` — one composable script per concern. + +## Checks implemented (one file per concern) +| Check | Concern | Gates exit code? | +|-------|---------|------------------| +| `versions.sh` | VERSIONS present, source-able, all required keys present; WARN on PIN_DIGEST | yes | +| `tools.sh` | every required tool present + version ≥ VERSIONS pin | yes | +| `env.sh` | `PULUMI_CONFIG_PASSPHRASE` set/non-empty (presence only, never printed — D2); `SSH_PRIVATE_KEY_PATH` (default `~/.ssh/id_rsa`) exists | yes | +| `docker.sh` | `docker info` succeeds (daemon reachable) | yes | +| `ssh.sh` | OPTIONAL/GATED: SSH reachability to `foundation:vm.host` — WARN-skip if no stack config | no | +| `dns.sh` | OPTIONAL/GATED: resolves `foundation:hosts.*` — WARN-skip if no stack config | no | + +Shared helpers live in `preflight/lib/common.sh` (PASS/FAIL/WARN reporters, bash-3.2-safe numeric +version compare `pf_vercmp`/`pf_ge`, `pf_versions_get`). + +## Assumptions +- **Tool list** is taken from the task scope: pulumi, bun, node, docker, git, age, zstd, jq, vault CLI, + postgresql client (psql + pg_dump), openssh client, S3/RustFS client (`mc`). CONTRACT_001/003 name the + *artifacts* but not an explicit tool floor list, so this scope list is authoritative (no contract conflict). +- **Tool minimums** in `VERSIONS` are conservative real-world floors (not exact host versions), so the + file is portable across operator workstation + CI without pinning to whatever happens to be installed here. +- **Image digests** cannot be resolved offline in this environment → every image carries the stable tag and + the `@sha256:PIN_DIGEST` placeholder. This is a WARNING, not a failure, at scaffold stage (honesty / PD-5). +- **Gated checks** depend on Pulumi stack config (`bootstrap/`) that does not meaningfully exist yet → they + detect absence and WARN-skip rather than fail (per task spec). +- Image tags chosen: caddy `2.10`, forgejo `codeberg.org/forgejo/forgejo:11`, postgres `17`, + hashicorp/vault `1.18`, rustfs `rustfs/rustfs:latest` (no stable semver — must digest-pin), + act_runner `code.forgejo.org/forgejo/runner:6`, `registry:2`. These are the pinnable identities; the + binding determinism guarantee is the digest, added by the pin-digests procedure when online. + +## Out of scope (not touched) +`bootstrap/`, `packages/`, other components, any `pulumi up`, any secret material, git add/commit. diff --git a/documentation/agents/task_001_preflight/003_handoff.md b/documentation/agents/task_001_preflight/003_handoff.md new file mode 100644 index 0000000..13df532 --- /dev/null +++ b/documentation/agents/task_001_preflight/003_handoff.md @@ -0,0 +1,77 @@ +# Task T01 — Handoff + +**Status:** complete (scaffolding). All acceptance criteria met & validated on this host (macOS arm64, +bash 3.2.57 and bash 5). No live VM touched. Not committed (lead agent reviews/commits). + +## Files created +``` +foundation/VERSIONS # determinism pin-file (images + tools) +foundation/preflight/preflight.sh # orchestrator (exits non-zero on any required failure) +foundation/preflight/lib/common.sh # shared helpers (PASS/FAIL/WARN, version compare, VERSIONS getter) +foundation/preflight/checks/versions.sh # VERSIONS present + well-formed + all keys +foundation/preflight/checks/tools.sh # tool present + version >= VERSIONS pin +foundation/preflight/checks/env.sh # PULUMI_CONFIG_PASSPHRASE + SSH_PRIVATE_KEY_PATH +foundation/preflight/checks/docker.sh # docker daemon reachable (docker info) +foundation/preflight/checks/ssh.sh # GATED: ssh reachability to vm.host (warn-skip) +foundation/preflight/checks/dns.sh # GATED: dns resolution of hosts.* (warn-skip) +documentation/agents/task_001_preflight/000_subtask_outline.md +documentation/agents/task_001_preflight/003_handoff.md +``` +Also removed the placeholder `foundation/preflight/checks/.gitkeep` (now superseded by real check files). + +## Acceptance criteria — status +- [x] **Exits non-zero on missing/mismatched tool or missing ENV.** Verified: on this host `age`, `psql`, + `pg_dump` are genuinely absent and `PULUMI_CONFIG_PASSPHRASE` was unset → `preflight.sh` printed the + FAIL summary and returned **exit 1** (under both bash 5 and `/bin/bash` 3.2). +- [x] **Exits 0 on a host with tools + ENV present.** Verified by stubbing the three genuinely-missing tools + with version-reporting shims on `PATH` and exporting a throwaway `PULUMI_CONFIG_PASSPHRASE` → full run + returned **exit 0** (digest-pin WARNINGs only, which are intentional and non-fatal). +- [x] **`VERSIONS` is source-able, lists every CONTRACT_003 image + every required tool, documents the + digest-pinning procedure.** Verified `set -a; . ./VERSIONS` succeeds; 7 `IMAGE_*` keys (caddy, forgejo, + postgres, vault, rustfs, act_runner, registry:2) + 13 `TOOL_*_MIN` keys; the `pin-digests` procedure + (`docker manifest inspect` / `docker inspect RepoDigests` / `skopeo inspect`) is in the file header. +- [x] **Composable (one file per check) + aggregated.** Each concern is its own `checks/*.sh` returning a + pass/fail exit; `preflight.sh` runs them in a subshell, collects failures, and aggregates the exit code. + +## What I validated vs. could NOT validate (honesty / PD-5) +**Validated on this machine:** +- Both the non-zero (FAIL) and zero (PASS) overall paths. +- `pf_vercmp`/`pf_ge` numeric version comparison (unit-tested: `>`, `<`, `=`, `v`-prefix strip, 2- and + 3-field versions). +- Per-tool version parsing for every tool actually installed here (pulumi, bun, node, docker, git, zstd, + jq, vault, ssh, mc); fixed a zstd parse bug (`*** Zstandard CLI (64-bit) v1.5.7` was yielding `64`). +- bash 3.2 compatibility by running under macOS `/bin/bash` (3.2.57) directly. +- `docker info` reachability (Docker Desktop running here). + +**Could NOT validate (environment-limited — flagged honestly):** +- **Real image digests.** No registry access was used; every `IMAGE_*` carries `@sha256:PIN_DIGEST`. The + `versions` check WARNs on these (does not fail) so the scaffold is usable now. **Follow-up: run the + documented `pin-digests` procedure when online** and replace each `PIN_DIGEST`. +- **`age`, `psql`, `pg_dump` real version parsing** — not installed here, so their `pf_get_version` branches + were exercised only against stubs, not real binaries. The parse expressions follow each tool's documented + `--version` format but should be re-confirmed on the provisioned host. +- **Gated `ssh.sh` / `dns.sh` active probes** — exercised only their WARN-skip path (no `bootstrap/` stack + config exists yet). The live-probe branches are unexercised until T02 produces a configured stack. +- **Linux execution** — written to POSIX/bash-3.2 constraints and tested on macOS; not run on Linux/CI here. + The DNS resolver fallback (`getent`/`host`/`dig`/`nslookup`/`python3`) and `ls -l` perm parse are the + likeliest cross-OS edge cases to spot-check in CI. + +## Contract ambiguities found +- **No explicit tool-floor list in CONTRACT_001/003.** The contracts name artifacts (images, the two ENV + inputs) but not minimum tool versions. I used the task-scope tool list as authoritative and chose + conservative version floors. If a canonical tool-version matrix is desired, it belongs in CONTRACT_001 + §1 (alongside the `VERSIONS` reference) — recommend adding it there. Not a blocker. +- **`vault` CLI on this host reports `Vault v2.0.0`** (a different `vault` binary than HashiCorp Vault), + while the container image is `hashicorp/vault:1.18`. The host CLI version floor (`TOOL_VAULT_MIN=1.15.0`) + and the image pin are independent; just noting the host binary here is not the HashiCorp build. Confirm + the operator/CI host has the HashiCorp `vault` CLI (needed for raft snapshot in backup/DR, T12/T13). + +## Recommended follow-ups +1. **Pin real digests** in `VERSIONS` (run `pin-digests` when online); consider a CI gate that fails on any + remaining `PIN_DIGEST` for a real `pulumi up` (the scaffold deliberately only WARNs). +2. **Wire preflight into CI** (`.forgejo/workflows/preflight.yml`, T14) and into `dr/restore-to-fresh-vm.sh` + (T13) as the first step. +3. **Re-confirm `age`/`psql`/`pg_dump` --version parsing** on the provisioned host once those tools are + installed. +4. Once T02 lands `bootstrap/` + stack config, the gated `ssh.sh`/`dns.sh` live-probe branches become + active — re-run preflight against a configured stack to exercise them. diff --git a/preflight/checks/.gitkeep b/preflight/checks/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/preflight/checks/dns.sh b/preflight/checks/dns.sh new file mode 100755 index 0000000..d557db7 --- /dev/null +++ b/preflight/checks/dns.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# ----------------------------------------------------------------------------- +# checks/dns.sh — OPTIONAL / GATED: DNS resolution of the configured hosts.*. +# PLAN-002 §9.4: forge.olsitec.de (+ vault/s3) MUST resolve to the VM before +# TLS/handover — but NOT necessarily at scaffold time. So: if the host names +# are not configured in the Pulumi stack yet, SKIP with a WARNING. When they +# ARE configured, attempt resolution; a miss is a WARNING (DNS may not be set +# up this early), never a hard failure. This check never fails preflight alone. +# ----------------------------------------------------------------------------- +set -euo pipefail +PF_DIR=$(cd "$(dirname "$0")/.." && pwd) +# shellcheck source=../lib/common.sh +. "$PF_DIR/lib/common.sh" +BOOTSTRAP_DIR=$(cd "$PF_DIR/.." && pwd)/bootstrap + +echo "[dns] (gated) DNS resolution of configured hosts.*" + +pf_pulumi_config() { + pf_have pulumi || { printf ''; return; } + [ -d "$BOOTSTRAP_DIR" ] || { printf ''; return; } + ( cd "$BOOTSTRAP_DIR" 2>/dev/null && pulumi config get "$1" 2>/dev/null ) || printf '' +} + +# pf_resolve : true if name resolves to at least one address. +# Tries the tools likely present on macOS/Linux without requiring any one. +pf_resolve() { + n="$1" + if pf_have getent; then getent hosts "$n" >/dev/null 2>&1 && return 0; fi + if pf_have dscacheutil; then dscacheutil -q host -a name "$n" 2>/dev/null | grep -q ip_address && return 0; fi + if pf_have host; then host "$n" >/dev/null 2>&1 && return 0; fi + if pf_have dig; then [ -n "$(dig +short "$n" 2>/dev/null)" ] && return 0; fi + if pf_have nslookup; then nslookup "$n" >/dev/null 2>&1 && return 0; fi + if pf_have python3; then python3 -c "import socket,sys; socket.gethostbyname(sys.argv[1])" "$n" >/dev/null 2>&1 && return 0; fi + return 1 +} + +if ! pf_have pulumi || [ ! -d "$BOOTSTRAP_DIR" ]; then + pf_warn "no pulumi stack config available yet -> SKIPPING dns resolution (expected pre-Phase-0)" + pf_summary "dns"; exit $? +fi + +any=0 +for key in foundation:hosts.forge foundation:hosts.vault foundation:hosts.s3; do + name=$(pf_pulumi_config "$key") + [ -n "$name" ] || continue + any=1 + if pf_resolve "$name"; then + pf_pass "DNS: $name resolves" + else + pf_warn "DNS: $name does NOT resolve yet (required before TLS/handover, PLAN-002 §9.4) — not failing" + fi +done + +if [ "$any" -eq 0 ]; then + pf_warn "no hosts.* configured in stack yet -> SKIPPING dns resolution" +fi + +pf_summary "dns" diff --git a/preflight/checks/docker.sh b/preflight/checks/docker.sh new file mode 100755 index 0000000..6ad0b0f --- /dev/null +++ b/preflight/checks/docker.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# ----------------------------------------------------------------------------- +# checks/docker.sh — the Docker daemon is reachable. +# `docker info` must succeed (CONTRACT_003: the whole egg is Docker containers; +# the Pulumi @pulumi/docker provider needs a reachable daemon — locally for the +# CLI sanity check here, over SSH at deploy time). +# Exits non-zero if the docker CLI is missing or the daemon is unreachable. +# ----------------------------------------------------------------------------- +set -euo pipefail +PF_DIR=$(cd "$(dirname "$0")/.." && pwd) +# shellcheck source=../lib/common.sh +. "$PF_DIR/lib/common.sh" + +echo "[docker] docker daemon reachable" + +if ! pf_have docker; then + pf_fail "docker CLI not installed (see tools check)" + pf_summary "docker" + exit $? +fi + +if docker info >/dev/null 2>&1; then + ctx=$(docker context show 2>/dev/null || echo "default") + pf_pass "docker daemon reachable (context: $ctx)" +else + pf_fail "docker daemon NOT reachable ('docker info' failed) — is Docker Desktop / the engine running?" +fi + +pf_summary "docker" diff --git a/preflight/checks/env.sh b/preflight/checks/env.sh new file mode 100755 index 0000000..1530890 --- /dev/null +++ b/preflight/checks/env.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# ----------------------------------------------------------------------------- +# checks/env.sh — required environment for a `pulumi up` (CONTRACT_001 §1, §1.3). +# * PULUMI_CONFIG_PASSPHRASE : set & non-empty (the single external secret, D2). +# NEVER printed — only its presence is reported. +# * SSH_PRIVATE_KEY_PATH : path to the VM key (default ~/.ssh/id_rsa) exists. +# Exits non-zero if a required var is missing/empty or the key file is absent. +# ----------------------------------------------------------------------------- +set -euo pipefail +PF_DIR=$(cd "$(dirname "$0")/.." && pwd) +# shellcheck source=../lib/common.sh +. "$PF_DIR/lib/common.sh" + +echo "[env] required environment variables and secrets (CONTRACT_001 §1.3)" + +# --- PULUMI_CONFIG_PASSPHRASE: presence only, value is sacred (D2) --- +if [ -n "${PULUMI_CONFIG_PASSPHRASE:-}" ]; then + pf_pass "PULUMI_CONFIG_PASSPHRASE is set (value not shown — D2)" +elif [ -n "${PULUMI_CONFIG_PASSPHRASE_FILE:-}" ]; then + if [ -f "${PULUMI_CONFIG_PASSPHRASE_FILE}" ]; then + pf_pass "PULUMI_CONFIG_PASSPHRASE_FILE set and file exists (value not shown)" + else + pf_fail "PULUMI_CONFIG_PASSPHRASE_FILE='${PULUMI_CONFIG_PASSPHRASE_FILE}' does not exist" + fi +else + pf_fail "PULUMI_CONFIG_PASSPHRASE is unset/empty (and no PULUMI_CONFIG_PASSPHRASE_FILE)" +fi + +# --- SSH_PRIVATE_KEY_PATH: file must exist (CONTRACT_001 default ~/.ssh/id_rsa) --- +ssh_key="${SSH_PRIVATE_KEY_PATH:-$HOME/.ssh/id_rsa}" +# Expand a leading ~ if the operator exported it literally. +case "$ssh_key" in + "~/"*) ssh_key="$HOME/${ssh_key#~/}" ;; + "~") ssh_key="$HOME" ;; +esac +if [ -f "$ssh_key" ]; then + if [ -z "${SSH_PRIVATE_KEY_PATH:-}" ]; then + pf_pass "SSH private key found at default path: $ssh_key" + else + pf_pass "SSH private key found: $ssh_key" + fi + # Permission hygiene: warn (do not fail) on world/group-readable key. + perms=$(ls -l "$ssh_key" 2>/dev/null | cut -c1-10) + case "$perms" in + *------) : ;; # owner-only-ish; fine + *) pf_warn "SSH key $ssh_key permissions look loose ($perms); 'chmod 600' recommended" ;; + esac +else + pf_fail "SSH private key not found at '$ssh_key' (set SSH_PRIVATE_KEY_PATH or create ~/.ssh/id_rsa)" +fi + +pf_summary "env" diff --git a/preflight/checks/ssh.sh b/preflight/checks/ssh.sh new file mode 100755 index 0000000..c7843bf --- /dev/null +++ b/preflight/checks/ssh.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# ----------------------------------------------------------------------------- +# checks/ssh.sh — OPTIONAL / GATED: SSH reachability to the configured VM host. +# Depends on Pulumi config (foundation:vm.host / foundation:vm.user) that may +# not exist at scaffold time. If the stack/config is absent we SKIP with a +# WARNING (never a failure). Only when a host IS configured do we attempt a +# non-interactive SSH probe; a failed probe is a WARNING too, because the VM +# may legitimately not exist yet during early bootstrap (PLAN-002 Phase 0/1). +# This check therefore never causes preflight to exit non-zero on its own. +# ----------------------------------------------------------------------------- +set -euo pipefail +PF_DIR=$(cd "$(dirname "$0")/.." && pwd) +# shellcheck source=../lib/common.sh +. "$PF_DIR/lib/common.sh" +BOOTSTRAP_DIR=$(cd "$PF_DIR/.." && pwd)/bootstrap + +echo "[ssh] (gated) SSH reachability to configured VM host" + +# pf_pulumi_config : echo a stack config value, or "" if unavailable. +# Tolerates: pulumi not installed, no stack selected, key absent, no project. +pf_pulumi_config() { + pf_have pulumi || { printf ''; return; } + [ -d "$BOOTSTRAP_DIR" ] || { printf ''; return; } + ( cd "$BOOTSTRAP_DIR" 2>/dev/null && pulumi config get "$1" 2>/dev/null ) || printf '' +} + +if ! pf_have pulumi; then + pf_warn "pulumi not installed -> cannot read vm.host; SKIPPING ssh reachability" + pf_summary "ssh"; exit $? +fi +if [ ! -d "$BOOTSTRAP_DIR" ]; then + pf_warn "bootstrap/ not present yet -> no stack config; SKIPPING ssh reachability" + pf_summary "ssh"; exit $? +fi + +vm_host=$(pf_pulumi_config "foundation:vm.host") +vm_user=$(pf_pulumi_config "foundation:vm.user") +[ -n "$vm_user" ] || vm_user="root" + +if [ -z "$vm_host" ]; then + pf_warn "foundation:vm.host not configured yet -> SKIPPING ssh reachability (expected pre-Phase-0)" + pf_summary "ssh"; exit $? +fi + +ssh_key="${SSH_PRIVATE_KEY_PATH:-$HOME/.ssh/id_rsa}" +case "$ssh_key" in "~/"*) ssh_key="$HOME/${ssh_key#~/}" ;; esac + +pf_info "configured target: ${vm_user}@${vm_host} (key: $ssh_key)" + +if ! pf_have ssh; then + pf_warn "ssh client missing (see tools check) -> SKIPPING reachability probe" + pf_summary "ssh"; exit $? +fi + +# Non-interactive, fast-failing probe. BatchMode avoids password prompts; +# a 'true' command that succeeds proves auth + reachability. +if ssh -o BatchMode=yes -o ConnectTimeout=8 -o StrictHostKeyChecking=accept-new \ + -i "$ssh_key" "${vm_user}@${vm_host}" true >/dev/null 2>&1; then + pf_pass "SSH to ${vm_user}@${vm_host} succeeded" +else + pf_warn "SSH to ${vm_user}@${vm_host} did not succeed (VM may not exist yet / key not trusted) — not failing" +fi + +pf_summary "ssh" diff --git a/preflight/checks/tools.sh b/preflight/checks/tools.sh new file mode 100755 index 0000000..95852c6 --- /dev/null +++ b/preflight/checks/tools.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# ----------------------------------------------------------------------------- +# checks/tools.sh — every required host tool is present and >= its VERSIONS pin. +# Reads TOOL__MIN from foundation/VERSIONS (PLAN-002 §9.1 host validation). +# Exits non-zero if any required tool is missing or below its minimum. +# ----------------------------------------------------------------------------- +set -euo pipefail +PF_DIR=$(cd "$(dirname "$0")/.." && pwd) +# shellcheck source=../lib/common.sh +. "$PF_DIR/lib/common.sh" + +echo "[tools] required host tools present and >= VERSIONS pin" + +# pf_get_version : echo a parseable version string for the tool, or "". +# Kept in one place so the per-tool quirks are visible and testable. +pf_get_version() { + case "$1" in + pulumi) pulumi version 2>/dev/null ;; # -> v3.243.0 + bun) bun --version 2>/dev/null ;; # -> 1.3.9 + node) node --version 2>/dev/null ;; # -> v24.10.0 + docker) docker --version 2>/dev/null | sed 's/.*version //' ;; # Docker version 29.5.3, build ... + git) git --version 2>/dev/null ;; # git version 2.54.0 + age) age --version 2>/dev/null ;; # v1.1.1 (or "1.1.1") + zstd) zstd --version 2>/dev/null | sed -n 's/.*[ vV]\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' ;; # *** Zstandard CLI (64-bit) v1.5.7, ... + jq) jq --version 2>/dev/null ;; # jq-1.8.1 + vault) vault --version 2>/dev/null ;; # Vault v1.18.0 (...) + psql) psql --version 2>/dev/null ;; # psql (PostgreSQL) 16.2 + pg_dump) pg_dump --version 2>/dev/null ;; # pg_dump (PostgreSQL) 16.2 + ssh) ssh -V 2>&1 ;; # OpenSSH_10.2p1, LibreSSL ... + mc) mc --version 2>/dev/null | head -1 ;; # mc version RELEASE.2025-08-13T08-35-41Z + *) printf '' ;; + esac +} + +# pf_check_tool +pf_check_tool() { + bin="$1"; key="$2"; pretty="$3" + min=$(pf_versions_get "$key" 2>/dev/null || true) + if [ -z "$min" ]; then + pf_fail "$pretty: no $key in VERSIONS (cannot determine minimum)" + return + fi + if ! pf_have "$bin"; then + pf_fail "$pretty ($bin): NOT INSTALLED (need >= $min)" + return + fi + raw=$(pf_get_version "$bin" || true) + # mc uses a date-stamped RELEASE.YYYY-MM-DD... tag; normalize to a dotted date. + if [ "$bin" = "mc" ]; then + norm=$(printf '%s' "$raw" | sed -n 's/.*RELEASE\.\([0-9]\{4\}\)-\([0-9]\{2\}\)-\([0-9]\{2\}\).*/\1.\2.\3/p') + minn=$(printf '%s' "$min" | tr '-' '.') + if [ -z "$norm" ]; then + pf_warn "$pretty ($bin): present but version unparseable ('$raw'); assuming OK" + return + fi + if pf_ge "$norm" "$minn"; then + pf_pass "$pretty ($bin) $norm >= $min" + else + pf_fail "$pretty ($bin) $norm < required $min" + fi + return + fi + if [ -z "$raw" ]; then + pf_warn "$pretty ($bin): present but version unparseable; assuming OK" + return + fi + if pf_ge "$raw" "$min"; then + pf_pass "$pretty ($bin) $(printf '%s' "$raw" | sed -e 's/^[^0-9]*//' -e 's/[^0-9.].*$//') >= $min" + else + pf_fail "$pretty ($bin) '$raw' < required $min" + fi +} + +# bin VERSIONS key pretty name +pf_check_tool pulumi TOOL_PULUMI_MIN "Pulumi" +pf_check_tool bun TOOL_BUN_MIN "Bun" +pf_check_tool node TOOL_NODE_MIN "Node.js" +pf_check_tool docker TOOL_DOCKER_MIN "Docker CLI" +pf_check_tool git TOOL_GIT_MIN "Git" +pf_check_tool age TOOL_AGE_MIN "age" +pf_check_tool zstd TOOL_ZSTD_MIN "zstd" +pf_check_tool jq TOOL_JQ_MIN "jq" +pf_check_tool vault TOOL_VAULT_MIN "Vault CLI" +pf_check_tool psql TOOL_PSQL_MIN "PostgreSQL client (psql)" +pf_check_tool pg_dump TOOL_PG_DUMP_MIN "PostgreSQL client (pg_dump)" +pf_check_tool ssh TOOL_OPENSSH_MIN "OpenSSH client" +pf_check_tool mc TOOL_MC_MIN "S3/RustFS client (mc)" + +pf_summary "tools" diff --git a/preflight/checks/versions.sh b/preflight/checks/versions.sh new file mode 100755 index 0000000..8dd6d39 --- /dev/null +++ b/preflight/checks/versions.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# ----------------------------------------------------------------------------- +# checks/versions.sh — the VERSIONS pin-file is present, source-able, and lists +# every CONTRACT_003 §3.2 image + every required tool (CONTRACT_001 §Validation: +# "preflight asserts VERSIONS present and well-formed"). +# FAIL if missing/unparseable or a required key is absent. +# WARN (not fail) on any image still carrying the PIN_DIGEST placeholder. +# ----------------------------------------------------------------------------- +set -euo pipefail +PF_DIR=$(cd "$(dirname "$0")/.." && pwd) +# shellcheck source=../lib/common.sh +. "$PF_DIR/lib/common.sh" + +echo "[versions] VERSIONS pin-file present and well-formed" + +vf=$(pf_versions_file) +if [ ! -f "$vf" ]; then + pf_fail "VERSIONS not found at $vf" + pf_summary "versions"; exit $? +fi +pf_pass "VERSIONS present: $vf" + +# Source-able? (run in a subshell so a bad file can't poison this process). +if ( set -a; . "$vf"; set +a ) >/dev/null 2>&1; then + pf_pass "VERSIONS is source-able" +else + pf_fail "VERSIONS is NOT source-able (syntax error)" +fi + +# Required image keys (CONTRACT_003 §3.2). +for k in IMAGE_CADDY IMAGE_FORGEJO IMAGE_POSTGRES IMAGE_VAULT IMAGE_RUSTFS IMAGE_ACT_RUNNER IMAGE_REGISTRY; do + v=$(pf_versions_get "$k" 2>/dev/null || true) + if [ -z "$v" ]; then + pf_fail "missing required image key: $k" + else + case "$v" in + *@sha256:PIN_DIGEST) pf_warn "$k not yet digest-pinned ($v) — run the pin-digests procedure" ;; + *@sha256:*) pf_pass "$k pinned by digest" ;; + *) pf_warn "$k has no '@sha256:' digest ($v) — floating tag (D5 wants a digest)" ;; + esac + fi +done + +# Required tool-minimum keys. +for k in TOOL_PULUMI_MIN TOOL_BUN_MIN TOOL_NODE_MIN TOOL_DOCKER_MIN TOOL_GIT_MIN \ + TOOL_AGE_MIN TOOL_ZSTD_MIN TOOL_JQ_MIN TOOL_VAULT_MIN TOOL_PSQL_MIN \ + TOOL_PG_DUMP_MIN TOOL_OPENSSH_MIN TOOL_MC_MIN; do + v=$(pf_versions_get "$k" 2>/dev/null || true) + if [ -z "$v" ]; then + pf_fail "missing required tool-minimum key: $k" + else + pf_pass "$k = $v" + fi +done + +pf_summary "versions" diff --git a/preflight/lib/common.sh b/preflight/lib/common.sh new file mode 100644 index 0000000..3db27e0 --- /dev/null +++ b/preflight/lib/common.sh @@ -0,0 +1,82 @@ +# shellcheck shell=bash +# ----------------------------------------------------------------------------- +# preflight/lib/common.sh — shared helpers for all preflight checks. +# Sourced (not executed). POSIX-friendly, macOS bash 3.2 compatible: +# no associative arrays, no `mapfile`, no `${var^^}`, no process substitution. +# ----------------------------------------------------------------------------- + +# ANSI colors (disabled if not a tty or NO_COLOR set). +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then + PF_RED=$(printf '\033[31m'); PF_GRN=$(printf '\033[32m') + PF_YEL=$(printf '\033[33m'); PF_RST=$(printf '\033[0m') +else + PF_RED=""; PF_GRN=""; PF_YEL=""; PF_RST="" +fi + +# Per-process result counters. Each check script reports into these and prints +# its own lines; preflight.sh sums up by parsing the exit code + a summary line. +PF_PASS=0 +PF_FAIL=0 +PF_WARN=0 + +pf_pass() { PF_PASS=$((PF_PASS + 1)); printf ' %sPASS%s %s\n' "$PF_GRN" "$PF_RST" "$*"; } +pf_fail() { PF_FAIL=$((PF_FAIL + 1)); printf ' %sFAIL%s %s\n' "$PF_RED" "$PF_RST" "$*"; } +pf_warn() { PF_WARN=$((PF_WARN + 1)); printf ' %sWARN%s %s\n' "$PF_YEL" "$PF_RST" "$*"; } +pf_info() { printf ' %s----%s %s\n' "$PF_YEL" "$PF_RST" "$*"; } + +# pf_summary : print one machine-parseable line and return an exit +# code: 0 if no failures, 1 if any failure. WARN never fails. +pf_summary() { + printf ' -> %s: %d pass, %d warn, %d fail\n' "$1" "$PF_PASS" "$PF_WARN" "$PF_FAIL" + [ "$PF_FAIL" -eq 0 ] +} + +# pf_have : true if command exists on PATH. +pf_have() { command -v "$1" >/dev/null 2>&1; } + +# pf_vercmp : numeric dotted-version compare. Echoes: +# 0 if a == b, 1 if a > b, 2 if a < b. Missing components treated as 0. +# Non-numeric leading tokens are stripped (e.g. "v3.243.0" -> "3.243.0"). +pf_vercmp() { + a=$(printf '%s' "$1" | sed -e 's/^[^0-9]*//' -e 's/[^0-9.].*$//') + b=$(printf '%s' "$2" | sed -e 's/^[^0-9]*//' -e 's/[^0-9.].*$//') + # Normalize to dot-separated fields and compare field by field. + IFS=. + # shellcheck disable=SC2086 + set -- $a; a1=${1:-0}; a2=${2:-0}; a3=${3:-0}; a4=${4:-0} + # shellcheck disable=SC2086 + set -- $b; b1=${1:-0}; b2=${2:-0}; b3=${3:-0}; b4=${4:-0} + unset IFS + i=1 + for pair in "$a1 $b1" "$a2 $b2" "$a3 $b3" "$a4 $b4"; do + # shellcheck disable=SC2086 + set -- $pair + av=$(printf '%s' "$1" | sed 's/[^0-9].*$//'); av=${av:-0} + bv=$(printf '%s' "$2" | sed 's/[^0-9].*$//'); bv=${bv:-0} + if [ "$av" -gt "$bv" ] 2>/dev/null; then echo 1; return 0; fi + if [ "$av" -lt "$bv" ] 2>/dev/null; then echo 2; return 0; fi + i=$((i + 1)) + done + echo 0 +} + +# pf_ge : true (0) if version `have` >= `min`. +pf_ge() { + r=$(pf_vercmp "$1" "$2") + [ "$r" = "0" ] || [ "$r" = "1" ] +} + +# pf_versions_file : absolute path to the VERSIONS pin-file (repo root). +pf_versions_file() { + # lib/ is preflight/lib, repo root is two levels up. + printf '%s/VERSIONS' "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../.." && pwd)" +} + +# pf_versions_get : echo the value of KEY from VERSIONS (greppable form). +pf_versions_get() { + vf=$(pf_versions_file) + [ -f "$vf" ] || return 1 + line=$(grep "^$1=" "$vf" 2>/dev/null | head -1) + [ -n "$line" ] || return 1 + printf '%s' "${line#*=}" +} diff --git a/preflight/preflight.sh b/preflight/preflight.sh new file mode 100755 index 0000000..90053fa --- /dev/null +++ b/preflight/preflight.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# ============================================================================= +# preflight/preflight.sh — foundation host & toolchain pre-flight validation. +# +# PLAN-002 Phase 1 / §9.1: a freshly-cloned repo validates host + toolchain +# BEFORE any `pulumi up`. Fails CLOSED: exits NON-ZERO if ANY required check +# fails. Composable: one script per concern under checks/, aggregated here. +# +# Runs on the operator workstation AND in CI, on macOS (bash 3.2) and Linux. +# No hard bashisms: no associative arrays, no mapfile, no ${var^^}, no +# process substitution. +# +# USAGE +# ./preflight/preflight.sh # run all checks +# ./preflight/preflight.sh tools env # run only the named checks +# NO_COLOR=1 ./preflight/preflight.sh # disable ANSI color +# +# EXIT CODES +# 0 all REQUIRED checks passed (gated ssh/dns may WARN — that's fine) +# 1 at least one required check failed (missing/old tool, missing ENV, etc.) +# ============================================================================= +set -euo pipefail + +PF_DIR=$(cd "$(dirname "$0")" && pwd) +CHECKS_DIR="$PF_DIR/checks" + +# Colors for the top-level summary (lib/common.sh handles per-check coloring). +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then + C_RED=$(printf '\033[31m'); C_GRN=$(printf '\033[32m') + C_YEL=$(printf '\033[33m'); C_BLD=$(printf '\033[1m'); C_RST=$(printf '\033[0m') +else + C_RED=""; C_GRN=""; C_YEL=""; C_BLD=""; C_RST="" +fi + +# Ordered list of checks. REQUIRED checks gate the exit code; GATED checks +# (ssh, dns) are allowed to be absent/unreachable and only warn — they are +# listed so they run, but their result never flips the overall status. +REQUIRED_CHECKS="versions tools env docker" +GATED_CHECKS="ssh dns" + +# Optional positional args restrict which checks run (any from either list). +if [ "$#" -gt 0 ]; then + REQUIRED_CHECKS="" + GATED_CHECKS="" + for arg in "$@"; do + case "$arg" in + ssh|dns) GATED_CHECKS="$GATED_CHECKS $arg" ;; + *) REQUIRED_CHECKS="$REQUIRED_CHECKS $arg" ;; + esac + done +fi + +printf '%s== foundation preflight ==%s\n' "$C_BLD" "$C_RST" +printf 'repo: %s\n' "$(cd "$PF_DIR/.." && pwd)" +printf 'host: %s (%s)\n\n' "$(uname -s)" "$(uname -m)" + +FAILED_CHECKS="" +RAN_CHECKS="" + +run_check() { + name="$1" + script="$CHECKS_DIR/$name.sh" + if [ ! -f "$script" ]; then + printf '%s[%s] no such check (%s) %s\n' "$C_RED" "$name" "$script" "$C_RST" + FAILED_CHECKS="$FAILED_CHECKS $name" + return + fi + RAN_CHECKS="$RAN_CHECKS $name" + # Run in its own shell so `set -e` inside a check can't abort the orchestrator. + if bash "$script"; then + : # check returned 0 + else + FAILED_CHECKS="$FAILED_CHECKS $name" + fi + printf '\n' +} + +# Required checks first (these gate exit status). +for c in $REQUIRED_CHECKS; do + [ -n "$c" ] && run_check "$c" +done +# Gated checks (run for visibility; their failure does NOT gate exit status, +# but a non-zero from them would only happen on an internal error — we still +# don't let it fail the run, matching the "skip with WARNING" requirement). +for c in $GATED_CHECKS; do + [ -n "$c" ] || continue + script="$CHECKS_DIR/$c.sh" + if [ -f "$script" ]; then + RAN_CHECKS="$RAN_CHECKS $c" + bash "$script" || true # gated: never gates the exit code + printf '\n' + fi +done + +# ---- summary ---- +printf '%s== summary ==%s\n' "$C_BLD" "$C_RST" +printf 'ran:%s\n' "$RAN_CHECKS" + +if [ -n "$FAILED_CHECKS" ]; then + printf '%sFAIL%s — failing checks:%s\n' "$C_RED" "$C_RST" "$FAILED_CHECKS" + printf '%spreflight FAILED — fix the above before `pulumi up`.%s\n' "$C_RED" "$C_RST" + exit 1 +fi + +printf '%sPASS%s — all required checks passed.\n' "$C_GRN" "$C_RST" +printf '%sNote:%s gated ssh/dns checks WARN-skip until the Pulumi stack is configured.\n' "$C_YEL" "$C_RST" +exit 0