diff --git a/bin/context-kit b/bin/context-kit index 185c92e..34568f3 100755 --- a/bin/context-kit +++ b/bin/context-kit @@ -64,6 +64,13 @@ SHARED_SERVICES=(searxng "${WEB_SEARCH_SERVICE_NAME}" "${DOCS_SERVICE_NAME}") SNAPSHOT_IDS=() SNAPSHOT_RUNNING=() DOCS_SOURCES_FILE="${DATA_DIR}/docs-sources.txt" +DOCS_SOURCES_TRANSACTION_ACTIVE=0 +DOCS_SOURCES_PRIOR_PRESENT=0 +DOCS_SOURCES_BACKUP_DIR='' +DOCS_SOURCES_BACKUP_FILE='' +DOCS_SOURCES_RENDER_TMP='' +LIFECYCLE_STATE_ROLLBACK_ACTIVE=0 +LIFECYCLE_STATE_ROLLBACK_REMOVE_NEW=false DOCS_DATA_DIR="${DATA_DIR}/docs" MODELS_DATA_DIR="${DATA_DIR}/models" DOCS_LOCAL_SOURCES_DIR="${CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR:-${DATA_DIR}/local-sources}" @@ -130,12 +137,104 @@ require_no_args() { write_docs_sources_file() { mkdir -p "$(dirname "${DOCS_SOURCES_FILE}")" - local tmp="${DOCS_SOURCES_FILE}.tmp.$$" - { + local tmp="${DOCS_SOURCES_FILE}.tmp.${BASHPID}" + DOCS_SOURCES_RENDER_TMP="${tmp}" + if ! { printf '# generated by context-kit lifecycle commands; edit your CONTEXT_KIT_DOCS_SOURCES file(s) instead\n' resolved_sources - } > "${tmp}" - mv "${tmp}" "${DOCS_SOURCES_FILE}" + } > "${tmp}"; then + rm -f -- "${tmp}" + DOCS_SOURCES_RENDER_TMP='' + return 1 + fi + if ! mv -fT -- "${tmp}" "${DOCS_SOURCES_FILE}"; then + rm -f -- "${tmp}" + DOCS_SOURCES_RENDER_TMP='' + return 1 + fi + DOCS_SOURCES_RENDER_TMP='' +} + +reset_docs_sources_transaction() { + DOCS_SOURCES_TRANSACTION_ACTIVE=0 + DOCS_SOURCES_PRIOR_PRESENT=0 + DOCS_SOURCES_BACKUP_DIR='' + DOCS_SOURCES_BACKUP_FILE='' + DOCS_SOURCES_RENDER_TMP='' +} + +begin_docs_sources_transaction() { + [[ "${DOCS_SOURCES_TRANSACTION_ACTIVE}" -eq 0 ]] || return 1 + mkdir -p "$(dirname "${DOCS_SOURCES_FILE}")" + + local backup_dir + backup_dir="$(mktemp -d "${DOCS_SOURCES_FILE}.lifecycle-backup.XXXXXX")" || return 1 + DOCS_SOURCES_BACKUP_DIR="${backup_dir}" + DOCS_SOURCES_BACKUP_FILE="${backup_dir}/docs-sources.txt" + DOCS_SOURCES_PRIOR_PRESENT=0 + DOCS_SOURCES_TRANSACTION_ACTIVE=1 + + if [[ -e "${DOCS_SOURCES_FILE}" || -L "${DOCS_SOURCES_FILE}" ]]; then + DOCS_SOURCES_PRIOR_PRESENT=1 + if ! mv -T -- "${DOCS_SOURCES_FILE}" "${DOCS_SOURCES_BACKUP_FILE}"; then + rmdir -- "${DOCS_SOURCES_BACKUP_DIR}" 2>/dev/null || true + reset_docs_sources_transaction + return 1 + fi + fi +} + +restore_docs_sources_transaction() { + [[ "${DOCS_SOURCES_TRANSACTION_ACTIVE}" -eq 1 ]] || return 0 + local failed=0 + + if [[ -n "${DOCS_SOURCES_RENDER_TMP}" ]]; then + rm -f -- "${DOCS_SOURCES_RENDER_TMP}" || failed=1 + DOCS_SOURCES_RENDER_TMP='' + fi + if [[ "${DOCS_SOURCES_PRIOR_PRESENT}" -eq 1 ]]; then + if [[ -e "${DOCS_SOURCES_BACKUP_FILE}" || -L "${DOCS_SOURCES_BACKUP_FILE}" ]]; then + if ! mv -fT -- "${DOCS_SOURCES_BACKUP_FILE}" "${DOCS_SOURCES_FILE}"; then + warn "failed to restore prior docs sources file: ${DOCS_SOURCES_FILE}" + failed=1 + fi + else + warn "prior docs sources backup is missing: ${DOCS_SOURCES_BACKUP_FILE}" + failed=1 + fi + elif ! rm -f -- "${DOCS_SOURCES_FILE}"; then + warn "failed to restore prior absence of docs sources file: ${DOCS_SOURCES_FILE}" + failed=1 + fi + + if [[ "${failed}" -eq 0 ]] && ! rmdir -- "${DOCS_SOURCES_BACKUP_DIR}"; then + warn "failed to remove docs sources backup directory: ${DOCS_SOURCES_BACKUP_DIR}" + failed=1 + fi + if [[ "${failed}" -eq 0 ]]; then + reset_docs_sources_transaction + fi + return "${failed}" +} + +discard_docs_sources_transaction() { + [[ "${DOCS_SOURCES_TRANSACTION_ACTIVE}" -eq 1 ]] || return 0 + local failed=0 + + if [[ -n "${DOCS_SOURCES_RENDER_TMP}" ]]; then + rm -f -- "${DOCS_SOURCES_RENDER_TMP}" || failed=1 + DOCS_SOURCES_RENDER_TMP='' + fi + if [[ "${DOCS_SOURCES_PRIOR_PRESENT}" -eq 1 ]]; then + rm -f -- "${DOCS_SOURCES_BACKUP_FILE}" || failed=1 + fi + if [[ "${failed}" -eq 0 ]] && ! rmdir -- "${DOCS_SOURCES_BACKUP_DIR}"; then + failed=1 + fi + if [[ "${failed}" -eq 0 ]]; then + reset_docs_sources_transaction + fi + return "${failed}" } ensure_writable_dir() { @@ -491,6 +590,34 @@ restore_shared_service_states() { return "${failed}" } +rollback_lifecycle_transaction() { + local failed=0 + restore_docs_sources_transaction || failed=1 + if [[ "${LIFECYCLE_STATE_ROLLBACK_ACTIVE}" -eq 1 ]]; then + restore_shared_service_states "${LIFECYCLE_STATE_ROLLBACK_REMOVE_NEW}" || failed=1 + LIFECYCLE_STATE_ROLLBACK_ACTIVE=0 + fi + return "${failed}" +} + +lifecycle_transaction_exit() { + local status="$1" + trap - EXIT HUP INT TERM + rollback_lifecycle_transaction || true + exit "${status}" +} + +arm_lifecycle_transaction_traps() { + trap 'lifecycle_transaction_exit "$?"' EXIT + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM +} + +disarm_lifecycle_transaction_traps() { + trap - EXIT HUP INT TERM +} + shared_services_ready() { wait_for_searxng && wait_for_web_search_mcp && wait_for_docs_mcp } @@ -500,18 +627,42 @@ start_locked() { if ! docker image inspect "${WEB_SEARCH_IMAGE}" >/dev/null 2>&1 || ! docker image inspect "${DOCS_IMAGE}" >/dev/null 2>&1; then cmd_build fi - write_docs_sources_file - - local result=0 + local result=0 rollback_failed=0 + arm_lifecycle_transaction_traps + if begin_docs_sources_transaction; then + : + else + result=$? + disarm_lifecycle_transaction_traps + return "${result}" + fi + if write_docs_sources_file; then + : + else + result=$? + rollback_lifecycle_transaction || rollback_failed=1 + disarm_lifecycle_transaction_traps + [[ "${rollback_failed}" -eq 0 ]] || return 1 + return "${result}" + fi snapshot_shared_services + LIFECYCLE_STATE_ROLLBACK_ACTIVE=1 + LIFECYCLE_STATE_ROLLBACK_REMOVE_NEW=true compose up -d --no-recreate "${SHARED_SERVICES[@]}" || result=$? if [[ "${result}" -eq 0 ]] && ! shared_services_ready; then result=1 fi - [[ "${result}" -ne 0 ]] || return 0 + if [[ "${result}" -eq 0 ]] && discard_docs_sources_transaction; then + LIFECYCLE_STATE_ROLLBACK_ACTIVE=0 + disarm_lifecycle_transaction_traps + return 0 + fi + [[ "${result}" -ne 0 ]] || result=1 - warn "shared service startup failed; restoring prior container states" - restore_shared_service_states true || true + warn "shared service startup failed; restoring prior docs sources and container states" + rollback_lifecycle_transaction || rollback_failed=1 + disarm_lifecycle_transaction_traps + [[ "${rollback_failed}" -eq 0 ]] || return 1 return "${result}" } @@ -538,17 +689,42 @@ cmd_restart() { } restart_locked() { - local result=0 - write_docs_sources_file + local result=0 rollback_failed=0 + arm_lifecycle_transaction_traps + if begin_docs_sources_transaction; then + : + else + result=$? + disarm_lifecycle_transaction_traps + return "${result}" + fi + if write_docs_sources_file; then + : + else + result=$? + rollback_lifecycle_transaction || rollback_failed=1 + disarm_lifecycle_transaction_traps + [[ "${rollback_failed}" -eq 0 ]] || return 1 + return "${result}" + fi snapshot_shared_services + LIFECYCLE_STATE_ROLLBACK_ACTIVE=1 + LIFECYCLE_STATE_ROLLBACK_REMOVE_NEW=false compose restart "${SHARED_SERVICES[@]}" || result=$? if [[ "${result}" -eq 0 ]] && ! shared_services_ready; then result=1 fi - [[ "${result}" -ne 0 ]] || return 0 + if [[ "${result}" -eq 0 ]] && discard_docs_sources_transaction; then + LIFECYCLE_STATE_ROLLBACK_ACTIVE=0 + disarm_lifecycle_transaction_traps + return 0 + fi + [[ "${result}" -ne 0 ]] || result=1 - warn "shared service restart failed; restoring prior container states" - restore_shared_service_states false || true + warn "shared service restart failed; restoring prior docs sources and container states" + rollback_lifecycle_transaction || rollback_failed=1 + disarm_lifecycle_transaction_traps + [[ "${rollback_failed}" -eq 0 ]] || return 1 return "${result}" } diff --git a/docker/docs/Dockerfile b/docker/docs/Dockerfile index 74d59a4..e624990 100644 --- a/docker/docs/Dockerfile +++ b/docker/docs/Dockerfile @@ -29,7 +29,7 @@ RUN if [ -n "${LLMS_TXT_MCP_VERSION}" ]; then \ && rm /tmp/context-kit-docs-constraints.txt COPY entrypoint.sh /usr/local/bin/docs-mcp-entrypoint -RUN chmod +x /usr/local/bin/docs-mcp-entrypoint +RUN chmod 0555 /usr/local/bin/docs-mcp-entrypoint RUN mkdir -p /data /models /etc/context-kit ENV HF_HOME=/models \ diff --git a/docker/web-search/.dockerignore b/docker/web-search/.dockerignore index 77333f4..b912d3c 100644 --- a/docker/web-search/.dockerignore +++ b/docker/web-search/.dockerignore @@ -1,5 +1,7 @@ * !Dockerfile +!http-entrypoint.mjs +!mcp-probe.mjs !patch-mcp-web-search.mjs !overrides/ !overrides/bing.js diff --git a/docker/web-search/Dockerfile b/docker/web-search/Dockerfile index 8c86371..86b7922 100644 --- a/docker/web-search/Dockerfile +++ b/docker/web-search/Dockerfile @@ -29,6 +29,8 @@ RUN npm install -g "@zhafron/mcp-web-search@${MCP_WEB_SEARCH_VERSION}" \ && rm /tmp/patch-mcp-web-search.mjs /tmp/context-kit-bing-provider.js \ && npm cache clean --force +RUN chmod 0555 /usr/local/lib/context-kit + ENV CHROME_PATH=/usr/bin/chromium \ DEFAULT_SEARCH_PROVIDER=searxng \ HOME=/tmp \ diff --git a/scripts/release-check b/scripts/release-check index dc1b6be..d9ee9ef 100755 --- a/scripts/release-check +++ b/scripts/release-check @@ -1,6 +1,83 @@ #!/usr/bin/env bash set -euo pipefail +cleanup_ephemeral_lifecycle_lock() { + local project="${1:-}" lock_dir lock_file owner mode lock_owner + [[ "${project}" =~ ^context-kit-release-[0-9]+$ ]] || { + printf 'release-check: refusing non-release lifecycle lock project: %s\n' "${project}" >&2 + return 64 + } + command -v flock >/dev/null 2>&1 || { + printf 'release-check: flock is required for lifecycle lock cleanup\n' >&2 + return 1 + } + command -v stat >/dev/null 2>&1 || { + printf 'release-check: stat is required for lifecycle lock cleanup\n' >&2 + return 1 + } + + lock_dir="/tmp/context-kit-${project}.lock" + lock_file="${lock_dir}/lifecycle" + [[ -e "${lock_dir}" || -L "${lock_dir}" ]] || return 0 + [[ -d "${lock_dir}" && ! -L "${lock_dir}" ]] || { + printf 'release-check: refusing unsafe lifecycle lock path: %s\n' "${lock_dir}" >&2 + return 1 + } + if [[ -e "${lock_file}" || -L "${lock_file}" ]]; then + [[ -f "${lock_file}" && ! -L "${lock_file}" ]] || { + printf 'release-check: refusing unsafe lifecycle lock file: %s\n' "${lock_file}" >&2 + return 1 + } + fi + + ( + exec 9>"${lock_file}" || return 1 + if ! flock -n 9; then + printf 'release-check: lifecycle lock is still held: %s\n' "${lock_dir}" >&2 + return 1 + fi + + [[ -d "${lock_dir}" && ! -L "${lock_dir}" ]] || { + printf 'release-check: lifecycle lock path changed while acquiring it: %s\n' "${lock_dir}" >&2 + return 1 + } + owner="$(stat -c %u "${lock_dir}")" + mode="$(stat -c %a "${lock_dir}")" + [[ "${owner}" == "$(id -u)" && "${mode}" == "700" ]] || { + printf 'release-check: refusing lifecycle lock with uid %s and mode %s: %s\n' "${owner}" "${mode}" "${lock_dir}" >&2 + return 1 + } + [[ -f "${lock_file}" && ! -L "${lock_file}" ]] || { + printf 'release-check: lifecycle lock file changed while acquiring it: %s\n' "${lock_file}" >&2 + return 1 + } + lock_owner="$(stat -c %u "${lock_file}")" + [[ "${lock_owner}" == "$(id -u)" ]] || { + printf 'release-check: refusing lifecycle lock file owned by uid %s: %s\n' "${lock_owner}" "${lock_file}" >&2 + return 1 + } + + rm -f -- "${lock_file}" + if ! rmdir -- "${lock_dir}"; then + printf 'release-check: lifecycle lock directory contains unexpected entries: %s\n' "${lock_dir}" >&2 + return 1 + fi + ) +} + +if [[ "${1:-}" == "--cleanup-ephemeral-lock" ]]; then + [[ "$#" -eq 2 ]] || { + printf 'usage: scripts/release-check --cleanup-ephemeral-lock context-kit-release-PID\n' >&2 + exit 64 + } + cleanup_ephemeral_lifecycle_lock "$2" + exit +fi +[[ "$#" -eq 0 ]] || { + printf 'usage: scripts/release-check\n' >&2 + exit 64 +} + ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "${ROOT}" @@ -44,9 +121,16 @@ printf '%s\n' \ printf '%s\n' "${CONTEXT_KIT_LOCAL_SOURCE_SMOKE_URL}" > "${local_sources_profile}" cleanup() { + local status="$?" lock_status=0 + trap - EXIT docker compose -p "${CONTEXT_KIT_COMPOSE_PROJECT}" -f compose.yml down -v --remove-orphans >/dev/null 2>&1 || true docker image rm "${CONTEXT_KIT_WEB_SEARCH_IMAGE}" "${CONTEXT_KIT_DOCS_IMAGE}" >/dev/null 2>&1 || true + cleanup_ephemeral_lifecycle_lock "${CONTEXT_KIT_COMPOSE_PROJECT}" || lock_status=$? rm -rf "${tmp_dir}" + if [[ "${status}" -ne 0 ]]; then + exit "${status}" + fi + exit "${lock_status}" } trap cleanup EXIT diff --git a/scripts/test-lifecycle.sh b/scripts/test-lifecycle.sh index 69a75ce..3f12d34 100644 --- a/scripts/test-lifecycle.sh +++ b/scripts/test-lifecycle.sh @@ -3,14 +3,27 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" CONTEXT_KIT="${ROOT}/bin/context-kit" +RELEASE_CHECK="${ROOT}/scripts/release-check" TEST_ROOT="$(mktemp -d)" TEST_PROJECT="context-kit-lifecycle-$$" LOCK_DIR="/tmp/context-kit-${TEST_PROJECT}.lock" +RELEASE_LOCK_TEST_PROJECT="context-kit-release-$((900000000 + $$))" +RELEASE_LOCK_DIR="/tmp/context-kit-${RELEASE_LOCK_TEST_PROJECT}.lock" +LOCK_HOLDER_PID='' cleanup() { + if [[ -n "${LOCK_HOLDER_PID}" ]]; then + kill "${LOCK_HOLDER_PID}" 2>/dev/null || true + wait "${LOCK_HOLDER_PID}" 2>/dev/null || true + fi rm -rf "${TEST_ROOT}" if [[ -d "${LOCK_DIR}" && "$(stat -c %u "${LOCK_DIR}")" == "$(id -u)" ]]; then rm -rf "${LOCK_DIR}" fi + if [[ -L "${RELEASE_LOCK_DIR}" ]]; then + rm -f "${RELEASE_LOCK_DIR}" + elif [[ -d "${RELEASE_LOCK_DIR}" && "$(stat -c %u "${RELEASE_LOCK_DIR}")" == "$(id -u)" ]]; then + rm -rf "${RELEASE_LOCK_DIR}" + fi } trap cleanup EXIT @@ -36,6 +49,16 @@ fake_service_for_container() { return 1 } +assert_docs_sources_restored_before_state_change() { + if [[ -n "${FAKE_EXPECT_DOCS_SOURCES:-}" ]]; then + cmp -s "${FAKE_EXPECT_DOCS_SOURCES}" "${CONTEXT_KIT_DATA_DIR}/docs-sources.txt" \ + || fail_test "container state restoration ran before prior docs sources content was restored" + elif [[ "${FAKE_EXPECT_DOCS_SOURCES_ABSENT:-0}" -eq 1 ]]; then + [[ ! -e "${CONTEXT_KIT_DATA_DIR}/docs-sources.txt" && ! -L "${CONTEXT_KIT_DATA_DIR}/docs-sources.txt" ]] \ + || fail_test "container state restoration ran before prior docs sources absence was restored" + fi +} + fake_compose() { while [[ "$#" -gt 0 ]]; do case "$1" in @@ -164,12 +187,14 @@ docker() { start) local container_id="${!#}" service if service="$(fake_service_for_container "${container_id}" 2>/dev/null)"; then + assert_docs_sources_restored_before_state_change 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 + assert_docs_sources_restored_before_state_change rm -f "${FAKE_DOCKER_STATE}/service.${service}.id" "${FAKE_DOCKER_STATE}/service.${service}.running" fi rm -f "${FAKE_DOCKER_STATE}/owner.${container_id}" @@ -177,6 +202,7 @@ docker() { stop) local container_id="${!#}" service service="$(fake_service_for_container "${container_id}")" || return 1 + assert_docs_sources_restored_before_state_change rm -f "${FAKE_DOCKER_STATE}/service.${service}.running" ;; ps) @@ -214,7 +240,7 @@ curl() { } sleep() { return 0; } -export -f fail_test fake_log fake_service_for_container fake_compose docker curl sleep +export -f fail_test fake_log fake_service_for_container assert_docs_sources_restored_before_state_change fake_compose docker curl sleep new_case() { local name="$1" @@ -232,7 +258,8 @@ new_case() { 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 + FAKE_WEB_SEARCH_FAIL FAKE_DOCS_FAIL FAKE_LEGACY_CONTAINER FAKE_CLIENT_OWNER_MISMATCH \ + FAKE_EXPECT_DOCS_SOURCES FAKE_EXPECT_DOCS_SOURCES_ABSENT mkdir -p "${FAKE_DOCKER_STATE}" "${HOME}" : > "${FAKE_DOCKER_LOG}" } @@ -245,6 +272,62 @@ seed_service() { fi } +assert_no_docs_sources_artifacts() { + if compgen -G "${CONTEXT_KIT_DATA_DIR}/docs-sources.txt.lifecycle-backup.*" >/dev/null \ + || compgen -G "${CONTEXT_KIT_DATA_DIR}/docs-sources.txt.tmp.*" >/dev/null; then + fail_test "docs sources transaction left backup or render artifacts" + fi +} + +mkdir -m 700 "${RELEASE_LOCK_DIR}" +: > "${RELEASE_LOCK_DIR}/lifecycle" +chmod 755 "${RELEASE_LOCK_DIR}" +if "${RELEASE_CHECK}" --cleanup-ephemeral-lock "${RELEASE_LOCK_TEST_PROJECT}" >"${TEST_ROOT}/unsafe-lock.out" 2>&1; then + fail_test "release lock cleanup accepted an unsafe mode" +fi +[[ -d "${RELEASE_LOCK_DIR}" ]] || fail_test "release lock cleanup removed an unsafe lock" +chmod 700 "${RELEASE_LOCK_DIR}" +"${RELEASE_CHECK}" --cleanup-ephemeral-lock "${RELEASE_LOCK_TEST_PROJECT}" + +release_lock_target="${TEST_ROOT}/release-lock-symlink-target" +mkdir -m 700 "${release_lock_target}" +touch "${release_lock_target}/sentinel" +ln -s "${release_lock_target}" "${RELEASE_LOCK_DIR}" +if "${RELEASE_CHECK}" --cleanup-ephemeral-lock "${RELEASE_LOCK_TEST_PROJECT}" >"${TEST_ROOT}/symlink-lock.out" 2>&1; then + fail_test "release lock cleanup followed a symlink" +fi +[[ -f "${release_lock_target}/sentinel" ]] || fail_test "release lock cleanup changed a symlink target" +rm -f "${RELEASE_LOCK_DIR}" +rm -rf "${release_lock_target}" + +mkdir -m 700 "${RELEASE_LOCK_DIR}" +: > "${RELEASE_LOCK_DIR}/lifecycle" +( + flock -x 9 + touch "${TEST_ROOT}/release-lock-held" + /bin/sleep 30 +) 9>"${RELEASE_LOCK_DIR}/lifecycle" & +LOCK_HOLDER_PID=$! +for _ in {1..100}; do + [[ -f "${TEST_ROOT}/release-lock-held" ]] && break + /bin/sleep 0.01 +done +[[ -f "${TEST_ROOT}/release-lock-held" ]] || fail_test "release lock holder did not start" +if "${RELEASE_CHECK}" --cleanup-ephemeral-lock "${RELEASE_LOCK_TEST_PROJECT}" >"${TEST_ROOT}/held-lock.out" 2>&1; then + fail_test "release lock cleanup removed a held lock" +fi +grep -F 'still held' "${TEST_ROOT}/held-lock.out" >/dev/null || fail_test "held lock refusal was not explicit" +kill "${LOCK_HOLDER_PID}" +wait "${LOCK_HOLDER_PID}" 2>/dev/null || true +LOCK_HOLDER_PID='' +"${RELEASE_CHECK}" --cleanup-ephemeral-lock "${RELEASE_LOCK_TEST_PROJECT}" +[[ ! -e "${RELEASE_LOCK_DIR}" && ! -L "${RELEASE_LOCK_DIR}" ]] || fail_test "successful release lock cleanup left the lock path" + +mkdir -m 700 "${RELEASE_LOCK_DIR}" +: > "${RELEASE_LOCK_DIR}/lifecycle" +"${RELEASE_CHECK}" --cleanup-ephemeral-lock "${RELEASE_LOCK_TEST_PROJECT}" +[[ ! -e "${RELEASE_LOCK_DIR}" && ! -L "${RELEASE_LOCK_DIR}" ]] || fail_test "release lock cleanup did not remove its known ephemeral lock" + new_case snippets "${CONTEXT_KIT}" install opencode > "${CASE_ROOT}/opencode.json" "${CONTEXT_KIT}" install claude > "${CASE_ROOT}/claude.json" @@ -319,6 +402,12 @@ new_case readiness-failure touch "${FAKE_DOCKER_STATE}/network" "${FAKE_DOCKER_STATE}/volume" seed_service searxng seed_service docs-mcp stopped +mkdir -p "${CONTEXT_KIT_DATA_DIR}" +printf 'prior docs sources\nwith exact content\n' > "${CASE_ROOT}/prior-docs-sources.txt" +cp "${CASE_ROOT}/prior-docs-sources.txt" "${CONTEXT_KIT_DATA_DIR}/docs-sources.txt" +printf 'https://new.example.test/llms.txt\n' > "${CASE_ROOT}/new-sources.txt" +export CONTEXT_KIT_DOCS_SOURCES="${CASE_ROOT}/new-sources.txt" +export FAKE_EXPECT_DOCS_SOURCES="${CASE_ROOT}/prior-docs-sources.txt" export FAKE_DROP_RUNNING=searxng export FAKE_WEB_SEARCH_FAIL=1 if "${CONTEXT_KIT}" start >"${CASE_ROOT}/start.out" 2>&1; then @@ -326,6 +415,9 @@ if "${CONTEXT_KIT}" start >"${CASE_ROOT}/start.out" 2>&1; then 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" +[[ "$(<"${FAKE_DOCKER_STATE}/service.docs-mcp.id")" == cid-docs-mcp ]] || fail_test "readiness rollback changed the prior docs container ID" +cmp -s "${CASE_ROOT}/prior-docs-sources.txt" "${CONTEXT_KIT_DATA_DIR}/docs-sources.txt" || fail_test "readiness rollback did not restore prior docs sources content" +assert_no_docs_sources_artifacts [[ ! -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" @@ -338,20 +430,41 @@ 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" +assert_no_docs_sources_artifacts 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 +seed_service docs-mcp stopped +mkdir -p "${CONTEXT_KIT_DATA_DIR}" +printf 'prior restart sources\n' > "${CASE_ROOT}/prior-docs-sources.txt" +cp "${CASE_ROOT}/prior-docs-sources.txt" "${CONTEXT_KIT_DATA_DIR}/docs-sources.txt" +printf 'https://restart.example.test/llms.txt\n' > "${CASE_ROOT}/new-sources.txt" +export CONTEXT_KIT_DOCS_SOURCES="${CASE_ROOT}/new-sources.txt" +export FAKE_EXPECT_DOCS_SOURCES="${CASE_ROOT}/prior-docs-sources.txt" +export FAKE_DOCS_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.docs-mcp.running" ]] || fail_test "restart rollback did not restore prior stopped docs state" +[[ "$(<"${FAKE_DOCKER_STATE}/service.docs-mcp.id")" == cid-docs-mcp ]] || fail_test "restart rollback changed the prior docs container ID" [[ ! -f "${FAKE_DOCKER_STATE}/service.searxng.running" ]] || fail_test "restart rollback did not restore prior stopped SearXNG state" +cmp -s "${CASE_ROOT}/prior-docs-sources.txt" "${CONTEXT_KIT_DATA_DIR}/docs-sources.txt" || fail_test "restart rollback did not restore prior docs sources content" +assert_no_docs_sources_artifacts grep -F 'docker rm' "${FAKE_DOCKER_LOG}" >/dev/null && fail_test "restart rollback removed a shared container" +new_case render-error +mkdir -p "${CONTEXT_KIT_DATA_DIR}" +printf 'prior render-error sources\n' > "${CASE_ROOT}/prior-docs-sources.txt" +cp "${CASE_ROOT}/prior-docs-sources.txt" "${CONTEXT_KIT_DATA_DIR}/docs-sources.txt" +export CONTEXT_KIT_DOCS_SOURCES="${CASE_ROOT}/missing-sources.txt" +if "${CONTEXT_KIT}" start >"${CASE_ROOT}/start.out" 2>&1; then + fail_test "docs sources render error unexpectedly succeeded" +fi +cmp -s "${CASE_ROOT}/prior-docs-sources.txt" "${CONTEXT_KIT_DATA_DIR}/docs-sources.txt" || fail_test "render error did not restore prior docs sources" +assert_no_docs_sources_artifacts +grep -F ' up ' "${FAKE_DOCKER_LOG}" >/dev/null && fail_test "render error reached Compose startup" + new_case cross-user seed_service docs-mcp export FAKE_DOCS_UID="$(( $(id -u) + 1 ))" @@ -363,12 +476,18 @@ grep -F ' up ' "${FAKE_DOCKER_LOG}" >/dev/null && fail_test "cross-user rejectio new_case bounded-failure export FAKE_WEB_SEARCH_FAIL=1 +export FAKE_EXPECT_DOCS_SOURCES_ABSENT=1 +[[ ! -e "${CONTEXT_KIT_DATA_DIR}/docs-sources.txt" && ! -L "${CONTEXT_KIT_DATA_DIR}/docs-sources.txt" ]] \ + || fail_test "prior-absence case unexpectedly began with docs sources" 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 +[[ ! -e "${CONTEXT_KIT_DATA_DIR}/docs-sources.txt" && ! -L "${CONTEXT_KIT_DATA_DIR}/docs-sources.txt" ]] \ + || fail_test "fresh readiness rollback did not restore prior docs sources absence" +assert_no_docs_sources_artifacts [[ -f "${FAKE_DOCKER_STATE}/network" && -f "${FAKE_DOCKER_STATE}/volume" ]] || fail_test "bounded shared resources were destructively removed" new_case legacy-status