#!/usr/bin/env bash # Exercises the registry through an isolated Compose project. An optional WORKSPACE_GIT_REMOTE # is contacted read-only as a connectivity preflight; all pull/fallback mutations target a fresh # temporary bare repository so this smoke test can never alter an operator's shared registry. set -euo pipefail root="$(cd "$(dirname "$0")/.." && pwd -P)" tmp="$(mktemp -d "${TMPDIR:-/tmp}/thoth-workspace-registry-smoke.XXXXXX")" tmp="$(cd "$tmp" && pwd -P)" tmp_slug="$(basename "$tmp" | tr '[:upper:]._' '[:lower:]--' | tr -cd 'a-z0-9-')" project="thoth-workspace-registry-smoke-${tmp_slug}-$$" image="thothii-workspace-registry-smoke:${project}" remote="$tmp/remote.git" seed="$tmp/seed" branch="workspace-registry-smoke" core_remote="/fixtures/remote.git" cleanup_smoke_image() { local inspect_status listed list_status docker image inspect --format '{{.Id}}' "$image" >/dev/null 2>&1 inspect_status=$? if [[ "$inspect_status" -eq 0 ]]; then docker image rm -f "$image" >/dev/null 2>&1 return $? fi listed="$(docker image ls --quiet --no-trunc "$image" 2>/dev/null)" list_status=$? [[ "$list_status" -eq 0 && -z "$listed" ]] } workspace_registry_smoke_leftovers() { local output status=0 if output="$(docker ps -a --filter "label=com.docker.compose.project=$project" -q)"; then printf '%s\n' "$output" else status=1 fi if output="$(docker volume ls --filter "label=com.docker.compose.project=$project" -q)"; then printf '%s\n' "$output" else status=1 fi if output="$(docker network ls --filter "label=com.docker.compose.project=$project" -q)"; then printf '%s\n' "$output" else status=1 fi if output="$(docker image inspect --format '{{.Id}}' "$image" 2>/dev/null)"; then printf '%s\n' "$output" elif output="$(docker image ls --quiet --no-trunc "$image" 2>/dev/null)"; then printf '%s\n' "$output" else status=1 fi return "$status" } cleanup() { local body_status=$? local down_status image_status enumeration_status rm_status=0 cleanup_incomplete=0 final_status local leftovers trap - EXIT HUP INT TERM set +e compose down --volumes --remove-orphans >/dev/null 2>&1 down_status=$? cleanup_smoke_image image_status=$? leftovers="$(workspace_registry_smoke_leftovers)" enumeration_status=$? leftovers="$(printf '%s\n' "$leftovers" | sed '/^$/d')" if [[ "$down_status" -ne 0 || "$image_status" -ne 0 || "$enumeration_status" -ne 0 || -n "$leftovers" ]]; then cleanup_incomplete=1 else rm -rf "$tmp" rm_status=$? [[ "$rm_status" -eq 0 ]] || cleanup_incomplete=1 fi if [[ "$cleanup_incomplete" -ne 0 ]]; then echo "workspace registry cleanup incomplete: compose down status=$down_status, image cleanup status=$image_status, enumeration status=$enumeration_status, temp cleanup status=$rm_status." >&2 if [[ -n "$leftovers" ]]; then echo "workspace registry cleanup left owned Docker resources:" >&2 printf '%s\n' "$leftovers" >&2 fi echo "workspace registry smoke recovery path retained: $tmp" >&2 if [[ "$body_status" -ne 0 ]]; then final_status=$body_status else final_status=1 fi else echo "workspace registry cleanup proof: no compose containers, volumes, networks, image, or temporary path remain for $project." final_status=$body_status fi exit "$final_status" } compose() { SMOKE_ROOT="$root" \ SMOKE_IMAGE="$image" \ SMOKE_REMOTE="$remote" \ SMOKE_BRANCH="$branch" \ SMOKE_CORE_REMOTE="$core_remote" \ docker compose --project-name "$project" -f - "$@" <<'COMPOSE_YAML' services: core: build: context: "${SMOKE_ROOT:?}" dockerfile: docker/core.Dockerfile image: "${SMOKE_IMAGE:?}" environment: HOST: 0.0.0.0 PORT: "8787" AUTH_MODE: none THT_HARNESS_DIR: /app/harness THT_BIN: /opt/venv/bin/tht SETTINGS_FILE: /tmp/settings.json THT_WORKSPACE_REGISTRY_ROOT: /data/workspace-registry THT_WORKSPACE_GIT_REMOTE: "${SMOKE_CORE_REMOTE:?}" THT_WORKSPACE_GIT_BRANCH: "${SMOKE_BRANCH:?}" THT_WORKSPACE_INSTALLATION_ID: smoke THT_WORKSPACE_SECRET_ROOTS: /run/secrets volumes: - type: volume source: workspace-registry target: /data/workspace-registry - type: bind source: "${SMOKE_REMOTE:?}" target: /fixtures/remote.git read_only: true volumes: workspace-registry: {} COMPOSE_YAML } workspace_registry_smoke_self_test_image_cleanup_identity() { local calls exact_image foreign_project foreign_tag leftovers removed=0 calls="$(mktemp "${TMPDIR:-/tmp}/thoth-workspace-registry-smoke-image-contract.XXXXXX")" project="thoth-workspace-registry-smoke-selftest-123" exact_image="thothii-workspace-registry-smoke:${project}" foreign_project="thothii-workspace-registry-smoke:thoth-workspace-registry-smoke-foreign-456" foreign_tag="thothii-workspace-registry-smoke:local" image="$exact_image" docker() { printf '%s\n' "docker $*" >>"$calls" case "$1 $2" in "image inspect") [[ "$3" == "--format" && "$5" == "$exact_image" ]] || return 43 [[ "$removed" -eq 0 ]] ;; "image rm") [[ "$3" == "-f" && "$4" == "$exact_image" ]] || return 42 removed=1 ;; "image ls") [[ "$3" == "--quiet" && "$4" == "--no-trunc" && "$5" == "$exact_image" ]] || return 47 ;; "ps -a"|"volume ls"|"network ls") [[ "$*" == *"label=com.docker.compose.project=$project"* ]] || return 45 ;; *) return 46 ;; esac } cleanup_smoke_image leftovers="$(workspace_registry_smoke_leftovers)" [[ -z "$(printf '%s\n' "$leftovers" | sed '/^$/d')" ]] || { echo "self-test observed leftovers for the per-run image" >&2 printf '%s\n' "$leftovers" >&2 return 1 } grep -Fq "docker image rm -f $exact_image" "$calls" \ || { echo "self-test did not remove the exact per-run image reference" >&2; return 1; } if grep -Fq "$foreign_project" "$calls" || grep -Fq "$foreign_tag" "$calls"; then echo "self-test cleanup touched a foreign workspace-registry smoke image reference" >&2 return 1 fi rm -f "$calls" echo "workspace registry smoke image cleanup identity self-test passed" } workspace_registry_smoke_self_test_compose_config() { local special_root rendered special_root="$tmp/compose config path # colon: fixture" root="$special_root/root context" remote="$special_root/remote repo # fixture.git" branch="workspace registry # branch" core_remote="/fixtures/remote repo # fixture.git" image="thothii-workspace-registry-smoke:compose-config-selftest" project="thoth-workspace-registry-smoke-compose-config-selftest-$$" rendered="$special_root/rendered.yaml" mkdir -p "$root" "$remote" compose config --quiet compose config --format json >"$rendered" grep -Fq "$root" "$rendered" grep -Fq "$remote" "$rendered" grep -Fq "$branch" "$rendered" grep -Fq "$core_remote" "$rendered" grep -Fq '"read_only": true' "$rendered" rm -rf "$tmp" echo "workspace registry smoke Compose config special-path self-test passed" } workspace_registry_smoke_self_test_cleanup_failure_path() { local fixture_root retained calls output status fixture_root="$tmp/cleanup failure # fixture" retained="$fixture_root/retained smoke path" calls="$fixture_root/calls.log" mkdir -p "$retained" set +e output="$( ( tmp="$retained" project="thoth-workspace-registry-smoke-cleanup-failure-selftest" image="thothii-workspace-registry-smoke:cleanup-failure-selftest" compose() { printf '%s\n' "compose $*" >>"$calls"; return 71; } cleanup_smoke_image() { printf '%s\n' 'image cleanup' >>"$calls"; return 72; } workspace_registry_smoke_leftovers() { printf '%s\n' 'owned-resource-selftest'; printf '%s\n' 'enumerate leftovers' >>"$calls"; return 73; } trap cleanup EXIT exit 37 ) 2>&1 )" status=$? set -e [[ "$status" -eq 37 ]] || { echo "cleanup self-test did not preserve body status 37 (got $status)" >&2; return 1; } grep -Fq 'compose down --volumes --remove-orphans' "$calls" grep -Fq 'image cleanup' "$calls" grep -Fq 'enumerate leftovers' "$calls" grep -Fq 'cleanup incomplete: compose down status=71, image cleanup status=72, enumeration status=73' <<<"$output" grep -Fq 'owned-resource-selftest' <<<"$output" grep -Fq "recovery path retained: $retained" <<<"$output" [[ -d "$retained" ]] || { echo "cleanup self-test did not retain its recovery path" >&2; return 1; } rm -rf "$tmp" echo "workspace registry smoke cleanup failure-path self-test passed" } workspace_registry_smoke_metadata() { local fixture="$1" awk ' /^workspace:[[:space:]]*$/ { in_workspace = 1; next } in_workspace && /^[^[:space:]]/ { in_workspace = 0 } in_workspace && /^ id:[[:space:]]*/ { sub(/^ id:[[:space:]]*/, "") id = $0 next } in_workspace && /^ name:[[:space:]]*/ { sub(/^ name:[[:space:]]*/, "") name = $0 next } in_workspace && /^ description:[[:space:]]*/ { sub(/^ description:[[:space:]]*/, "") description = $0 next } END { if (id == "" || name == "") exit 1 print id print name if (description != "") print description } ' "$fixture" } workspace_registry_smoke_write_catalog() { local catalog_path="$1" id="$2" name="$3" description="${4:-}" { printf 'schema_version: 1 ' printf 'workspaces: ' printf ' - id: %s ' "$id" printf ' name: %s ' "$name" if [[ -n "$description" ]]; then printf ' description: %s ' "$description" fi } >"$catalog_path" } workspace_registry_smoke_replace_once() { local target="$1" old="$2" new="$3" python3 - "$target" "$old" "$new" <<'PY' from pathlib import Path import sys path = Path(sys.argv[1]) old = sys.argv[2] new = sys.argv[3] text = path.read_text() count = text.count(old) if count != 1: raise SystemExit(f"expected exactly one occurrence of {old!r} in {path}, found {count}") path.write_text(text.replace(old, new, 1)) PY } workspace_registry_smoke_seed_fixture() { local fixture="$1" metadata mapfile -t metadata < <(workspace_registry_smoke_metadata "$fixture") workspace_registry_smoke_id="${metadata[0]}" workspace_registry_smoke_name="${metadata[1]}" workspace_registry_smoke_description="${metadata[2]-}" mkdir -p "$seed/$workspace_registry_smoke_id" cp "$fixture" "$seed/$workspace_registry_smoke_id/workspace.yaml" workspace_registry_smoke_write_catalog "$seed/thoth-workspaces.yaml" "$workspace_registry_smoke_id" "$workspace_registry_smoke_name" "$workspace_registry_smoke_description" if grep -Fq 'type: filesystem' "$fixture"; then mkdir -p "$seed/$workspace_registry_smoke_id/evidence" printf 'guide v1\n' >"$seed/$workspace_registry_smoke_id/evidence/guide.md" fi } workspace_registry_smoke_commit() { local message="$1" git -C "$seed" add -A >/dev/null git -C "$seed" -c user.name='Workspace Registry Smoke' -c user.email='workspace-registry-smoke@example.invalid' commit -m "$message" >/dev/null git -C "$seed" push ${2:-origin} "HEAD:$branch" >/dev/null } workspace_registry_smoke_prepare_fixture() { git init --bare --initial-branch=main "$remote" >/dev/null git clone "$remote" "$seed" >/dev/null git -C "$seed" checkout -b "$branch" >/dev/null workspace_registry_smoke_seed_fixture "$root/scripts/fixtures/workspace-registry-smoke.yaml" workspace_registry_smoke_commit 'Seed workspace registry smoke' } workspace_registry_smoke_registry_head() { printf '%s' "$1" | sed -n 's/.*"head":"\([0-9a-f]*\)".*/\1/p' } workspace_registry_smoke_reset_seed() { local commit="$1" git -C "$seed" reset --hard "$commit" >/dev/null git -C "$seed" push --force origin "HEAD:$branch" >/dev/null } workspace_registry_smoke_fixtures_only() { local descriptor_path catalog_path initial_head updated_head workspace_registry_smoke_prepare_fixture descriptor_path="$seed/$workspace_registry_smoke_id/workspace.yaml" catalog_path="$seed/thoth-workspaces.yaml" [[ -f "$catalog_path" ]] || { echo 'missing root workspace catalog' >&2; return 1; } [[ -f "$descriptor_path" ]] || { echo 'missing nested workspace descriptor' >&2; return 1; } [[ -f "$seed/$workspace_registry_smoke_id/evidence/guide.md" ]] || { echo 'missing nested workspace evidence' >&2; return 1; } [[ ! -e "$seed/workspaces/$workspace_registry_smoke_id.yaml" ]] || { echo 'legacy flat descriptor path was created' >&2; return 1; } [[ ! -e "$seed/workspace-content/$workspace_registry_smoke_id" ]] || { echo 'legacy flat evidence path was created' >&2; return 1; } grep -Fq 'schema_version: 1' "$catalog_path" grep -Fq 'name: Local' "$catalog_path" grep -Fq 'description: Isolated workspace registry smoke fixture.' "$catalog_path" initial_head="$(git -C "$seed" rev-parse HEAD)" workspace_registry_smoke_replace_once "$descriptor_path" 'name: Local' 'name: Local Updated' workspace_registry_smoke_replace_once "$catalog_path" 'name: Local' 'name: Local Updated' workspace_registry_smoke_commit 'Update workspace registry smoke metadata' updated_head="$(git -C "$seed" rev-parse HEAD)" [[ "$updated_head" != "$initial_head" ]] || { echo 'metadata co-commit did not advance Git head' >&2; return 1; } grep -Fq 'name: Local Updated' "$descriptor_path" grep -Fq 'name: Local Updated' "$catalog_path" printf 'guide v2\n' >"$seed/$workspace_registry_smoke_id/evidence/guide.md" workspace_registry_smoke_commit 'Update workspace registry smoke evidence only' [[ "$(git -C "$seed" rev-parse HEAD)" != "$updated_head" ]] || { echo 'evidence-only commit did not advance Git head' >&2; return 1; } mkdir -p "$seed/orphan" cp "$descriptor_path" "$seed/orphan/workspace.yaml" workspace_registry_smoke_replace_once "$seed/orphan/workspace.yaml" 'id: local' 'id: orphan' workspace_registry_smoke_replace_once "$seed/orphan/workspace.yaml" 'name: Local Updated' 'name: Orphan Workspace' workspace_registry_smoke_replace_once "$seed/orphan/workspace.yaml" 'collection: local' 'collection: orphan' workspace_registry_smoke_replace_once "$seed/orphan/workspace.yaml" 'uri: local/evidence' 'uri: orphan/evidence' mkdir -p "$seed/orphan/evidence" printf 'orphan guide\n' >"$seed/orphan/evidence/guide.md" [[ -f "$seed/orphan/workspace.yaml" ]] || { echo 'orphan descriptor was not created' >&2; return 1; } mkdir -p "$seed/workspaces" "$seed/workspace-content/$workspace_registry_smoke_id/evidence" cp "$root/scripts/fixtures/workspace-registry-smoke.yaml" "$seed/workspaces/$workspace_registry_smoke_id.yaml" printf 'legacy guide\n' >"$seed/workspace-content/$workspace_registry_smoke_id/evidence/guide.md" [[ -f "$seed/workspaces/$workspace_registry_smoke_id.yaml" ]] || { echo 'legacy flat descriptor fixture missing' >&2; return 1; } [[ -f "$seed/workspace-content/$workspace_registry_smoke_id/evidence/guide.md" ]] || { echo 'legacy flat evidence fixture missing' >&2; return 1; } echo 'workspace registry smoke fixture contract passed' } case "${WORKSPACE_REGISTRY_SMOKE_SELF_TEST:-}" in "") ;; image-cleanup-identity) workspace_registry_smoke_self_test_image_cleanup_identity rm -rf "$tmp" exit 0 ;; compose-config-contract) workspace_registry_smoke_self_test_compose_config exit 0 ;; cleanup-failure-path) workspace_registry_smoke_self_test_cleanup_failure_path exit 0 ;; *) echo "unknown workspace registry smoke self-test: ${WORKSPACE_REGISTRY_SMOKE_SELF_TEST}" >&2 rm -rf "$tmp" exit 2 ;; esac if [[ "${1:-}" == "--fixtures-only" ]]; then workspace_registry_smoke_fixtures_only rm -rf "$tmp" exit 0 fi trap cleanup EXIT trap 'exit 129' HUP trap 'exit 130' INT trap 'exit 143' TERM wait_for_core() { local attempt for attempt in $(seq 1 30); do if compose exec -T core curl -fsS http://127.0.0.1:8787/health >/dev/null 2>&1; then return 0 fi sleep 1 done compose logs core >&2 || true return 1 } if [[ -n "${WORKSPACE_GIT_REMOTE:-}" ]]; then echo "== Read-only Git remote preflight ==" git ls-remote --heads "$WORKSPACE_GIT_REMOTE" >/dev/null fi echo "== Seed isolated workspace registry ==" workspace_registry_smoke_prepare_fixture echo "== Build and start isolated Compose core ==" compose up -d --build wait_for_core initial_status="$(compose exec -T core curl -fsS http://127.0.0.1:8787/workspace-registry/status)" printf '%s' "$initial_status" | grep -Eq '"head":"[0-9a-f]{40}"' compose exec -T core test -f /data/workspace-registry/state/active.json echo "== Recreate offline and prove registry-volume fallback ==" core_remote="/fixtures/offline.git" compose up -d --force-recreate wait_for_core recreated_status="$(compose exec -T core curl -fsS http://127.0.0.1:8787/workspace-registry/status)" initial_head="$(workspace_registry_smoke_registry_head "$initial_status")" recreated_head="$(workspace_registry_smoke_registry_head "$recreated_status")" test -n "$initial_head" && test "$initial_head" = "$recreated_head" printf '%s' "$recreated_status" | grep -Fq '"degraded":true' compose exec -T core curl -fsS http://127.0.0.1:8787/workspaces | grep -Fq 'Local' echo "== Pull a valid catalog+descriptor metadata update ==" workspace_registry_smoke_replace_once "$seed/$workspace_registry_smoke_id/workspace.yaml" 'name: Local' 'name: Local Updated' workspace_registry_smoke_replace_once "$seed/thoth-workspaces.yaml" 'name: Local' 'name: Local Updated' workspace_registry_smoke_commit 'Update workspace registry smoke metadata' core_remote="/fixtures/remote.git" compose up -d --force-recreate wait_for_core valid_status="$(compose exec -T core curl -fsS -X POST http://127.0.0.1:8787/workspace-registry/pull)" valid_head="$(workspace_registry_smoke_registry_head "$valid_status")" [[ "$valid_head" =~ ^[0-9a-f]{40}$ && "$valid_head" != "$initial_head" ]] compose exec -T core curl -fsS http://127.0.0.1:8787/workspaces | grep -Fq 'Local Updated' good_seed_commit="$(git -C "$seed" rev-parse HEAD)" echo "== Pull a content-only Evidence update ==" printf 'guide v2\n' >"$seed/$workspace_registry_smoke_id/evidence/guide.md" workspace_registry_smoke_commit 'Update workspace registry smoke evidence only' evidence_status="$(compose exec -T core curl -fsS -X POST http://127.0.0.1:8787/workspace-registry/pull)" evidence_head="$(workspace_registry_smoke_registry_head "$evidence_status")" [[ "$evidence_head" =~ ^[0-9a-f]{40}$ && "$evidence_head" != "$valid_head" ]] compose exec -T core curl -fsS http://127.0.0.1:8787/workspaces | grep -Fq 'Local Updated' good_seed_commit="$(git -C "$seed" rev-parse HEAD)" echo "== Reject catalog/descriptor metadata mismatch and retain the last valid snapshot ==" workspace_registry_smoke_replace_once "$seed/thoth-workspaces.yaml" 'name: Local Updated' 'name: Local Drift' workspace_registry_smoke_commit 'Break workspace registry metadata parity' if compose exec -T core curl -fsS -X POST http://127.0.0.1:8787/workspace-registry/pull >/dev/null; then echo 'registry accepted catalog/descriptor metadata mismatch' >&2 exit 1 fi compose exec -T core curl -fsS http://127.0.0.1:8787/workspaces | grep -Fq 'Local Updated' compose exec -T core test -f /data/workspace-registry/state/active.json workspace_registry_smoke_replace_once "$seed/thoth-workspaces.yaml" 'name: Local Drift' 'name: Local Updated' workspace_registry_smoke_commit 'Restore workspace registry metadata parity' restore_status="$(compose exec -T core curl -fsS -X POST http://127.0.0.1:8787/workspace-registry/pull)" evidence_head="$(workspace_registry_smoke_registry_head "$restore_status")" compose exec -T core curl -fsS http://127.0.0.1:8787/workspaces | grep -Fq 'Local Updated' echo "== Reject orphan descriptor directories not listed in the catalog ==" mkdir -p "$seed/orphan" cp "$seed/$workspace_registry_smoke_id/workspace.yaml" "$seed/orphan/workspace.yaml" workspace_registry_smoke_replace_once "$seed/orphan/workspace.yaml" 'id: local' 'id: orphan' workspace_registry_smoke_replace_once "$seed/orphan/workspace.yaml" 'name: Local Updated' 'name: Orphan Workspace' workspace_registry_smoke_replace_once "$seed/orphan/workspace.yaml" 'collection: local' 'collection: orphan' workspace_registry_smoke_replace_once "$seed/orphan/workspace.yaml" 'uri: local/evidence' 'uri: orphan/evidence' mkdir -p "$seed/orphan/evidence" printf 'orphan guide\n' >"$seed/orphan/evidence/guide.md" workspace_registry_smoke_commit 'Add orphan workspace directory' if compose exec -T core curl -fsS -X POST http://127.0.0.1:8787/workspace-registry/pull >/dev/null; then echo 'registry accepted orphan descriptor directory' >&2 exit 1 fi compose exec -T core curl -fsS http://127.0.0.1:8787/workspaces | grep -Fq 'Local Updated' rm -rf "$seed/orphan" workspace_registry_smoke_commit 'Remove orphan workspace directory' restore_status="$(compose exec -T core curl -fsS -X POST http://127.0.0.1:8787/workspace-registry/pull)" evidence_head="$(workspace_registry_smoke_registry_head "$restore_status")" compose exec -T core curl -fsS http://127.0.0.1:8787/workspaces | grep -Fq 'Local Updated' echo "== Reject the retired flat workspace layout and retain the last valid snapshot ==" mkdir -p "$seed/workspaces" "$seed/workspace-content/$workspace_registry_smoke_id/evidence" cp "$root/scripts/fixtures/workspace-registry-smoke.yaml" "$seed/workspaces/$workspace_registry_smoke_id.yaml" printf 'legacy guide\n' >"$seed/workspace-content/$workspace_registry_smoke_id/evidence/guide.md" workspace_registry_smoke_commit 'Reintroduce retired flat workspace layout' if compose exec -T core curl -fsS -X POST http://127.0.0.1:8787/workspace-registry/pull >/dev/null; then echo 'registry accepted the retired flat workspace layout' >&2 exit 1 fi compose exec -T core curl -fsS http://127.0.0.1:8787/workspaces | grep -Fq 'Local Updated' echo "workspace registry smoke passed"