319 lines
13 KiB
Bash
Executable File
319 lines
13 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Validate the installation manuals without reading an operator environment or production remote.
|
|
set -euo pipefail
|
|
|
|
root="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|
profile="${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"
|
|
return 0
|
|
}
|
|
|
|
verify_server_public_contract() {
|
|
local server_example="$root/docs/install/examples/server-compose.workspace-registry.yaml"
|
|
for expected in \
|
|
'THT_SESSION_STORAGE: postgres' \
|
|
'THT_SESSION_RUNTIME_PASSWORD_FILE: /run/secrets/session_runtime_password' \
|
|
'THT_SESSION_DB_SSLMODE: ${THT_SESSION_DB_SSLMODE:-verify-full}' \
|
|
'THT_SESSION_DB_SSLROOTCERT: /run/secrets/session_ca.pem' \
|
|
'session_runtime_password:' \
|
|
'session_ca:'; do
|
|
grep -Fq "$expected" "$server_example" || {
|
|
echo "server Compose example lacks required public PostgreSQL/TLS contract: $expected" >&2
|
|
return 1
|
|
}
|
|
done
|
|
}
|
|
|
|
compose_fixture() {
|
|
local name="$1" directory="$2"; shift 2
|
|
(
|
|
cd "$directory"
|
|
docker compose --env-file .env "$@" config --quiet
|
|
)
|
|
echo "$name passed"
|
|
}
|
|
|
|
prepare_binding_fixture() {
|
|
local directory="$1"
|
|
cp "$root/docs/install/examples/workspace-bindings.env.example" "$directory/workspace-bindings.env"
|
|
printf 'THT_WORKSPACE_BINDINGS_ENV_FILE=%s\n' "$directory/workspace-bindings.env" >>"$directory/.env"
|
|
}
|
|
|
|
verify_connector_fixture() {
|
|
local directory="$1" rendered project
|
|
project="thoth-install-connector-fixture-$$"
|
|
rendered="$(
|
|
cd "$directory"
|
|
docker compose --env-file .env -f compose.workspace-registry.yaml -f connector-secrets.yaml config
|
|
)"
|
|
for expected in \
|
|
'THT_WS_PSD_CLINICAL_DWH_TRANSPORT: postgres_direct' \
|
|
'THT_WS_PSD_CLINICAL_DWH_PASSWORD_FILE: /run/secrets/psd-clinical-dwh-password' \
|
|
'THT_WS_PSD_CLINICAL_VECTOR_PASSWORD_FILE: /run/secrets/psd-clinical-vector-password' \
|
|
'target: psd-clinical-dwh-password' \
|
|
'target: psd-clinical-vector-password'; do
|
|
grep -Fq "$expected" <<<"$rendered" || {
|
|
echo "connector fixture does not give core required binding or secret target: $expected" >&2
|
|
return 1
|
|
}
|
|
done
|
|
echo "copied connector binding/secret fixture passed"
|
|
if ! (
|
|
cd "$directory"
|
|
docker compose --project-name "$project" --env-file .env -f compose.workspace-registry.yaml -f connector-secrets.yaml \
|
|
run --rm --no-deps --build --entrypoint sh core -c '
|
|
test "$THT_WS_PSD_CLINICAL_DWH_TRANSPORT" = postgres_direct
|
|
test "$THT_WS_PSD_CLINICAL_DWH_PASSWORD_FILE" = /run/secrets/psd-clinical-dwh-password
|
|
test "$THT_WS_PSD_CLINICAL_VECTOR_PASSWORD_FILE" = /run/secrets/psd-clinical-vector-password
|
|
test -f "$THT_WS_PSD_CLINICAL_DWH_PASSWORD_FILE"
|
|
test -f "$THT_WS_PSD_CLINICAL_VECTOR_PASSWORD_FILE"
|
|
'
|
|
); then
|
|
(
|
|
cd "$directory"
|
|
docker compose --project-name "$project" --env-file .env -f compose.workspace-registry.yaml -f connector-secrets.yaml \
|
|
down --volumes --remove-orphans
|
|
) || true
|
|
return 1
|
|
fi
|
|
(
|
|
cd "$directory"
|
|
docker compose --project-name "$project" --env-file .env -f compose.workspace-registry.yaml -f connector-secrets.yaml \
|
|
down --volumes --remove-orphans
|
|
)
|
|
echo "core process sees connector bindings and secret files passed"
|
|
}
|
|
|
|
verify_copied_operator_fixtures() {
|
|
local fixture_root local_dir server_dir https_dir ssh_dir connector_dir
|
|
fixture_root="$(mktemp -d "${TMPDIR:-/tmp}/thoth-install-fixtures.XXXXXX")"
|
|
trap 'rm -rf "$fixture_root"' RETURN
|
|
local_dir="$fixture_root/local"; server_dir="$fixture_root/server"
|
|
https_dir="$fixture_root/https"; ssh_dir="$fixture_root/ssh"; connector_dir="$fixture_root/connector"
|
|
mkdir -p "$local_dir" "$server_dir" "$https_dir" "$ssh_dir" "$connector_dir"
|
|
|
|
cp "$root/docs/install/examples/local-compose.workspace-registry.yaml" "$local_dir/compose.workspace-registry.yaml"
|
|
printf 'THT_SOURCE_ROOT=%s\n' "$root" >"$local_dir/.env"
|
|
prepare_binding_fixture "$local_dir"
|
|
compose_fixture "copied local base fixture" "$local_dir" -f compose.workspace-registry.yaml
|
|
|
|
cp "$root/docs/install/examples/server-compose.workspace-registry.yaml" "$server_dir/compose.workspace-registry.yaml"
|
|
cp "$root/deploy/workspaces/server-sessions.yaml.example" "$server_dir/server-sessions.yaml"
|
|
: >"$server_dir/session-runtime-password"; : >"$server_dir/session-ca.pem"
|
|
printf '%s\n' \
|
|
"THT_SOURCE_ROOT=$root" \
|
|
"THT_SERVER_WORKSPACE_CONFIG=$server_dir/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_RUNTIME_PASSWORD_SOURCE=$server_dir/session-runtime-password" \
|
|
"THT_SESSION_CA_SOURCE=$server_dir/session-ca.pem" >"$server_dir/.env"
|
|
prepare_binding_fixture "$server_dir"
|
|
compose_fixture "copied server PostgreSQL/TLS fixture" "$server_dir" -f compose.workspace-registry.yaml
|
|
|
|
cp "$root/docs/install/examples/local-compose.workspace-registry.yaml" "$https_dir/compose.workspace-registry.yaml"
|
|
cp "$root/docs/install/examples/git-https.workspace-registry.yaml" "$https_dir/git-https.yaml"
|
|
: >"$https_dir/git-credentials"; : >"$https_dir/git-ca.pem"
|
|
printf '%s\n' \
|
|
"THT_SOURCE_ROOT=$root" \
|
|
"THT_WORKSPACE_GIT_CREDENTIALS_FILE=$https_dir/git-credentials" \
|
|
"THT_WORKSPACE_GIT_CA_FILE=$https_dir/git-ca.pem" >"$https_dir/.env"
|
|
prepare_binding_fixture "$https_dir"
|
|
compose_fixture "copied HTTPS Git override fixture" "$https_dir" -f compose.workspace-registry.yaml -f git-https.yaml
|
|
|
|
cp "$root/docs/install/examples/local-compose.workspace-registry.yaml" "$ssh_dir/compose.workspace-registry.yaml"
|
|
cp "$root/docs/install/examples/git-ssh.workspace-registry.yaml" "$ssh_dir/git-ssh.yaml"
|
|
: >"$ssh_dir/git-ssh-key"; : >"$ssh_dir/git-known-hosts"
|
|
printf '%s\n' \
|
|
"THT_SOURCE_ROOT=$root" \
|
|
"THT_WORKSPACE_GIT_SSH_KEY_FILE=$ssh_dir/git-ssh-key" \
|
|
"THT_WORKSPACE_GIT_KNOWN_HOSTS_FILE=$ssh_dir/git-known-hosts" >"$ssh_dir/.env"
|
|
prepare_binding_fixture "$ssh_dir"
|
|
compose_fixture "copied SSH Git override fixture" "$ssh_dir" -f compose.workspace-registry.yaml -f git-ssh.yaml
|
|
|
|
cp "$root/docs/install/examples/local-compose.workspace-registry.yaml" "$connector_dir/compose.workspace-registry.yaml"
|
|
cp "$root/docs/install/examples/connector-secrets.workspace-registry.yaml" "$connector_dir/connector-secrets.yaml"
|
|
: >"$connector_dir/dwh-password"; : >"$connector_dir/vector-password"
|
|
printf '%s\n' \
|
|
"THT_SOURCE_ROOT=$root" \
|
|
"THT_WS_PSD_CLINICAL_DWH_PASSWORD_SOURCE=$connector_dir/dwh-password" \
|
|
"THT_WS_PSD_CLINICAL_VECTOR_PASSWORD_SOURCE=$connector_dir/vector-password" >"$connector_dir/.env"
|
|
prepare_binding_fixture "$connector_dir"
|
|
verify_connector_fixture "$connector_dir"
|
|
|
|
printf 'THT_WS_EXAMPLE_DWH_PASSWORD_FILE=not-a-path\n' >"$fixture_root/non-path-secret.env"
|
|
if verify_path_variable_values "$fixture_root/non-path-secret.env" >/dev/null 2>&1; then
|
|
echo "non-path secret-file fixture was accepted" >&2
|
|
return 1
|
|
fi
|
|
echo "non-path secret-file fixture rejected passed"
|
|
|
|
printf 'THT_WS_EXAMPLE_DWH_PASSWORD_SOURCE=literal-value\n' >"$fixture_root/literal-source.env"
|
|
if verify_path_variable_values "$fixture_root/literal-source.env" >/dev/null 2>&1; then
|
|
echo "literal secret-source fixture was accepted" >&2
|
|
return 1
|
|
fi
|
|
echo "literal secret-source fixture rejected passed"
|
|
|
|
printf 'THT_WS_EXAMPLE_DWH_PASSWORD_SOURCE=installation-secrets/password\n' >"$fixture_root/relative-source.env"
|
|
if verify_path_variable_values "$fixture_root/relative-source.env" >/dev/null 2>&1; then
|
|
echo "relative secret-source fixture was accepted" >&2
|
|
return 1
|
|
fi
|
|
echo "relative secret-source fixture rejected passed"
|
|
|
|
printf 'THT_WS_EXAMPLE_DWH_PASSWORD_SOURCE=/srv/thothii/secrets/../password\n' >"$fixture_root/non-normalized-source.env"
|
|
if verify_path_variable_values "$fixture_root/non-normalized-source.env" >/dev/null 2>&1; then
|
|
echo "non-normalized secret-source fixture was accepted" >&2
|
|
return 1
|
|
fi
|
|
echo "non-normalized secret-source fixture rejected passed"
|
|
}
|
|
|
|
case "$profile" in
|
|
--fixtures-only)
|
|
[[ $# -eq 1 ]] || { echo "usage: $0 --fixtures-only" >&2; exit 2; }
|
|
verify_copied_operator_fixtures
|
|
exit 0
|
|
;;
|
|
--profile)
|
|
profile="${2:-}"
|
|
[[ $# -eq 2 ]] || { echo "usage: $0 --profile {local|server}" >&2; exit 2; }
|
|
;;
|
|
*)
|
|
echo "usage: $0 --profile {local|server}" >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
|
|
case "$profile" in
|
|
local)
|
|
manual="$root/docs/install/local-workspace-registry.md"
|
|
example="$root/docs/install/examples/local-compose.workspace-registry.yaml"
|
|
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"
|
|
)
|
|
;;
|
|
server)
|
|
manual="$root/docs/install/server-workspace-registry.md"
|
|
example="$root/docs/install/examples/server-compose.workspace-registry.yaml"
|
|
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"
|
|
)
|
|
;;
|
|
*)
|
|
echo "unknown documentation profile: $profile" >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
|
|
[[ -f "$manual" ]] || { echo "missing $profile installation manual: $manual" >&2; exit 1; }
|
|
[[ -f "$example" ]] || { echo "missing $profile Compose example: $example" >&2; exit 1; }
|
|
|
|
for heading in "${headings[@]}"; do
|
|
grep -Fqx "## $heading" "$manual" >/dev/null || {
|
|
echo "missing required heading in $profile manual: $heading" >&2
|
|
exit 1
|
|
}
|
|
done
|
|
|
|
grep -Fq "$(basename "$example")" "$manual" || {
|
|
echo "the $profile manual does not reference its Compose example" >&2
|
|
exit 1
|
|
}
|
|
|
|
# Values for secret-bearing variables must be paths. These patterns catch common accidental
|
|
# credentials while allowing declarative *_FILE bindings and explicitly empty assignments.
|
|
if grep -Ein '(^|[[:space:]])(password|api[_-]?key|token|secret)[[:space:]]*[:=][[:space:]]*[^[:space:]#]' \
|
|
"$manual" "$example" >/dev/null; then
|
|
echo "installation documentation contains a secret literal" >&2
|
|
exit 1
|
|
fi
|
|
verify_path_variable_values "$manual"
|
|
verify_path_variable_values "$example"
|
|
verify_path_variable_values "$root/docs/install/examples/git-https.workspace-registry.yaml"
|
|
verify_path_variable_values "$root/docs/install/examples/git-ssh.workspace-registry.yaml"
|
|
verify_path_variable_values "$root/docs/install/examples/workspace-bindings.env.example"
|
|
verify_path_variable_values "$root/docs/install/examples/connector-secrets.workspace-registry.yaml"
|
|
verify_server_public_contract
|
|
|
|
commands="$(mktemp "${TMPDIR:-/tmp}/thoth-install-docs.XXXXXX")"
|
|
trap 'rm -f "$commands"' EXIT HUP INT TERM
|
|
|
|
# A runnable documentation command is a sh fence immediately following this marker. Commands
|
|
# outside the marker are explanatory/operator commands and are deliberately never executed here.
|
|
awk '
|
|
/^<!--[[:space:]]*verify:command[[:space:]]*-->[[:space:]]*$/ { marked=1; next }
|
|
marked && /^```(sh|bash|shell)[[:space:]]*$/ { in_fence=1; marked=0; seen=1; next }
|
|
in_fence && /^```[[:space:]]*$/ { in_fence=0; next }
|
|
in_fence { print }
|
|
' "$manual" >"$commands"
|
|
|
|
[[ -s "$commands" ]] || { echo "no marked runnable commands in $profile manual" >&2; exit 1; }
|
|
|
|
echo "== Validate $profile documented Compose example =="
|
|
(
|
|
cd "$root"
|
|
bash "$commands"
|
|
)
|
|
|
|
echo "== Run isolated workspace-registry bootstrap and recovery smoke =="
|
|
(
|
|
cd "$root"
|
|
env -u WORKSPACE_GIT_REMOTE ./scripts/workspace-registry-smoke.sh
|
|
)
|
|
|
|
echo "== Validate copied operator fixtures and optional Git transports =="
|
|
verify_copied_operator_fixtures
|
|
|
|
echo "$profile installation documentation verification passed"
|