fix(upgrade): verify compose container ownership

This commit is contained in:
Vitor Vilas Boas
2026-08-23 19:25:20 -03:00
parent 33ff6d9e36
commit 9f11139f27
5 changed files with 143 additions and 9 deletions
+1
View File
@@ -134,6 +134,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: scripts/check-native-packaging.sh
- run: tests/wrapper_upgrade.sh
# `bin/release` copies `## [Unreleased]` into the new version section
# verbatim, so a repeated `### ` heading there ships release notes with the
+8
View File
@@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- `ai-memory upgrade` now verifies that a discovered Compose project actually
owns the running `ai-memory` container before invoking `docker compose up`.
A standalone `docker run` install could previously be mistaken for a Compose
deployment merely because an unrelated `docker-compose.yml` existed in a
conventional search path; Compose then failed with a container-name conflict
and the safe standalone recreation-script fallback was skipped. Unowned
containers now use that existing fallback, preserving their inspected ports,
mounts, restart policy, command, and operator-set environment. (#469)
- The Windows Docker wrapper's thin-client commands now reach a
loopback-published server instead of failing with `Connection refused (os
error 111)`. `ai-memory status`, `search`, `bootstrap` and every other
+33 -9
View File
@@ -224,6 +224,27 @@ ENVEOF
return 0
}
# A compose file found in a conventional location is only relevant when its
# project actually owns the running container. A stale or unrelated file may
# declare the same container_name and `compose up` would then fail with a name
# conflict instead of taking the safe standalone recovery path.
compose_manages_container() {
local container="$1" compose_dir="$2" container_id compose_id
container_id="$("${DOCKER}" inspect "${container}" --format '{{.Id}}' 2>/dev/null)" || return 1
[ -n "${container_id}" ] || return 1
while IFS= read -r compose_id; do
[ -n "${compose_id}" ] || continue
case "${container_id}" in
"${compose_id}"*) return 0 ;;
esac
case "${compose_id}" in
"${container_id}"*) return 0 ;;
esac
done < <((cd "${compose_dir}" && "${DOCKER}" compose ps -q) 2>/dev/null)
return 1
}
cmd_upgrade() {
if [ -z "${AI_MEMORY_SKIP_SELF_UPGRADE:-}" ]; then
self_upgrade_script
@@ -271,11 +292,9 @@ cmd_upgrade() {
| grep -q '^ai-memory$'; then
# A LOCAL ai-memory container is running. We've just pulled a new
# image; `docker restart` won't recreate from the new image, so we
# need to stop+remove+recreate. The safe way is via `docker compose
# up -d` when a compose file is reachable (it remembers all the
# ports/volumes/env). Without compose we can't recreate safely
# because we don't know the original `docker run` args — fall
# back to a clear instruction.
# need to stop+remove+recreate. Use `docker compose up -d` only when
# the discovered project owns this container; otherwise reconstruct
# the standalone `docker run` from its inspected runtime settings.
local compose_dir=""
if [ -f "$(pwd)/docker/docker-compose.yml" ]; then
compose_dir="$(pwd)/docker"
@@ -284,14 +303,19 @@ cmd_upgrade() {
elif [ -f "${HOME}/deploy/ai-memory/docker-compose.yml" ]; then
compose_dir="${HOME}/deploy/ai-memory"
fi
if [ -n "${compose_dir}" ]; then
if [ -n "${compose_dir}" ] && compose_manages_container "ai-memory" "${compose_dir}"; then
echo "→ restarting local ai-memory container via docker compose (${compose_dir})"
( cd "${compose_dir}" && "${DOCKER}" compose up -d ) \
|| echo " (compose restart failed; re-run manually: cd ${compose_dir} && docker compose up -d)"
else
echo "→ a local ai-memory container is running but no compose file"
echo " was found in \$PWD/docker-compose.yml, ./docker/docker-compose.yml,"
echo " or ~/deploy/ai-memory/docker-compose.yml."
if [ -n "${compose_dir}" ]; then
echo "→ the compose file at ${compose_dir} does not manage the running ai-memory container"
echo " Treating it as a standalone docker run install to avoid a container-name conflict."
else
echo "→ a local ai-memory container is running but no compose file"
echo " was found in \$PWD/docker-compose.yml, ./docker/docker-compose.yml,"
echo " or ~/deploy/ai-memory/docker-compose.yml."
fi
local recreate_script="${CACHE_DIR}/recreate-ai-memory.sh"
mkdir -p "${CACHE_DIR}" 2>/dev/null || true
if emit_docker_run_script "ai-memory" "${recreate_script}"; then
+5
View File
@@ -1924,6 +1924,11 @@ image, re-stages hook scripts under
prints how to restart the server container so the new binary is used.
Re-running `install-hooks --apply` remains idempotent: ai-memory
replaces only the hook entries it owns and leaves unrelated hooks alone.
When a Compose file is found, the wrapper first verifies that its project owns
the running `ai-memory` container. A standalone container is never handed to an
unrelated Compose project just because its file occupies a conventional path;
the wrapper instead writes the inspected standalone recreation script for
review, preserving the existing `/data` mount and other runtime options.
Set `AI_MEMORY_NO_VERSION_CHECK=1` to silence the daily check. To pin wrapper
self-upgrades to a fork or tagged release, set `AI_MEMORY_WRAPPER_URL=<url>`;
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/ai-memory-wrapper-upgrade.XXXXXX")"
trap 'rm -rf "${TMP_ROOT}"' EXIT
fail() {
printf 'error: %s\n' "$*" >&2
exit 1
}
assert_contains() {
local file="$1" needle="$2"
grep -Fq -- "${needle}" "${file}" \
|| fail "${file} does not contain expected text: ${needle}"
}
assert_not_contains() {
local file="$1" needle="$2"
if grep -Fq -- "${needle}" "${file}"; then
fail "${file} unexpectedly contains: ${needle}"
fi
}
FAKE_DOCKER="${TMP_ROOT}/docker"
cat >"${FAKE_DOCKER}" <<'DOCKER'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >>"${AI_MEMORY_WRAPPER_TEST_LOG}"
case "${1:-}" in
pull)
exit 0
;;
ps)
printf 'ai-memory\n'
;;
compose)
if [ "${2:-}" = "ps" ] && [ "${AI_MEMORY_TEST_COMPOSE_OWNS:-}" = "1" ]; then
printf 'running-container-id\n'
fi
;;
inspect)
case "${4:-}" in
'{{.Id}}') printf 'running-container-id\n' ;;
'{{.Config.Image}}') printf 'akitaonrails/ai-memory:latest\n' ;;
*PortBindings*) printf '%s\n' '-p 127.0.0.1:49374:49374/tcp ' ;;
*Mounts*) printf '%s\n' '-v ai-memory-data:/data ' ;;
*RestartPolicy*) printf '%s\n' '--restart unless-stopped' ;;
'{{json .Config.Cmd}}') printf '[]\n' ;;
*'.Config.Env'*) : ;;
*) printf 'unexpected inspect format: %s\n' "${4:-<missing>}" >&2; exit 2 ;;
esac
;;
*)
printf 'unexpected docker command: %s\n' "$*" >&2
exit 2
;;
esac
DOCKER
chmod 0755 "${FAKE_DOCKER}"
run_upgrade_case() {
local name="$1" owns="$2" case_dir log output
case_dir="${TMP_ROOT}/${name}"
log="${case_dir}/docker.log"
output="${case_dir}/output.log"
mkdir -p "${case_dir}/home" "${case_dir}/cache"
: >"${case_dir}/docker-compose.yml"
(
cd "${case_dir}"
HOME="${case_dir}/home" \
XDG_CACHE_HOME="${case_dir}/cache" \
AI_MEMORY_DOCKER="${FAKE_DOCKER}" \
AI_MEMORY_SKIP_SELF_UPGRADE=1 \
AI_MEMORY_WRAPPER_TEST_LOG="${log}" \
AI_MEMORY_TEST_COMPOSE_OWNS="${owns}" \
"${ROOT}/bin/ai-memory" upgrade >"${output}" 2>&1
)
}
run_upgrade_case standalone 0
assert_contains "${TMP_ROOT}/standalone/output.log" "does not manage the running ai-memory container"
assert_not_contains "${TMP_ROOT}/standalone/docker.log" "compose up -d"
assert_contains "${TMP_ROOT}/standalone/cache/ai-memory/recreate-ai-memory.sh" "-v ai-memory-data:/data"
run_upgrade_case compose 1
assert_contains "${TMP_ROOT}/compose/output.log" "restarting local ai-memory container via docker compose"
assert_contains "${TMP_ROOT}/compose/docker.log" "compose up -d"
if [ -e "${TMP_ROOT}/compose/cache/ai-memory/recreate-ai-memory.sh" ]; then
fail "Compose-owned container unexpectedly produced a standalone recreation script"
fi
printf 'wrapper upgrade ownership checks passed\n'