#!/usr/bin/env bash set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" CONTEXT_KIT="${ROOT}/bin/context-kit" TEST_ROOT="$(mktemp -d)" TEST_PROJECT="context-kit-lifecycle-$$" LOCK_DIR="/tmp/context-kit-${TEST_PROJECT}.lock" cleanup() { rm -rf "${TEST_ROOT}" if [[ -d "${LOCK_DIR}" && "$(stat -c %u "${LOCK_DIR}")" == "$(id -u)" ]]; then rm -rf "${LOCK_DIR}" fi } trap cleanup EXIT fail_test() { printf 'lifecycle test: %s\n' "$*" >&2 exit 1 } fake_log() { printf '%s\n' "$*" >> "${FAKE_DOCKER_LOG}" } fake_service_for_container() { local container_id="$1" file for file in "${FAKE_DOCKER_STATE}"/service.*.id; do [[ -f "${file}" ]] || continue if [[ "$(<"${file}")" == "${container_id}" ]]; then file="${file##*/service.}" printf '%s' "${file%.id}" return 0 fi done return 1 } fake_compose() { while [[ "$#" -gt 0 ]]; do case "$1" in -p|-f) shift 2 ;; *) break ;; esac done local command="${1:-}" shift || true case "${command}" in ps) local service="${!#}" if [[ -f "${FAKE_DOCKER_STATE}/service.${service}.id" ]]; then printf '%s\n' "$(<"${FAKE_DOCKER_STATE}/service.${service}.id")" fi ;; up) local no_recreate=0 service local services=() for service in "$@"; do case "${service}" in -d) ;; --no-recreate) no_recreate=1 ;; *) services+=("${service}") ;; esac done if [[ -n "${FAKE_REPLACEMENT_REQUIRED:-}" && "${no_recreate}" -eq 1 && -f "${FAKE_DOCKER_STATE}/service.${FAKE_REPLACEMENT_REQUIRED}.id" ]]; then return 17 fi if ! mkdir "${FAKE_DOCKER_STATE}/up.guard" 2>/dev/null; then fake_log RACE return 75 fi /bin/sleep 0.2 touch "${FAKE_DOCKER_STATE}/network" "${FAKE_DOCKER_STATE}/volume" for service in "${services[@]}"; do if [[ ! -f "${FAKE_DOCKER_STATE}/service.${service}.id" ]]; then printf 'cid-%s\n' "${service}" > "${FAKE_DOCKER_STATE}/service.${service}.id" fi touch "${FAKE_DOCKER_STATE}/service.${service}.running" done if [[ -n "${FAKE_DROP_RUNNING:-}" ]]; then rm -f "${FAKE_DOCKER_STATE}/service.${FAKE_DROP_RUNNING}.running" fi rmdir "${FAKE_DOCKER_STATE}/up.guard" ;; restart) local service for service in "$@"; do if [[ -f "${FAKE_DOCKER_STATE}/service.${service}.id" ]]; then touch "${FAKE_DOCKER_STATE}/service.${service}.running" fi done if [[ -n "${FAKE_DROP_RUNNING:-}" ]]; then rm -f "${FAKE_DOCKER_STATE}/service.${FAKE_DROP_RUNNING}.running" fi [[ "${FAKE_RESTART_FAIL:-0}" -eq 0 ]] ;; stop) local service for service in "$@"; do rm -f "${FAKE_DOCKER_STATE}/service.${service}.running" done ;; build|version) ;; *) fail_test "unsupported fake compose command: ${command}" ;; esac } docker() { fake_log "docker $*" local object="${1:-}" shift || true case "${object}" in info|pull) ;; image) ;; compose) fake_compose "$@" ;; network|volume) local action="${1:-}" [[ "${action}" == inspect && -f "${FAKE_DOCKER_STATE}/${object}" ]] ;; inspect) local container_id="${!#}" service service="$(fake_service_for_container "${container_id}" 2>/dev/null)" || { [[ -f "${FAKE_DOCKER_STATE}/owner.${container_id}" ]] || return 1 if [[ "$*" == *"dev.context-kit.owner"* ]]; then printf '%s\n' "$(<"${FAKE_DOCKER_STATE}/owner.${container_id}")" fi return } if [[ "$*" == *".State.Running"* ]]; then [[ -f "${FAKE_DOCKER_STATE}/service.${service}.running" ]] && printf 'true\n' || printf 'false\n' elif [[ "$*" == *"com.docker.compose.project"* ]]; then printf '%s:%s\n' "${CONTEXT_KIT_COMPOSE_PROJECT}" "${service}" elif [[ "$*" == *".Config.User"* ]]; then printf '%s:1000\n' "${FAKE_DOCS_UID:-$(id -u)}" elif [[ "$*" == *"dev.context-kit.uid"* ]]; then printf '%s\n' "${FAKE_WEB_UID:-$(id -u)}" fi ;; create) local name='' owner='' argument container_id while [[ "$#" -gt 0 ]]; do argument="$1" case "${argument}" in --name) name="$2"; shift 2 ;; --label) [[ "$2" == dev.context-kit.owner=* ]] && owner="${2#*=}" shift 2 ;; --network|-e|-v|--workdir|--entrypoint) shift 2 ;; -i|--rm) shift ;; *) shift ;; esac done [[ -n "${name}" && -n "${owner}" ]] || fail_test "client container lacks a deterministic name or owner" container_id="cid-${name}" if [[ "${FAKE_CLIENT_OWNER_MISMATCH:-0}" -eq 1 ]]; then printf 'unrelated-owner\n' > "${FAKE_DOCKER_STATE}/owner.${container_id}" else printf '%s\n' "${owner}" > "${FAKE_DOCKER_STATE}/owner.${container_id}" fi printf '%s\n' "${container_id}" ;; start) local container_id="${!#}" service if service="$(fake_service_for_container "${container_id}" 2>/dev/null)"; then touch "${FAKE_DOCKER_STATE}/service.${service}.running" fi ;; rm) local container_id="${!#}" service if service="$(fake_service_for_container "${container_id}" 2>/dev/null)"; then rm -f "${FAKE_DOCKER_STATE}/service.${service}.id" "${FAKE_DOCKER_STATE}/service.${service}.running" fi rm -f "${FAKE_DOCKER_STATE}/owner.${container_id}" ;; stop) local container_id="${!#}" service service="$(fake_service_for_container "${container_id}")" || return 1 rm -f "${FAKE_DOCKER_STATE}/service.${service}.running" ;; ps) if [[ "$*" == *"label=dev.context-kit=true"* && "${FAKE_LEGACY_CONTAINER:-0}" -eq 1 ]]; then printf 'legacy-web-search\tCreated\t\t\n' fi ;; *) fail_test "unsupported fake docker command: ${object}" ;; esac } curl() { local argument url='' data='' while [[ "$#" -gt 0 ]]; do argument="$1" case "${argument}" in --data) data="$2"; shift 2 ;; http://*|https://*) url="${argument}"; shift ;; *) shift ;; esac done case "${url}" in *:8099/healthz) [[ "${FAKE_SEARXNG_FAIL:-0}" -eq 0 ]] ;; *:8777/mcp) [[ "${FAKE_WEB_SEARCH_FAIL:-0}" -eq 0 ]] || return 1 if [[ "${data}" == *'"method":"initialize"'* ]]; then printf '{"jsonrpc":"2.0","id":1,"result":{"serverInfo":{"name":"web"}}}\n' else printf '{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"search_web"},{"name":"fetch_url"}]}}\n' fi ;; *:8776/status) [[ "${FAKE_DOCS_FAIL:-0}" -eq 0 ]] ;; *) fail_test "unexpected fake curl URL: ${url}" ;; esac } sleep() { return 0; } export -f fail_test fake_log fake_service_for_container fake_compose docker curl sleep new_case() { local name="$1" export CASE_ROOT="${TEST_ROOT}/${name}" export FAKE_DOCKER_STATE="${CASE_ROOT}/docker" export FAKE_DOCKER_LOG="${CASE_ROOT}/docker.log" export HOME="${CASE_ROOT}/home" export CONTEXT_KIT_DATA_DIR="${CASE_ROOT}/data" export CONTEXT_KIT_COMPOSE_PROJECT="${TEST_PROJECT}" export CONTEXT_KIT_SEARXNG_PORT=8099 export CONTEXT_KIT_WEB_SEARCH_PORT=8777 export CONTEXT_KIT_WEB_SEARCH_HTTP_URL=http://127.0.0.1:8777/mcp export CONTEXT_KIT_DOCS_PORT=8776 export CONTEXT_KIT_DOCS_HTTP_URL=http://127.0.0.1:8776/mcp export CONTEXT_KIT_DOCS_SOURCES=config/sources.default.txt unset CONTEXT_KIT_DOCKER_CIDFILE CONTEXT_KIT_RUNTIME_DIR FAKE_DOCS_UID FAKE_WEB_UID \ FAKE_REPLACEMENT_REQUIRED FAKE_RESTART_FAIL FAKE_DROP_RUNNING FAKE_SEARXNG_FAIL \ FAKE_WEB_SEARCH_FAIL FAKE_DOCS_FAIL FAKE_LEGACY_CONTAINER FAKE_CLIENT_OWNER_MISMATCH mkdir -p "${FAKE_DOCKER_STATE}" "${HOME}" : > "${FAKE_DOCKER_LOG}" } seed_service() { local service="$1" state="${2:-running}" printf 'cid-%s\n' "${service}" > "${FAKE_DOCKER_STATE}/service.${service}.id" if [[ "${state}" == running ]]; then touch "${FAKE_DOCKER_STATE}/service.${service}.running" fi } new_case snippets "${CONTEXT_KIT}" install opencode > "${CASE_ROOT}/opencode.json" "${CONTEXT_KIT}" install claude > "${CASE_ROOT}/claude.json" grep -F '"url": "http://127.0.0.1:8777/mcp"' "${CASE_ROOT}/opencode.json" >/dev/null || fail_test "OpenCode web search is not remote HTTP" grep -F '"url": "http://127.0.0.1:8777/mcp"' "${CASE_ROOT}/claude.json" >/dev/null || fail_test "Claude web search is not HTTP" new_case stdio-bridge touch "${FAKE_DOCKER_STATE}/network" seed_service web-search-mcp "${CONTEXT_KIT}" web-search /dev/null || fail_test "stdio bridge does not reuse the shared service" [[ -f "${FAKE_DOCKER_STATE}/service.web-search-mcp.running" ]] || fail_test "stdio bridge stopped the shared service" if compgen -G "${FAKE_DOCKER_STATE}/owner.*" >/dev/null; then fail_test "stdio bridge did not clean up its own container" fi new_case client-owner-isolation touch "${FAKE_DOCKER_STATE}/network" seed_service web-search-mcp export FAKE_CLIENT_OWNER_MISMATCH=1 "${CONTEXT_KIT}" web-search /dev/null \ || fail_test "stdio bridge removed a container whose owner label did not match" grep -F 'docker rm' "${FAKE_DOCKER_LOG}" >/dev/null \ && fail_test "stdio bridge attempted to remove a container whose owner label did not match" new_case differing-environments mkdir -p "${CASE_ROOT}/runtime-a" "${CASE_ROOT}/runtime-b" "${CASE_ROOT}/tmp-a" "${CASE_ROOT}/tmp-b" XDG_RUNTIME_DIR="${CASE_ROOT}/runtime-a" TMPDIR="${CASE_ROOT}/tmp-a" "${CONTEXT_KIT}" start >"${CASE_ROOT}/start-a.out" 2>&1 & first_pid=$! XDG_RUNTIME_DIR="${CASE_ROOT}/runtime-b" TMPDIR="${CASE_ROOT}/tmp-b" "${CONTEXT_KIT}" start >"${CASE_ROOT}/start-b.out" 2>&1 & second_pid=$! wait "${first_pid}" || fail_test "first concurrent start failed" wait "${second_pid}" || fail_test "second concurrent start failed" grep -F RACE "${FAKE_DOCKER_LOG}" >/dev/null && fail_test "environment-specific locks allowed a startup race" [[ -f "${LOCK_DIR}/lifecycle" ]] || fail_test "canonical project lock was not used" new_case unsafe-lock-mode chmod 755 "${LOCK_DIR}" if "${CONTEXT_KIT}" start >"${CASE_ROOT}/start.out" 2>&1; then fail_test "unsafe lock mode unexpectedly succeeded" fi grep -F 'expected uid' "${CASE_ROOT}/start.out" >/dev/null || fail_test "unsafe lock rejection was not explicit" grep -F ' up ' "${FAKE_DOCKER_LOG}" >/dev/null && fail_test "unsafe lock rejection happened after Compose startup" chmod 700 "${LOCK_DIR}" new_case origin-upgrade touch "${FAKE_DOCKER_STATE}/network" "${FAKE_DOCKER_STATE}/volume" seed_service searxng seed_service docs-mcp "${CONTEXT_KIT}" start [[ "$(<"${FAKE_DOCKER_STATE}/service.searxng.id")" == cid-searxng ]] || fail_test "origin searxng was replaced" [[ "$(<"${FAKE_DOCKER_STATE}/service.docs-mcp.id")" == cid-docs-mcp ]] || fail_test "origin docs-mcp was replaced" [[ -f "${FAKE_DOCKER_STATE}/service.web-search-mcp.running" ]] || fail_test "upgrade did not create shared web search" grep -F 'up -d --no-recreate searxng web-search-mcp docs-mcp' "${FAKE_DOCKER_LOG}" >/dev/null || fail_test "upgrade omitted --no-recreate" grep -E 'docker (network|volume) rm' "${FAKE_DOCKER_LOG}" >/dev/null && fail_test "upgrade removed an origin resource" new_case replacement-required seed_service searxng seed_service web-search-mcp seed_service docs-mcp export FAKE_REPLACEMENT_REQUIRED=docs-mcp if "${CONTEXT_KIT}" start >"${CASE_ROOT}/start.out" 2>&1; then fail_test "replacement-required start unexpectedly succeeded" fi for service in searxng web-search-mcp docs-mcp; do [[ -f "${FAKE_DOCKER_STATE}/service.${service}.running" ]] || fail_test "replacement failure left ${service} down" [[ "$(<"${FAKE_DOCKER_STATE}/service.${service}.id")" == "cid-${service}" ]] || fail_test "replacement failure changed ${service}" done new_case readiness-failure touch "${FAKE_DOCKER_STATE}/network" "${FAKE_DOCKER_STATE}/volume" seed_service searxng seed_service docs-mcp stopped export FAKE_DROP_RUNNING=searxng export FAKE_WEB_SEARCH_FAIL=1 if "${CONTEXT_KIT}" start >"${CASE_ROOT}/start.out" 2>&1; then fail_test "readiness failure unexpectedly succeeded" fi [[ -f "${FAKE_DOCKER_STATE}/service.searxng.running" ]] || fail_test "readiness rollback did not restart prior searxng" [[ ! -f "${FAKE_DOCKER_STATE}/service.docs-mcp.running" ]] || fail_test "readiness rollback did not restore prior stopped docs state" [[ ! -f "${FAKE_DOCKER_STATE}/service.web-search-mcp.id" ]] || fail_test "readiness rollback left its new web container" [[ -f "${FAKE_DOCKER_STATE}/network" && -f "${FAKE_DOCKER_STATE}/volume" ]] || fail_test "readiness rollback removed origin resources" new_case restart-sources seed_service searxng seed_service web-search-mcp seed_service docs-mcp printf 'https://example.test/llms.txt\n' > "${CASE_ROOT}/sources.txt" export CONTEXT_KIT_DOCS_SOURCES="${CASE_ROOT}/sources.txt" "${CONTEXT_KIT}" restart grep -F 'https://example.test/llms.txt' "${CONTEXT_KIT_DATA_DIR}/docs-sources.txt" >/dev/null \ || fail_test "restart did not regenerate the bind-mounted docs source list" new_case restart-failure seed_service searxng stopped seed_service web-search-mcp seed_service docs-mcp export FAKE_DROP_RUNNING=docs-mcp export FAKE_RESTART_FAIL=1 if "${CONTEXT_KIT}" restart >"${CASE_ROOT}/restart.out" 2>&1; then fail_test "restart failure unexpectedly succeeded" fi [[ -f "${FAKE_DOCKER_STATE}/service.docs-mcp.running" ]] || fail_test "restart rollback left prior docs down" [[ ! -f "${FAKE_DOCKER_STATE}/service.searxng.running" ]] || fail_test "restart rollback did not restore prior stopped SearXNG state" grep -F 'docker rm' "${FAKE_DOCKER_LOG}" >/dev/null && fail_test "restart rollback removed a shared container" new_case cross-user seed_service docs-mcp export FAKE_DOCS_UID="$(( $(id -u) + 1 ))" if "${CONTEXT_KIT}" start >"${CASE_ROOT}/start.out" 2>&1; then fail_test "cross-user ownership unexpectedly succeeded" fi grep -F 'cross-user ownership is unsupported' "${CASE_ROOT}/start.out" >/dev/null || fail_test "cross-user rejection was not explicit" grep -F ' up ' "${FAKE_DOCKER_LOG}" >/dev/null && fail_test "cross-user rejection happened after Compose startup" new_case bounded-failure export FAKE_WEB_SEARCH_FAIL=1 if "${CONTEXT_KIT}" start >"${CASE_ROOT}/start.out" 2>&1; then fail_test "fresh readiness failure unexpectedly succeeded" fi for service in searxng web-search-mcp docs-mcp; do [[ ! -f "${FAKE_DOCKER_STATE}/service.${service}.id" ]] || fail_test "fresh failure left ${service}" done [[ -f "${FAKE_DOCKER_STATE}/network" && -f "${FAKE_DOCKER_STATE}/volume" ]] || fail_test "bounded shared resources were destructively removed" new_case legacy-status export FAKE_LEGACY_CONTAINER=1 "${CONTEXT_KIT}" status >"${CASE_ROOT}/status.out" grep -F 'Legacy unlabeled Context Kit containers' "${CASE_ROOT}/status.out" >/dev/null || fail_test "status omitted legacy diagnostics" grep -F 'legacy-web-search' "${CASE_ROOT}/status.out" >/dev/null || fail_test "status omitted the legacy container" printf 'pass lifecycle and origin-upgrade tests\n'