diff --git a/scripts/install.sh b/scripts/install.sh index accc651d..18f0ee0e 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -72,6 +72,14 @@ esac SELF="$SCRIPT_DIR/install.sh" +# One report for the whole run, not one per half. Both scripts append to this +# file, so the person reviewing it sees a single account of what happened rather +# than two they have to stitch together and hope are complete. +# +# Exported before either half starts, and timestamped once here — if each script +# made its own name they would differ by however long the first one took. +export REPORT_FILE="${REPORT_FILE:-${HOME}/officer-install-report-$(date '+%Y%m%d-%H%M%S').md}" + # ── Privileges: asked for, not demanded ── # # Run this as YOURSELF. On Linux it needs root for apt, systemd units, useradd, @@ -95,6 +103,7 @@ if [[ "$KERNEL" != "Darwin" && "$EUID" -ne 0 ]]; then OFFICER_ROOT="${OFFICER_ROOT:-}" \ SETUP_USERNAME="${SETUP_USERNAME:-}" \ MACHINE_ROLE="${MACHINE_ROLE:-}" \ + REPORT_FILE="${REPORT_FILE:-}" \ bash "$SELF" "$@" fi diff --git a/scripts/setup/machine-setup/lib/base.sh b/scripts/setup/machine-setup/lib/base.sh index 0284571e..57caa162 100644 --- a/scripts/setup/machine-setup/lib/base.sh +++ b/scripts/setup/machine-setup/lib/base.sh @@ -187,6 +187,9 @@ declare -A MACOS_SKIP=( step() { CURRENT_STEP="$1" + # The report follows the step, rather than each section remembering to say + # which one it is. Twenty-six sections, one place. + declare -F report_section >/dev/null && report_section "$1" if [[ "${OS:-}" == "macos" && -n "${MACOS_SKIP[$1]:-}" ]]; then echo "" diff --git a/scripts/setup/machine-setup/lib/files.sh b/scripts/setup/machine-setup/lib/files.sh index e0168267..b6127eff 100644 --- a/scripts/setup/machine-setup/lib/files.sh +++ b/scripts/setup/machine-setup/lib/files.sh @@ -43,10 +43,17 @@ install_config() { if [[ ! -f "$dest" ]]; then install -D -m 0644 -o "$owner" -g "$(user_group "$owner")" "$src" "$dest" + # Recorded here rather than at the call site: "which files did it write" is + # the question a reviewer asks first, and a per-section report would drift + # from what this function actually did. + declare -F report_changed >/dev/null && report_changed "wrote ${dest} (0644, owner ${owner}) — did not exist" return 0 fi - cmp -s "$src" "$dest" && return 1 + if cmp -s "$src" "$dest"; then + declare -F report_kept >/dev/null && report_kept "${dest} already identical to the shipped version — not touched" + return 1 + fi echo "" warn "${dest} already exists here, and differs from the one this script ships." @@ -56,6 +63,7 @@ install_config() { # was present to defend. if [[ "${ASSUME_YES:-}" == "1" ]] || [[ ! -t 0 ]]; then echo " keeping yours (nothing was asked, so nothing is replaced)" + declare -F report_kept >/dev/null && report_kept "${dest} differs from ours and was KEPT — unattended run, nothing replaced" return 2 fi @@ -67,17 +75,20 @@ install_config() { if ! read -rp " Which one? (1/2/3) [1]: " answer; then echo "" echo " keeping yours" + declare -F report_kept >/dev/null && report_kept "${dest} differs from ours and was KEPT — no answer available" return 2 fi case "${answer:-1}" in 1) echo " keeping yours" + declare -F report_kept >/dev/null && report_kept "${dest} differs from ours and was KEPT by choice" return 2 ;; 2) cp -a "$dest" "${dest}.before-machine-setup" install -D -m 0644 -o "$owner" -g "$(user_group "$owner")" "$src" "$dest" ok "replaced — yours is at ${dest}.before-machine-setup" + declare -F report_changed >/dev/null && report_changed "REPLACED ${dest} by choice — previous kept at ${dest}.before-machine-setup" return 0 ;; 3) diff --git a/scripts/setup/machine-setup/lib/packages.sh b/scripts/setup/machine-setup/lib/packages.sh index d6c40f98..188e3f96 100644 --- a/scripts/setup/machine-setup/lib/packages.sh +++ b/scripts/setup/machine-setup/lib/packages.sh @@ -211,8 +211,20 @@ pkg_install() { LAST_INSTALLED=("${missing[@]}") LAST_KEPT=("${present[@]}") - announce_plan "$label" present missing || return 0 - pkg_install_now "${missing[@]}" + announce_plan "$label" present missing || { + # Declining is a fact a reviewer wants: it explains a package being absent + # later without having to guess whether the script failed or was refused. + declare -F report_skipped >/dev/null && report_skipped "${label}: declined — ${#missing[@]} package(s) not installed" + return 0 + } + + if pkg_install_now "${missing[@]}"; then + declare -F report_installed >/dev/null && ((${#missing[@]})) && report_installed "${PM}: ${missing[*]}" + declare -F report_kept >/dev/null && ((${#present[@]})) && report_kept "already present, untouched: ${present[*]}" + else + declare -F report_failed >/dev/null && report_failed "${PM} install failed: ${missing[*]}" + return 1 + fi } # Print what a section is about to do and ask permission for it. diff --git a/scripts/setup/machine-setup/machine-setup.sh b/scripts/setup/machine-setup/machine-setup.sh index 82f79a2a..21f3e88f 100755 --- a/scripts/setup/machine-setup/machine-setup.sh +++ b/scripts/setup/machine-setup/machine-setup.sh @@ -48,6 +48,7 @@ done # lib/ so a step can eventually be read — or run — on its own without dragging the # whole script in. Definitions only; nothing in there acts. # shellcheck source=lib/base.sh +source "$SCRIPT_DIR/../report.sh" source "$SCRIPT_DIR/lib/base.sh" # shellcheck source=lib/packages.sh source "$SCRIPT_DIR/lib/packages.sh" @@ -87,6 +88,7 @@ echo -e "${BOLD}╚════════════════════ [[ -n "${RE_ASK:-}" ]] && rm -f "$ANSWERS_FILE" load_answers +trap report_flush EXIT detect_os echo "" info "Machine: ${OS_NAME} (${ARCH})" @@ -152,6 +154,7 @@ elif [[ "$EUID" -ne 0 ]]; then OFFICER_ROOT="${OFFICER_ROOT:-}" \ SETUP_USERNAME="${SETUP_USERNAME:-}" \ MACHINE_ROLE="${MACHINE_ROLE:-}" \ + REPORT_FILE="${REPORT_FILE:-}" \ bash "$SCRIPT_DIR/machine-setup.sh" "$@" fi @@ -2521,3 +2524,5 @@ echo "" # Clean up progress file on success rm -f "$PROGRESS_FILE" + +report_mark_complete diff --git a/scripts/setup/officer-setup.sh b/scripts/setup/officer-setup.sh index a6148ab3..69257ab4 100755 --- a/scripts/setup/officer-setup.sh +++ b/scripts/setup/officer-setup.sh @@ -37,6 +37,7 @@ while [[ $# -gt 0 ]]; do done # shellcheck source=officer-setup/lib/base.sh +source "$SCRIPT_DIR/report.sh" source "$SCRIPT_DIR/officer-setup/lib/base.sh" # shellcheck source=officer-setup/lib/preflight.sh source "$SCRIPT_DIR/officer-setup/lib/preflight.sh" @@ -88,9 +89,12 @@ elif [[ "$EUID" -ne 0 ]]; then OFFICER_ROOT="${OFFICER_ROOT:-}" \ SETUP_USERNAME="${SETUP_USERNAME:-}" \ MACHINE_ROLE="${MACHINE_ROLE:-}" \ + REPORT_FILE="${REPORT_FILE:-}" \ bash "$SCRIPT_DIR/officer-setup.sh" "$@" fi +trap report_flush EXIT + # ── what machine-setup already established ── echo "" if load_machine_answers; then @@ -214,6 +218,7 @@ fi # # Before the repository, because the repository is cloned into it. +report_section "Layout" step "Layout" if ! skip; then echo "" @@ -257,6 +262,7 @@ fi # 3. Repository # ============================================================================= +report_section "Repository" step "Repository" if ! skip; then PLATFORM_DIR="$(platform_dir)" @@ -329,6 +335,7 @@ fi # 4. Dependencies # ============================================================================= +report_section "Dependencies" step "Dependencies" if ! skip; then echo "" @@ -383,6 +390,7 @@ fi # # POSTGRES_URL is set here and written by the environment section below. +report_section "Database" step "Database" if ! skip; then echo "" @@ -500,6 +508,7 @@ fi # 6. Environment # ============================================================================= +report_section "Environment" step "Environment" if ! skip; then echo "" @@ -545,6 +554,7 @@ if ! skip; then if confirm "Write it?"; then write_env ok "written, 0600, owned by ${USERNAME}" + report_changed "wrote $(env_file) (0600, owner ${USERNAME}) — PORT, PUBLIC_URL, POSTGRES_URL. No secrets: every key lives in the secret store." [[ -f "$(env_file).before-officer-setup" ]] && echo " previous kept as $(env_file).before-officer-setup" SUMMARY+=("Environment: $(env_file)") else @@ -564,6 +574,7 @@ fi # races to create it, and an install that finishes without ever saying the words # "back this up" is one where nobody learns the file matters until it is gone. +report_section "Secrets" step "Secrets" if ! skip; then echo "" @@ -583,6 +594,7 @@ if ! skip; then if confirm "Create it?"; then if bootstrap_secret_store; then ok "created, 0600, owned by ${USERNAME}" + report_changed "created $(secret_store_path) (0600, dir 0700, owner ${USERNAME}) with keys for: jwt, headscale. Generated locally, never transmitted." echo "" warn "back up $(secret_store_path) — and keep it OUT of the backup that holds your database dump." echo " Losing it signs everyone out and makes every encrypted column in" @@ -607,6 +619,7 @@ fi # 8. Schema # ============================================================================= +report_section "Schema" step "Schema" if ! skip; then echo "" @@ -629,6 +642,7 @@ if ! skip; then if confirm "Push it?"; then if OUT="$(push_schema)"; then ok "schema applied" + report_changed "applied ${SCHEMA_TABLES} tables to Postgres with 'bun db:push' (drizzle-kit; no migration files)" SUMMARY+=("Schema: ${SCHEMA_TABLES:-?} tables pushed") else warn "db:push failed" @@ -646,6 +660,7 @@ fi # 9. Build # ============================================================================= +report_section "Build" step "Build" if ! skip; then echo "" @@ -666,6 +681,7 @@ if ! skip; then elif confirm "Generate it?"; then if OUT="$(gen_index)"; then ok "$(gen_index_output)" + report_changed "generated $(gen_index_output) from index.html, substituting PUBLIC_URL=${ENV_PUBLIC_URL}" SUMMARY+=("Build: index.gen.html for ${ENV_PUBLIC_URL}") else warn "gen:index failed" @@ -683,6 +699,7 @@ fi # 10. Services # ============================================================================= +report_section "Services" step "Services" if ! skip; then echo "" @@ -702,6 +719,7 @@ if ! skip; then if confirm "Write it and start them?"; then write_ecosystem ok "written — $(ecosystem_file)" + report_changed "wrote $(ecosystem_file) — six pm2 apps: $(printf '%s ' "${CORE_PROCESSES[@]%%|*}")" # Starting against a database that is not answering is not fatal — the server # waits and the agent retries forever — but it makes the Verify section below @@ -713,12 +731,14 @@ if ! skip; then if OUT="$(pm2_start)"; then ok "processes started" + report_started "pm2 startOrRestart: $(printf '%s ' "${CORE_PROCESSES[@]%%|*}")" pm2_save >/dev/null 2>&1 && ok "process list saved (survives a pm2 restart)" echo "" if confirm "Start them on boot too?"; then if pm2_enable_startup; then ok "pm2 will resurrect them at boot" + report_ran "pm2 startup systemd — installed a systemd unit so pm2 resurrects these at boot" SUMMARY+=("Services: 6 processes started, enabled at boot") else warn "could not enable the boot hook — run 'pm2 startup' yourself and follow it" @@ -743,6 +763,7 @@ fi # 11. Verify # ============================================================================= +report_section "Verify" step "Verify" if ! skip; then echo "" @@ -791,3 +812,5 @@ fi echo "" echo -e "${BOLD} Pre-flight complete.${NC} The remaining sections are not built yet." echo "" + +report_mark_complete diff --git a/scripts/setup/report.sh b/scripts/setup/report.sh new file mode 100644 index 00000000..da5b12a8 --- /dev/null +++ b/scripts/setup/report.sh @@ -0,0 +1,184 @@ +#!/bin/bash +# ============================================================================= +# The install report +# ============================================================================= +# +# Every run writes a timestamped markdown file recording what it installed, what +# it changed, what it left alone, and what it ran as root. +# +# ── Who it is for ── +# +# Not us. It exists so the person who just ran a setup script off the internet +# can hand the result to an agent of THEIR choosing and ask "did this do anything +# it should not have". That is an adversarial read by someone who does not trust +# us, which decides almost every choice below: +# +# Facts, not narration. "installed docker-ce" is checkable. "set up Docker" is +# a claim. Every entry names the thing precisely enough to verify against the +# machine afterwards. +# +# Recorded by the HELPERS, not by the sections. A section that has to remember +# to report is a section that will forget, and an incomplete report is worse +# than none — it reads as a full account. `pkg_install` and `install_config` +# record themselves, so anything installed or written through them appears +# whether or not the section author thought about it. +# +# Kept and skipped are recorded too. "Left your .zshrc alone" is the claim a +# reviewer most wants substantiated, and it is invisible unless stated. +# +# NO SECRETS. The whole point is that this file gets shared. Passwords, keys +# and connection strings are redacted at the moment of recording rather than +# filtered later — see `report_redact`. +# +# ── Shape ── +# +# Facts accumulate in an array during the run and the file is rendered at the +# end, so a crash halfway leaves no half-written report claiming to be complete. +# `report_flush` is called by the exit trap, which marks it INCOMPLETE and says +# where it stopped. + +[[ -n "${OFFICER_REPORT_LOADED:-}" ]] && return 0 +OFFICER_REPORT_LOADED=1 + +REPORT_FACTS=() +REPORT_SECTION="(start)" +REPORT_STARTED="$(date '+%Y-%m-%d %H:%M:%S %Z')" +REPORT_COMPLETE=false + +# Where it goes. install.sh exports REPORT_FILE so both halves land in ONE file; +# a half run on its own makes its own. +report_path() { + if [[ -n "${REPORT_FILE:-}" ]]; then + echo "$REPORT_FILE" + return + fi + local base="${OFFICER_ROOT:-${USER_HOME:-$HOME}}" + [[ -d "$base" ]] || base="${USER_HOME:-$HOME}" + echo "${base}/install-report-$(date '+%Y%m%d-%H%M%S').md" +} + +# Redact anything that looks like a credential. +# +# Applied when the fact is RECORDED, not when it is rendered, so a secret never +# sits in memory formatted for printing and cannot be leaked by a future change +# to the renderer. Deliberately blunt: a password that survives is a leak, a URL +# over-redacted is an inconvenience. +report_redact() { + sed -E \ + -e 's#(://[^:/@[:space:]]+):[^@[:space:]]+@#\1:REDACTED@#g' \ + -e 's#((password|passwd|secret|token|key|apikey|api_key)[[:space:]]*[=:][[:space:]]*)[^[:space:]]+#\1REDACTED#gI' +} + +report_section() { REPORT_SECTION="$1"; } + +# One fact. `kind` is what a reviewer scans for: installed, kept, changed, +# skipped, ran, started, failed. +report_fact() { + local kind="$1" text="$2" + REPORT_FACTS+=("${REPORT_SECTION}|${kind}|$(printf '%s' "$text" | report_redact | tr '\n' ' ')") +} + +report_installed() { report_fact installed "$1"; } +report_kept() { report_fact kept "$1"; } +report_changed() { report_fact changed "$1"; } +report_skipped() { report_fact skipped "$1"; } +report_started() { report_fact started "$1"; } +report_failed() { report_fact failed "$1"; } + +# A command run with privilege. The reviewer's first question is "what did it run +# as root", and the honest answer is a list rather than a promise. +report_ran() { report_fact ran "$1"; } + +report_mark_complete() { REPORT_COMPLETE=true; } + +# Render. Safe to call twice; the trap and a normal finish both reach it. +report_flush() { + local dest kinds k + dest="$(report_path)" + [[ -n "${REPORT_WRITTEN:-}" ]] && return 0 + REPORT_WRITTEN=1 + + { + echo "# Officer install report" + echo "" + if $REPORT_COMPLETE; then + echo "**Status:** finished." + else + echo "**Status: INCOMPLETE — the run stopped during \`${REPORT_SECTION}\`.**" + echo "Everything below still happened; what comes after it did not." + fi + echo "" + echo "| | |" + echo "| --- | --- |" + echo "| started | ${REPORT_STARTED} |" + echo "| finished | $(date '+%Y-%m-%d %H:%M:%S %Z') |" + echo "| host | $(hostname 2>/dev/null || echo unknown) |" + echo "| system | $(uname -srm) |" + echo "| account | ${USERNAME:-$(id -un)} |" + echo "| script commit | $(git -C "${SCRIPT_DIR:-.}" rev-parse --short HEAD 2>/dev/null || echo 'not a git checkout') |" + echo "" + echo "---" + echo "" + echo "## How to review this" + echo "" + echo "This file exists so you can hand it to someone — or something — that does" + echo "not trust the script that wrote it. It is a list of facts, each meant to be" + echo "checkable against the machine rather than taken on faith." + echo "" + echo "Worth asking of it:" + echo "" + echo "- Does anything under **installed** come from somewhere other than your" + echo " distribution's repositories, Homebrew, or a vendor's documented installer?" + echo "- Does anything under **changed** touch a file outside this install, your" + echo " home directory, or the system configuration a setup script would be" + echo " expected to touch?" + echo "- Does anything under **ran** do more than the section it sits under claims?" + echo "- Is anything **started** that you did not ask for?" + echo "" + echo "Credentials are redacted where they were recorded. If you find one that is" + echo "not, that is a bug worth reporting — this file is meant to be shareable." + echo "" + echo "What this report does NOT cover: anything a package's own post-install" + echo "script did. Reviewing \`docker-ce\` itself is a different exercise from" + echo "reviewing the script that installed it." + echo "" + echo "---" + echo "" + + if ((${#REPORT_FACTS[@]} == 0)); then + echo "_Nothing was recorded — no section made a change._" + else + local last="" + local line section kind text + for line in "${REPORT_FACTS[@]}"; do + section="${line%%|*}" + kind="${line#*|}"; kind="${kind%%|*}" + text="${line#*|*|}" + if [[ "$section" != "$last" ]]; then + [[ -n "$last" ]] && echo "" + echo "## ${section}" + echo "" + last="$section" + fi + printf -- '- **%s** — %s\n' "$kind" "$text" + done + fi + + echo "" + echo "---" + echo "" + echo "## Summary by kind" + echo "" + for k in installed changed kept skipped started ran failed; do + local n + n="$(printf '%s\n' "${REPORT_FACTS[@]}" | grep -c "|${k}|" || true)" + printf -- '- %-10s %s\n' "$k" "$n" + done + } >"$dest" 2>/dev/null + + [[ -n "${USERNAME:-}" ]] && chown "${USERNAME}:$(id -gn "$USERNAME" 2>/dev/null || echo "$USERNAME")" "$dest" 2>/dev/null || true + chmod 0644 "$dest" 2>/dev/null || true + + echo "" + echo " Install report: ${dest}" +}