Files
ThothII/scripts/verify-workspace-install-docs.sh
T

804 lines
34 KiB
Bash
Executable File

#!/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"
node - "$guide" <<'NODE'
const fs = require("fs");
const source = fs.readFileSync(process.argv[2], "utf8");
function section(name) {
const marker = `## ${name}`;
const start = source.indexOf(marker);
if (start < 0) throw new Error(`missing section: ${name}`);
const next = source.indexOf("\n## ", start + marker.length);
return source.slice(start, next < 0 ? source.length : next);
}
function blocks(name, language) {
const expression = new RegExp("```" + language + "\\n([\\s\\S]*?)```", "g");
return [...section(name).matchAll(expression)].map((match) => match[1]);
}
function requireTokens(label, text, tokens) {
for (const token of tokens) {
if (!text.includes(token)) throw new Error(`${label} lacks structural token: ${token}`);
}
}
function requirePattern(label, text, pattern) {
if (!pattern.test(text)) throw new Error(label);
}
let inCodeFence = false;
for (const line of source.split(/\n/)) {
if (line.trimStart().startsWith("```")) {
inCodeFence = !inCodeFence;
continue;
}
if (!line.includes("docker compose down --volumes")) continue;
const normalized = line.toLowerCase().replaceAll("*", "");
if (inCodeFence || !/(do not|never)/.test(normalized) || /^\s*(docker|&?\s*docker)/.test(normalized)) {
throw new Error("docker compose down --volumes must appear only in an explicit prose prohibition");
}
}
const setupPowerShell = blocks("Create the local operator files", "powershell").join("\n");
requireTokens("native PowerShell setup", setupPowerShell, [
"Copy-Item", "New-Item", "icacls.exe", "/inheritance:r", "/grant:r",
"WindowsIdentity", "deploy/env/local.env.example", "thothii-installation.yaml",
]);
const healthPowerShell = blocks("Start and verify", "powershell").join("\n");
requireTokens("native PowerShell health", healthPowerShell, [
"curl.exe --fail", "http://127.0.0.1:8080/health", "http://127.0.0.1:8787/health",
"pi doctor", "pi test",
]);
const updateShell = blocks("Update an installation", "sh").join("\n");
requireTokens("installation-aware source update", updateShell, [
"NEXT_PI_VERSION", "RUNNING_PI_VERSION", "--source build", "git rev-parse HEAD",
"pi status", "status", "doctor", "curl --fail", "set -euo pipefail",
"git status --porcelain --untracked-files=all", "USES_BASE_CORE", "thothii-core:local",
]);
if (/\|\|\s*true|;\s*true\b/.test(updateShell)) throw new Error("POSIX source update contains a failure-bypass command");
requirePattern("POSIX source update does not fail closed: source pull", updateShell,
/if ! git pull --ff-only; then abort_update/);
requirePattern("POSIX source update does not fail closed: installation status", updateShell,
/if ! INSTALLATION_STATUS="\$\("\$THTCTL" --installation "\$INSTALLATION" status\)"; then/);
requirePattern("POSIX source update does not fail closed: Pi status", updateShell,
/if ! RUNNING_PI_VERSION="\$\("\$THTCTL" --installation "\$INSTALLATION" pi status\)"; then/);
requirePattern("POSIX source update does not fail closed: local build", updateShell,
/if ! bash scripts\/build-local\.sh; then/);
requirePattern("POSIX source update does not fail closed: thothctl build", updateShell,
/if ! bash scripts\/build-thothctl\.sh; then/);
requirePattern("POSIX source update lacks the same-version/no-selector path", updateShell,
/if \[\[ "\$NEXT_PI_VERSION" == "\$RUNNING_PI_VERSION" \]\]; then[\s\S]*"\$USES_BASE_CORE" == true[\s\S]*TRANSACTIONAL_PI_UPDATE=false/);
for (const [label, pattern] of [
["installation start", /if ! "\$THTCTL" --installation "\$INSTALLATION" start; then/],
["frontend health", /if ! curl --fail http:\/\/127\.0\.0\.1:8080\/health; then/],
["core health", /if ! curl --fail http:\/\/127\.0\.0\.1:8787\/health; then/],
["final status", /if ! FINAL_STATUS="\$\("\$THTCTL" --installation "\$INSTALLATION" status\)"; then/],
["final Pi status", /if ! FINAL_PI_STATUS="\$\("\$THTCTL" --installation "\$INSTALLATION" pi status\)"; then/],
["final doctor", /if ! "\$THTCTL" --installation "\$INSTALLATION" doctor; then/],
]) requirePattern(`POSIX source update does not fail closed: ${label}`, updateShell, pattern);
const provenance = updateShell.indexOf("printf 'Built source revision:");
if (provenance < updateShell.lastIndexOf("require_clean_source") ||
provenance < updateShell.indexOf('abort_update "final doctor failed"')) {
throw new Error("POSIX source revision provenance is printed before final checks");
}
const updatePowerShell = blocks("Update an installation", "powershell").join("\n");
requireTokens("native PowerShell source update", updatePowerShell, [
"$NextPiVersion", "$RunningPiVersion", "--source build", "git rev-parse HEAD",
"pi status", "status", "doctor", "curl.exe --fail", "throw", "$ErrorActionPreference = 'Stop'",
"git status --porcelain --untracked-files=all", "$UsesBaseCore", "thothii-core:local",
"$TransactionalPiUpdate = $false",
]);
for (const [command, step] of [
["git pull --ff-only", "source pull"],
["$InstallationStatus = @(& $THTCTL --installation $INSTALLATION status)", "installation status"],
["$RunningPiStatus = (& $THTCTL --installation $INSTALLATION pi status)", "Pi status"],
["powershell -ExecutionPolicy Bypass -File scripts/build-local.ps1", "local image build"],
["& \"C:\\Program Files\\Git\\bin\\bash.exe\" scripts/build-thothctl.sh", "thothctl build"],
["curl.exe --fail --silent --show-error http://127.0.0.1:8080/health", "frontend health check"],
["curl.exe --fail --silent --show-error http://127.0.0.1:8787/health", "core health check"],
["$FinalPiStatus = (& $THTCTL --installation $INSTALLATION pi status)", "final Pi status"],
["& $THTCTL --installation $INSTALLATION doctor", "final doctor"],
]) {
const commandAt = updatePowerShell.indexOf(command);
const checkAt = updatePowerShell.indexOf(`Assert-NativeSuccess '${step}'`, commandAt);
if (commandAt < 0 || checkAt < commandAt || checkAt - commandAt > 220) {
throw new Error(`PowerShell source update does not propagate failure: ${step}`);
}
}
requirePattern("PowerShell source update lacks the same-version/no-selector path", updatePowerShell,
/if \(\$NextPiVersion -eq \$RunningPiVersion\) \{[\s\S]*-not \$UsesBaseCore[\s\S]*\$TransactionalPiUpdate = \$false/);
const backupPowerShell = blocks("Back up and restore", "powershell").join("\n");
requireTokens("native PowerShell backup/restore", backupPowerShell, [
"$BackupDir", "$Volume", "-czf", "$TargetVolume", "$Archive", "Split-Path -Parent",
"Split-Path -Leaf", "test -z", "-xzf",
]);
for (const block of [setupPowerShell, healthPowerShell, updatePowerShell, backupPowerShell]) {
if (/\$\((dirname|basename)\b|\bmkdir -p\b|\bchmod\s+[0-7]/.test(block)) {
throw new Error("native PowerShell block contains a POSIX-only command sequence");
}
}
NODE
local update_fixture update_script fake_bin calls output status
update_fixture="$(mktemp -d "${TMPDIR%/}/thoth-source-update.XXXXXX")"
trap 'rm -rf "$update_fixture"' RETURN
update_script="$update_fixture/update.sh"
awk '
/^## Update an installation$/ { in_section=1; next }
in_section && /^```sh$/ { in_code=1; next }
in_code && /^```$/ { exit }
in_code { print }
' "$guide" >"$update_script"
chmod 0700 "$update_script"
mkdir -p "$update_fixture/project/docker" "$update_fixture/project/scripts" "$update_fixture/bin"
printf 'ARG PI_VERSION=0.80.3\n' >"$update_fixture/project/docker/core.Dockerfile"
printf '%s\n' \
'#!/bin/sh' \
'printf "git %s\n" "$*" >>"$CALLS"' \
'case "$1" in' \
' status) if [ "$FAIL_STEP" = dirty ]; then printf "?? untracked-build-context\n"; fi ;;' \
' pull) [ "$FAIL_STEP" != pull ] || exit 9 ;;' \
' rev-parse) printf "0123456789abcdef\n" ;;' \
'esac' \
'exit 0' >"$update_fixture/bin/git"
printf '%s\n' \
'#!/bin/sh' \
'printf "bash %s\n" "$*" >>"$CALLS"' \
'if [ "$1" = scripts/build-local.sh ] && [ "$FAIL_STEP" = build ]; then exit 8; fi' \
'exit 0' >"$update_fixture/bin/bash"
printf '%s\n' \
'#!/bin/sh' \
'printf "thothctl %s\n" "$*" >>"$CALLS"' \
'case " $* " in' \
' *" pi status "*) [ "$FAIL_STEP" != status ] || exit 7; printf "Pi version: 0.80.3\n" ;;' \
' *" status "*) printf "[{\"Service\":\"core\",\"Image\":\"thothii-core:local\"}]\n" ;;' \
'esac' \
'exit 0' >"$update_fixture/bin/thothctl"
printf '%s\n' \
'#!/bin/sh' \
'printf "curl %s\n" "$*" >>"$CALLS"' \
'exit 0' >"$update_fixture/bin/curl"
chmod 0700 "$update_fixture/bin/git" "$update_fixture/bin/bash" \
"$update_fixture/bin/thothctl" "$update_fixture/bin/curl"
for fixture_step in clean dirty pull status build; do
calls="$update_fixture/calls-$fixture_step"
output="$update_fixture/output-$fixture_step"
: >"$calls"
set +e
(
cd "$update_fixture/project"
env PATH="$update_fixture/bin:$PATH" CALLS="$calls" FAIL_STEP="$fixture_step" \
THTCTL="$update_fixture/bin/thothctl" INSTALLATION="$update_fixture/installation.yaml" \
/bin/bash "$update_script"
) >"$output" 2>&1
status=$?
set -e
if [[ "$fixture_step" == clean ]]; then
[[ $status -eq 0 ]] || { echo "same-version/no-selector source fixture failed" >&2; return 1; }
grep -Fq 'Built source revision: 0123456789abcdef' "$output" || {
echo "successful source fixture did not report revision provenance" >&2; return 1;
}
if grep -Fq ' pi update ' "$calls"; then
echo "same-version/no-selector source fixture incorrectly invoked pi update" >&2
return 1
fi
grep -Fq 'bash scripts/build-local.sh' "$calls" || return 1
grep -Fq 'thothctl --installation ' "$calls" || return 1
else
[[ $status -ne 0 ]] || { echo "$fixture_step source failure fixture was accepted" >&2; return 1; }
if grep -Fq 'Built source revision:' "$output"; then
echo "$fixture_step source failure fixture claimed revision provenance" >&2
return 1
fi
fi
done
echo "source update fail-closed semantics passed"
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 checkout-index --all --force" \
"git diff --cached --check" \
"reclone"
node - "$guide" <<'NODE'
const fs = require("fs");
const source = fs.readFileSync(process.argv[2], "utf8");
const lines = source.split(/\n/);
const sectionStart = source.indexOf("## Recover an existing CRLF clone");
const recovery = source.slice(sectionStart);
const shell = [...recovery.matchAll(/```sh\n([\s\S]*?)```/g)].map((match) => match[1]).join("\n");
const powershell = [...recovery.matchAll(/```powershell\n([\s\S]*?)```/g)].map((match) => match[1]).join("\n");
const commands = [
"git add --renormalize .",
"git checkout-index --all --force --prefix=",
"bash scripts/verify-line-endings.sh",
];
let prior = -1;
for (const command of commands) {
const index = lines.findIndex((line, candidate) => candidate > prior && line.trim().includes(command));
if (index < 0) throw new Error(`CRLF recovery lacks ordered command: ${command}`);
prior = index;
}
for (const token of [
"set -euo pipefail", "validate_index_export", "validate_worktree_modes", "rewrite_index_entry", "git ls-files -s -z",
"100644", "100755", "120000", "readlink", "ln -s", "if ! git checkout-index",
]) {
if (!shell.includes(token)) throw new Error(`POSIX CRLF repair lacks fail-closed semantic: ${token}`);
}
if (/\|\|\s*true|;\s*true\b/.test(shell)) throw new Error("POSIX CRLF repair contains a failure-bypass command");
const exportAt = shell.indexOf("if ! git checkout-index");
const validationAt = shell.indexOf("if ! validate_index_export", exportAt);
const exportedBytesAt = shell.indexOf('if ! bash scripts/verify-line-endings.sh "$REPAIR_DIR"', validationAt);
const rewriteAt = shell.indexOf("if ! git ls-files -s -z", exportedBytesAt);
const finalModesAt = shell.indexOf("if ! validate_worktree_modes; then", rewriteAt);
const finalAt = shell.indexOf("if ! bash scripts/verify-line-endings.sh; then", finalModesAt);
if ([exportAt, validationAt, exportedBytesAt, rewriteAt, finalModesAt, finalAt].some((index) => index < 0) ||
!(exportAt < validationAt && validationAt < exportedBytesAt && exportedBytesAt < rewriteAt && rewriteAt < finalModesAt && finalModesAt < finalAt)) {
throw new Error("POSIX CRLF repair does not prove a complete export before destructive rewrite");
}
for (const token of [
"$ErrorActionPreference = 'Stop'", "Assert-NativeSuccess 'index renormalization'",
"Assert-NativeSuccess 'normalized index check'", "Assert-NativeSuccess 'index export'",
"Assert-NativeSuccess 'index inventory'", "100644", "100755", "120000", "SymbolicLink",
"-ErrorAction Stop", "$WorktreeItem", "Assert-NativeSuccess 'repaired worktree LF verification'",
]) {
if (!powershell.includes(token)) throw new Error(`PowerShell CRLF repair lacks failure propagation: ${token}`);
}
const warningPattern = /WARNING[^\n]*destructive[^\n]*(backup|commit)/i;
const powerShellWarningAt = powershell.indexOf("# WARNING: destructive copy");
const powerShellRewriteAt = powershell.indexOf("foreach ($Entry in $IndexEntries)", powerShellWarningAt);
if (!warningPattern.test(shell.slice(Math.max(0, rewriteAt - 180), rewriteAt)) ||
powerShellRewriteAt < 0 ||
!warningPattern.test(powershell.slice(Math.max(0, powerShellRewriteAt - 180), powerShellRewriteAt))) {
throw new Error("worktree rewrite lacks an immediate destructive warning requiring backup/commit");
}
NODE
local repair_root repair_script real_git partial_repo clean_repo partial_output repair_status
repair_root="$(mktemp -d "${TMPDIR%/}/thoth-crlf-repair.XXXXXX")"
trap 'rm -rf "$repair_root"' RETURN
repair_script="$repair_root/repair.sh"
awk '
/^## Recover an existing CRLF clone$/ { in_section=1; next }
in_section && /^```sh$/ { in_code=1; next }
in_code && /^```$/ { exit }
in_code { print }
' "$guide" >"$repair_script"
chmod 0700 "$repair_script"
prepare_crlf_fixture() {
local repository="$1"
mkdir -p "$repository/scripts"
git -C "$repository" init -q
printf '*.sh text eol=lf\n' >"$repository/.gitattributes"
printf '#!/bin/sh\nexit 0\n' >"$repository/repair.sh"
printf 'target\n' >"$repository/target.txt"
cp "$root/scripts/verify-line-endings.sh" "$repository/scripts/verify-line-endings.sh"
ln -s target.txt "$repository/workspace-link"
git -C "$repository" add .gitattributes repair.sh target.txt workspace-link \
scripts/verify-line-endings.sh 2>/dev/null
printf '#!/bin/sh\r\nexit 0\r\n' >"$repository/repair.sh"
}
partial_repo="$repair_root/partial/worktree"
mkdir -p "$partial_repo" "$repair_root/partial/bin"
prepare_crlf_fixture "$partial_repo"
real_git="$(command -v git)"
printf '%s\n' \
'#!/bin/sh' \
'"$REAL_GIT" "$@"' \
'status=$?' \
'if [ $status -eq 0 ] && [ "$1" = checkout-index ]; then rm -f "$PARTIAL_EXPORT_PATH"; fi' \
'exit $status' >"$repair_root/partial/bin/git"
chmod 0700 "$repair_root/partial/bin/git"
partial_output="$repair_root/partial/output"
set +e
(
cd "$partial_repo"
env PATH="$repair_root/partial/bin:$PATH" REAL_GIT="$real_git" \
PARTIAL_EXPORT_PATH="$repair_root/partial/ThothII-lf-repair/repair.sh" \
/bin/bash "$repair_script"
) >"$partial_output" 2>&1
repair_status=$?
set -e
[[ $repair_status -ne 0 ]] || { echo "partial CRLF export fixture was accepted" >&2; return 1; }
LC_ALL=C grep -q $'\r' "$partial_repo/repair.sh" || {
echo "partial CRLF export fixture rewrote bytes before complete validation" >&2; return 1;
}
[[ -L "$partial_repo/workspace-link" && "$(readlink "$partial_repo/workspace-link")" == target.txt ]] || {
echo "partial CRLF export fixture changed the tracked symlink" >&2; return 1;
}
clean_repo="$repair_root/clean/worktree"
mkdir -p "$clean_repo"
prepare_crlf_fixture "$clean_repo"
(cd "$clean_repo" && /bin/bash "$repair_script") >/dev/null
"$root/scripts/verify-line-endings.sh" "$clean_repo"
[[ -L "$clean_repo/workspace-link" && "$(readlink "$clean_repo/workspace-link")" == target.txt ]] || {
echo "successful CRLF repair did not preserve the mode-120000 symlink" >&2; return 1;
}
echo "CRLF recovery rewrites bytes and preserves mode-120000 symlinks passed"
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" \
"Raw Compose access is unsupported" \
"no browser shell" \
"does not mount the Docker socket"
node - "$guide" <<'NODE'
const fs = require("fs");
const source = fs.readFileSync(process.argv[2], "utf8");
if (/docker\s+compose(?:.|\n){0,160}\bexec\b(?:.|\n){0,80}\bcore\b(?:.|\n){0,80}\bpi\b/i.test(source)) {
throw new Error("raw non-installation-aware Compose Pi access is forbidden");
}
const marker = "## Direct support access";
const start = source.indexOf(marker);
const support = start < 0 ? "" : source.slice(start, source.indexOf("\n## ", start + marker.length) < 0
? source.length : source.indexOf("\n## ", start + marker.length));
for (const token of ["unsupported", "thothctl", "pi status", "pi doctor", "pi test", "pi logs"]) {
if (!support.toLowerCase().includes(token.toLowerCase())) {
throw new Error(`direct support section lacks installation-aware diagnostic: ${token}`);
}
}
NODE
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