#!/usr/bin/env bash # Verify canonical local/server installation manuals and their base+override Compose paths. set -euo pipefail root="$(cd "$(dirname "$0")/.." && pwd -P)" mode="${1:-}" trim() { local value="$1" value="${value#"${value%%[![:space:]]*}"}" value="${value%"${value##*[![:space:]]}"}" printf '%s' "$value" } is_safe_absolute_path() { local value="$1" segment local -a segments [[ "$value" == /* && "$value" != *//* ]] || return 1 IFS=/ read -r -a segments <<<"$value" for segment in "${segments[@]}"; do [[ "$segment" != . && "$segment" != .. ]] || return 1 done } verify_path_variable_values() { local source="$1" line trimmed name value while IFS= read -r line || [[ -n "$line" ]]; do trimmed="$(trim "$line")" if [[ "$trimmed" == *=* || "$trimmed" == *:* ]]; then name="$(trim "${trimmed%%[=:]*}")" value="$(trim "${trimmed#"$name"}")" value="$(trim "${value#[:=]}")" if [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*_(FILE|SOURCE)$ ]]; then value="$(trim "${value%%#*}")" value="${value#\"}"; value="${value%\"}" value="${value#\'}"; value="${value%\'}" if [[ -n "$value" ]] && ! is_safe_absolute_path "$value"; then echo "unsafe path value for $name in $source" >&2 return 1 fi fi fi done <"$source" } require_headings() { local source="$1" label="$2" shift 2 local heading for heading in "$@"; do grep -Fqx "## $heading" "$source" || { echo "missing required heading in $label: $heading" >&2 return 1 } done } require_text() { local source="$1" label="$2" shift 2 local expected for expected in "$@"; do grep -Fq -- "$expected" "$source" || { echo "$label lacks required instruction: $expected" >&2 return 1 } done } verify_local_guide() { local guide="$root/docs/install/local.md" [[ -f "$guide" ]] || { echo "missing local installation guide: docs/install/local.md" >&2 return 1 } require_headings "$guide" "local installation guide" \ "Choose your platform" \ "Prerequisites" \ "Clone and verify LF" \ "Create the local operator files" \ "Address external services" \ "Build ThothII and thothctl" \ "Start and verify" \ "Update an installation" \ "Back up and restore" \ "Data-preserving uninstall" \ "Next: workspaces and Pi" require_text "$guide" "local installation guide" \ "git clone" \ "bash scripts/verify-line-endings.sh" \ "deploy/env/local.env" \ "host.docker.internal" \ "host-gateway" \ "container 127.0.0.1" \ "bash scripts/build-local.sh" \ "scripts/build-local.ps1" \ "bash scripts/build-thothctl.sh" \ "thothctl --installation" \ "curl --fail http://127.0.0.1:8080/health" \ "http://127.0.0.1:8080" \ "git pull --ff-only" \ "docker compose down --volumes" echo "local installation guide contract passed" } verify_windows_line_endings_guide() { local guide="$root/docs/install/windows-line-endings.md" [[ -f "$guide" ]] || { echo "missing Windows line-ending guide: docs/install/windows-line-endings.md" >&2 return 1 } require_headings "$guide" "Windows line-ending guide" \ "Recommended WSL2 clone" \ "Repository-local LF policy" \ "Verify after clone or pull" \ "Recover an existing CRLF clone" require_text "$guide" "Windows line-ending guide" \ "git config --local core.autocrlf false" \ "bash scripts/verify-line-endings.sh" \ "git add --renormalize ." \ "git diff --cached --check" \ "reclone" if grep -Fq 'git reset --hard' "$guide"; then node - "$guide" <<'NODE' const fs = require("fs"); const lines = fs.readFileSync(process.argv[2], "utf8").split(/\n/); for (let index = 0; index < lines.length; index += 1) { if (!lines[index].includes("git reset --hard")) continue; const warning = lines.slice(Math.max(0, index - 4), index).join(" ").toLowerCase(); if (!warning.includes("warning") || !warning.includes("destructive") || !warning.includes("backup") || !warning.includes("commit")) { throw new Error("git reset --hard lacks an immediate destructive warning requiring backup/commit"); } } NODE fi echo "Windows line-ending recovery guide contract passed" } verify_pi_management_guide() { local guide="$root/docs/install/pi-management.md" [[ -f "$guide" ]] || { echo "missing Pi management guide: docs/install/pi-management.md" >&2 return 1 } require_headings "$guide" "Pi management guide" \ "Who can use Pi Management" \ "Use the Pi Management page" \ "Use thothctl" \ "Handle credentials and secrets" \ "Update and roll back Pi" \ "Recover a failed update" \ "Direct support access" require_text "$guide" "Pi management guide" \ "pi status" \ "pi doctor" \ "pi test" \ "pi check" \ "pi configure" \ "pi update" \ "pi rollback --yes" \ "pi maintenance status" \ "pi maintenance recover --yes" \ "pi logs" \ "/run/secrets" \ "docker compose exec core pi" \ "no browser shell" \ "does not mount the Docker socket" echo "Pi management guide contract passed" } verify_manual() { local profile="$1" manual manual="$root/docs/install/$profile-workspace-registry.md" local -a headings if [[ "$profile" == local ]]; then headings=( "Prerequisites" "Git remote: SSH and HTTPS" "Shared Git values, local bindings, and secret files" "Direct PostgreSQL, REST, and SSH tunnel bindings" "Bootstrap, first pull, and diagnostics" "Publish, update, backup, outage recovery, and rollback" "Troubleshooting" ) else headings=( "Service account, storage, and firewall" "Gitea and remote Git setup" "Git credentials, CA, SSH key, and known-hosts mounts" "Shared Git values, local bindings, and secret files" "Direct PostgreSQL, REST, and SSH tunnel bindings" "Same-origin reverse proxy, bootstrap, and health" "Pull, publish, upgrade, backup, and recovery" "Troubleshooting and snapshot rollback" ) fi for heading in "${headings[@]}"; do grep -Fqx "## $heading" "$manual" || { echo "missing required heading in $profile manual: $heading" >&2 return 1 } done for expected in \ 'export THT_SOURCE_ROOT=/absolute/path/to/ThothII' \ '--env-file "$THT_OPERATOR_ENV"' \ "-f \"\$THT_SOURCE_ROOT/compose.yaml\" -f \"\$THT_SOURCE_ROOT/deploy/compose.$profile.yaml\"" \ '"$THT_SOURCE_ROOT/scripts/generate-connector-secrets-override.sh"'; do grep -Fq -- "$expected" "$manual" || { echo "$profile manual lacks canonical operator step: $expected" >&2 return 1 } done if rg -n 'local-compose\.workspace-registry|server-compose\.workspace-registry|connector-secrets\.workspace-registry|docker compose' "$manual"; then echo "$profile manual documents a superseded or bypassed Compose path" >&2 return 1 fi verify_path_variable_values "$manual" echo "$profile manual canonical base+override references passed" } verify_local_installation_example() { local example="$root/docs/install/examples/thothii-installation.local.yaml" [[ -f "$example" ]] || { echo "missing local installation example: docs/install/examples/thothii-installation.local.yaml" >&2 return 1 } local fixture source_copy operator_dir copied_example connector_override env_file fixture="$(mktemp -d "${TMPDIR%/}/thoth local install.XXXXXX")" trap 'rm -rf "$fixture"' RETURN [[ "$fixture" == *" "* ]] || { echo "local installation fixture path does not contain spaces" >&2 return 1 } source_copy="$fixture/ThothII source" operator_dir="$fixture/operator files" mkdir -p "$source_copy/deploy/pi" "$operator_dir" cp "$root/compose.yaml" "$source_copy/compose.yaml" cp "$root/deploy/compose.local.yaml" "$source_copy/deploy/compose.local.yaml" cp "$root/deploy/compose.git-ssh.yaml" "$source_copy/deploy/compose.git-ssh.yaml" cp "$root/deploy/pi/models.json" "$source_copy/deploy/pi/models.json" cp "$root/deploy/pi/settings.json" "$source_copy/deploy/pi/settings.json" write_private "$operator_dir/pi-auth.json" '{"zai":{"type":"api_key","key":"fixture-local-pi-key"}}' write_private "$operator_dir/thothii.secrets" 'THT_MODEL_API_KEY=fixture-local-model-key' write_private "$operator_dir/git-ssh-key" 'fixture-local-ssh-key' write_private "$operator_dir/git-known-hosts" 'fixture-local-known-hosts' write_private "$operator_dir/dwh-password" 'fixture-local-dwh-password' printf '%s\n' \ 'THT_WS_NORTH_STAR_RESEARCH_DWH_TRANSPORT=postgres_direct' \ 'THT_WS_NORTH_STAR_RESEARCH_DWH_PASSWORD_FILE=/run/secrets/north-star-research-dwh-password' \ >"$operator_dir/workspace-bindings.env" env_file="$source_copy/deploy/env/local.env" mkdir -p "$source_copy/deploy/env" printf '%s\n' \ 'THT_WORKSPACE_GIT_REMOTE=ssh://git@git.example.invalid/platform/thoth-workspaces.git' \ "PI_AUTH_FILE=$operator_dir/pi-auth.json" \ "THT_SECRETS_FILE=$operator_dir/thothii.secrets" \ "THT_WORKSPACE_BINDINGS_ENV_FILE=$operator_dir/workspace-bindings.env" \ "THT_WORKSPACE_GIT_SSH_KEY_FILE=$operator_dir/git-ssh-key" \ "THT_WORKSPACE_GIT_KNOWN_HOSTS_FILE=$operator_dir/git-known-hosts" \ "THT_WS_NORTH_STAR_RESEARCH_DWH_PASSWORD_SOURCE=$operator_dir/dwh-password" \ >"$env_file" connector_override="$operator_dir/connector-secrets.local.yaml" "$root/scripts/generate-connector-secrets-override.sh" \ --bindings-env "$operator_dir/workspace-bindings.env" \ --operator-env "$env_file" \ --output "$connector_override" >/dev/null copied_example="$fixture/thothii-installation.yaml" local contents contents="$(<"$example")" contents="${contents//\/absolute\/path\/to\/ThothII/$source_copy}" contents="${contents//\/absolute\/path\/to\/thothii-operator/$operator_dir}" printf '%s\n' "$contents" >"$copied_example" local profile project_directory descriptor_env value local -a overrides files profile="$(sed -n 's/^profile: \([^[:space:]]*\)$/\1/p' "$copied_example")" project_directory="$(sed -n 's/^projectDirectory: "\(.*\)"$/\1/p' "$copied_example")" descriptor_env="$(sed -n 's/^envFile: "\(.*\)"$/\1/p' "$copied_example")" while IFS= read -r value; do overrides+=("$value"); done < <(sed -n 's/^ - "\(.*\)"$/\1/p' "$copied_example") [[ "$profile" == local && "$project_directory" == "$source_copy" && "$descriptor_env" == "$env_file" ]] || { echo "local installation example does not resolve its required fields" >&2 return 1 } [[ "${#overrides[@]}" -eq 2 && "${overrides[1]}" == "$connector_override" ]] || { echo "local installation example does not select the expected optional overrides" >&2 return 1 } files=(-f "$project_directory/compose.yaml" -f "$project_directory/deploy/compose.$profile.yaml") for value in "${overrides[@]}"; do files+=(-f "$value"); done local rendered="$fixture/local-installation.json" "$root/scripts/compose-with-preflight.sh" --env-file "$descriptor_env" \ "${files[@]}" config --format json >"$rendered" node - "$rendered" <<'NODE' const fs = require("fs"); const config = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); if (Object.keys(config.services).sort().join(",") !== "core,frontend") { throw new Error("local installation example must render exactly core,frontend"); } const output = JSON.stringify(config); for (const secret of [ "fixture-local-pi-key", "fixture-local-model-key", "fixture-local-ssh-key", "fixture-local-known-hosts", "fixture-local-dwh-password", ]) { if (output.includes(secret)) throw new Error("local installation rendering exposed a fixture secret"); } NODE echo "local installation example rendered from path with spaces passed" } write_private() { local path="$1" value="$2" printf '%s\n' "$value" >"$path" chmod 0600 "$path" } verify_compose_fixtures() { local fixture connector_override profile rendered fixture="$(mktemp -d "${TMPDIR%/}/thoth-install-fixtures.XXXXXX")" trap 'rm -rf "$fixture"' RETURN mkdir -p "$fixture/data" "$fixture/pi-state" "$fixture/workspace-registry" write_private "$fixture/pi-auth.json" '{"zai":{"type":"api_key","key":"fixture-native-auth-key"}}' write_private "$fixture/thothii.secrets" 'THT_MODEL_API_KEY=fixture-model-api-key' write_private "$fixture/git-ssh-key" 'fixture-git-ssh-key' write_private "$fixture/git-known-hosts" 'fixture-git-known-hosts' write_private "$fixture/dwh-password" 'fixture-dwh-password' write_private "$fixture/vector-api-key" 'fixture-vector-api-key' write_private "$fixture/session-runtime-password" 'fixture-session-runtime-password' write_private "$fixture/session-migrator-password" 'fixture-session-migrator-password' write_private "$fixture/session-ca.pem" 'fixture-session-ca' cp "$root/deploy/workspaces/server-sessions.yaml.example" "$fixture/server-sessions.yaml" printf '%s\n' \ 'THT_WS_NORTH_STAR_RESEARCH_DWH_TRANSPORT=postgres_direct' \ 'THT_WS_NORTH_STAR_RESEARCH_DWH_PASSWORD_FILE=/run/secrets/north-star-research-dwh-password' \ 'THT_WS_NORTH_STAR_RESEARCH_VECTOR_TRANSPORT=rest_api' \ 'THT_WS_NORTH_STAR_RESEARCH_VECTOR_API_KEY_FILE=/run/secrets/north-star-research-vector-api-key' \ >"$fixture/workspace-bindings.env" printf '%s\n' \ 'THT_WORKSPACE_GIT_REMOTE=ssh://git@git.example.invalid/platform/thoth-workspaces.git' \ "PI_AUTH_FILE=$fixture/pi-auth.json" \ "THT_SECRETS_FILE=$fixture/thothii.secrets" \ "THT_WORKSPACE_BINDINGS_ENV_FILE=$fixture/workspace-bindings.env" \ "THT_WORKSPACE_GIT_SSH_KEY_FILE=$fixture/git-ssh-key" \ "THT_WORKSPACE_GIT_KNOWN_HOSTS_FILE=$fixture/git-known-hosts" \ "THT_WS_NORTH_STAR_RESEARCH_DWH_PASSWORD_SOURCE=$fixture/dwh-password" \ "THT_WS_NORTH_STAR_RESEARCH_VECTOR_API_KEY_SOURCE=$fixture/vector-api-key" \ "THT_DATA_ROOT=$fixture/data" \ "THT_PI_STATE_ROOT=$fixture/pi-state" \ "THT_WORKSPACE_REGISTRY_ROOT=$fixture/workspace-registry" \ "THT_SERVER_WORKSPACE_CONFIG=$fixture/server-sessions.yaml" \ 'THT_SESSION_DB_HOST=sessions.example.invalid' \ 'THT_SESSION_DB_NAME=thoth_sessions' \ 'THT_SESSION_RUNTIME_USER=thoth_sessions_app' \ 'THT_SESSION_MIGRATOR_USER=thoth_sessions_migrate' \ "THT_SESSION_RUNTIME_PASSWORD_SOURCE=$fixture/session-runtime-password" \ "THT_SESSION_MIGRATOR_PASSWORD_SOURCE=$fixture/session-migrator-password" \ "THT_SESSION_CA_SOURCE=$fixture/session-ca.pem" \ >"$fixture/operator.env" connector_override="$fixture/connector-secrets.local.yaml" "$root/scripts/generate-connector-secrets-override.sh" \ --bindings-env "$fixture/workspace-bindings.env" \ --operator-env "$fixture/operator.env" \ --output "$connector_override" >/dev/null for profile in local server; do rendered="$fixture/$profile.json" files=( -f "$root/compose.yaml" -f "$root/deploy/compose.$profile.yaml" ) if [[ "$profile" == server ]]; then files+=(-f "$root/deploy/compose.session-server.yaml.example") fi files+=( -f "$root/deploy/compose.git-ssh.yaml" -f "$connector_override" ) "$root/scripts/compose-with-preflight.sh" --env-file "$fixture/operator.env" \ "${files[@]}" config --format json >"$rendered" node - "$rendered" "$profile" <<'NODE' const fs = require("fs"); const [path, profile] = process.argv.slice(2); const config = JSON.parse(fs.readFileSync(path, "utf8")); if (Object.keys(config.services).sort().join(",") !== "core,frontend") { throw new Error(profile + ": mandatory stack must be exactly core,frontend"); } const core = config.services.core; for (const target of [ "/home/thoth/.pi/agent/auth.json", "/home/thoth/.pi/agent/models.json", "/home/thoth/.pi/agent/settings.json", ]) { if (!(core.volumes || []).some((mount) => mount.target === target && mount.read_only)) { throw new Error(profile + ": missing read-only Pi mount " + target); } } for (const [name, value] of Object.entries({ THT_WS_NORTH_STAR_RESEARCH_DWH_PASSWORD_FILE: "/run/secrets/north-star-research-dwh-password", THT_WS_NORTH_STAR_RESEARCH_VECTOR_API_KEY_FILE: "/run/secrets/north-star-research-vector-api-key", })) { if (core.environment?.[name] !== value) throw new Error(profile + ": missing binding " + name); } const secretTargets = new Set((core.secrets || []).map((secret) => secret.target)); for (const target of [ "thothii.secrets", "north-star-research-dwh-password", "north-star-research-vector-api-key", ]) { if (!secretTargets.has(target)) throw new Error(profile + ": missing secret target " + target); } if (profile === "server") { for (const target of ["session_runtime_password", "session_ca.pem"]) { if (!secretTargets.has(target)) throw new Error("server: missing session secret " + target); } } if ((config.services.frontend.secrets || []).length !== 0) { throw new Error(profile + ": frontend received a runtime secret"); } const rendered = JSON.stringify(config); for (const value of [ "fixture-native-auth-key", "fixture-model-api-key", "fixture-git-ssh-key", "fixture-git-known-hosts", "fixture-dwh-password", "fixture-vector-api-key", "fixture-session-runtime-password", "fixture-session-migrator-password", "fixture-session-ca", ]) { if (rendered.includes(value)) throw new Error(profile + ": rendered Compose leaked " + value); } NODE echo "canonical $profile base+override fixture passed" done printf 'THT_WS_EXAMPLE_DWH_PASSWORD_SOURCE=relative/secret\n' >"$fixture/unsafe.env" if verify_path_variable_values "$fixture/unsafe.env" >/dev/null 2>&1; then echo "relative secret-source fixture was accepted" >&2 return 1 fi echo "relative secret-source fixture rejected passed" } case "$mode" in --fixtures-only) [[ $# -eq 1 ]] || { echo "usage: $0 --fixtures-only" >&2; exit 2; } verify_local_guide verify_windows_line_endings_guide verify_pi_management_guide verify_local_installation_example verify_manual local verify_manual server verify_compose_fixtures ;; --profile) profile="${2:-}" [[ $# -eq 2 && "$profile" =~ ^(local|server)$ ]] \ || { echo "usage: $0 --profile {local|server}" >&2; exit 2; } if [[ "$profile" == local ]]; then verify_local_guide verify_windows_line_endings_guide verify_pi_management_guide verify_local_installation_example fi verify_manual "$profile" verify_compose_fixtures echo "== Run isolated workspace-registry bootstrap and recovery smoke ==" ( cd "$root" env -u WORKSPACE_GIT_REMOTE ./scripts/workspace-registry-smoke.sh ) echo "$profile installation documentation verification passed" ;; *) echo "usage: $0 --fixtures-only | --profile {local|server}" >&2 exit 2 ;; esac