Recreate containers when a deploy rebuilds their image

podman-compose only recreates containers when the compose file's own hash
changes. A push that only changed code built a new image, but the running
containers kept the old one. Deploys now build first and pass
--force-recreate when a container's image ID no longer matches its tag.

Compose calls also pass -p with the app's name. Without it, compose named
the project after the compose file's directory, so every git app
(stacks/<app>/repo/compose.yaml) was the project "repo". That broke the
terminal and monitoring checks, and --remove-orphans could remove another
git app's containers. On its next deploy or restart, an app's containers
from the old project are removed and recreated under the new name. An app
whose containers use named volumes keeps the old project (recorded as
APP_COMPOSE_PROJECT), since its volumes are named after it; for those apps
panelctl skips --remove-orphans.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UbWSNkXxZhYf7eqHTyx3Bf
This commit is contained in:
agent 2026-09-27 20:40:14 +00:00
parent d9d0136d70
commit 0cf5c4bc02

View file

@ -17,6 +17,7 @@ PANEL_GROUP="${PANEL_GROUP:-panelroutes}"
# as KEY=VALUE words (passed to compose via env(1), never sourced).
CURRENT_APP=""
COMPOSE_ARGS=()
ORPHAN_ARGS=()
APP_ENV_ARGS=()
APP_ENV_KEYS=()
@ -198,6 +199,7 @@ load_app() {
local manifest
manifest="$(app_manifest "${name}")"
[[ -f "${manifest}" ]] || fail "app '${name}' does not exist"
unset APP_COMPOSE_PROJECT
# shellcheck disable=SC1090
source "${manifest}"
@ -235,10 +237,20 @@ app_ports_override() {
echo "${STACKS_DIR}/$1/.panel-ports.yaml"
}
# The compose project the app's containers belong to: the app's name, unless
# the manifest keeps an older one (see adopt_legacy_project).
compose_project() {
echo "${APP_COMPOSE_PROJECT:-${CURRENT_APP}}"
}
# The app's compose file followed by whichever generated overrides exist.
refresh_compose_args() {
local override
COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}")
COMPOSE_ARGS=(-p "$(compose_project)" -f "${APP_COMPOSE_FILE}")
# Other git apps may share a kept legacy project; their containers would
# look like orphans.
ORPHAN_ARGS=(--remove-orphans)
[[ "$(compose_project)" == "${CURRENT_APP}" ]] || ORPHAN_ARGS=()
for override in "$(app_ports_override "${CURRENT_APP}")" "$(app_env_override "${CURRENT_APP}")"; do
if [[ -f "${override}" ]]; then
COMPOSE_ARGS+=(-f "${override}")
@ -597,6 +609,56 @@ cmd_render_route() {
log info "rendered route ${route_file}"
}
# Before panelctl passed -p, compose named the project after the compose
# file's directory, so every git app (repo/compose.yaml) shared the project
# "repo". Move the app's old containers out of the way so they are recreated
# under its own name. Named volumes are prefixed with the project name, so an
# app whose containers use them keeps its old project instead.
adopt_legacy_project() {
[[ -z "${APP_COMPOSE_PROJECT:-}" ]] || return 0
local dir id project legacy="" volumes
local ids=()
dir="$(cd "$(dirname "${APP_COMPOSE_FILE}")" 2>/dev/null && pwd -P)" || return 0
while IFS='|' read -r id project; do
[[ -n "${id}" && -n "${project}" && "${project}" != "${CURRENT_APP}" ]] || continue
ids+=("${id}")
legacy="${project}"
done < <(run_podman ps --all --filter "label=com.docker.compose.project.working_dir=${dir}" \
--format '{{.ID}}|{{index .Labels "com.docker.compose.project"}}' 2>/dev/null || true)
[[ ${#ids[@]} -gt 0 ]] || return 0
volumes="$(run_podman inspect --format '{{range .Mounts}}{{if eq .Type "volume"}}{{.Name}} {{end}}{{end}}' "${ids[@]}" 2>/dev/null | xargs || true)"
if [[ -n "${volumes}" ]]; then
echo "APP_COMPOSE_PROJECT=\"${legacy}\"" >>"$(app_manifest "${CURRENT_APP}")"
APP_COMPOSE_PROJECT="${legacy}"
refresh_compose_args
echo "Keeping compose project '${legacy}', because the app's containers use named volumes (${volumes})"
log warning "app '${CURRENT_APP}' keeps compose project '${legacy}' because its containers use named volumes"
return 0
fi
echo "Moving the app's containers from compose project '${legacy}' to '${CURRENT_APP}'"
run_podman rm --force "${ids[@]}" >/dev/null || fail "could not remove the old containers"
# The shared pod goes away once no other app's containers are left in it.
run_podman pod rm "pod_${legacy}" >/dev/null 2>&1 || true
}
# True when a container of the app runs an older image than its tag now
# points at, e.g. after a rebuild. podman-compose only recreates containers
# when the compose file itself changes, so without this a push that only
# changes code would build a new image and keep running the old one.
containers_outdated() {
local id image current
while IFS='|' read -r id image; do
[[ -n "${id}" && -n "${image}" ]] || continue
current="$(run_podman image inspect --format '{{.Id}}' "${image}" 2>/dev/null)" || continue
[[ "${current}" == "${id}"* || "${id}" == "${current}"* ]] || return 0
done < <(run_podman ps --all --filter "label=com.docker.compose.project=$(compose_project)" \
--format '{{.ImageID}}|{{.Image}}' 2>/dev/null || true)
return 1
}
cmd_deploy() {
local name="$1"
validate_name "${name}"
@ -607,10 +669,21 @@ cmd_deploy() {
cmd_render_route "${name}"
prepare_ports_override "${name}"
prepare_env_override "${name}"
adopt_legacy_project
# Stream compose output as it happens (the panel shows it live in the
# deployment log) and keep a copy in the journal.
if ! run_compose "${COMPOSE_ARGS[@]}" up -d --build --remove-orphans 2>&1 | tee >(journal_copy); then
if ! run_compose "${COMPOSE_ARGS[@]}" build 2>&1 | tee >(journal_copy); then
log err "Deployment failed for app '${name}'"
fail "image build failed"
fi
local up_args=(up -d --no-build "${ORPHAN_ARGS[@]}")
if containers_outdated; then
echo "Images changed, recreating the app's containers"
up_args+=(--force-recreate)
fi
if ! run_compose "${COMPOSE_ARGS[@]}" "${up_args[@]}" 2>&1 | tee >(journal_copy); then
log err "Deployment failed for app '${name}'"
fail "compose up failed"
fi
@ -625,11 +698,12 @@ cmd_restart() {
log info "Restarting app '${name}'"
run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans || fail "compose down failed"
adopt_legacy_project
run_compose "${COMPOSE_ARGS[@]}" down "${ORPHAN_ARGS[@]}" || fail "compose down failed"
prepare_ports_override "${name}"
prepare_env_override "${name}"
if ! run_compose "${COMPOSE_ARGS[@]}" up -d --build --remove-orphans 2>&1; then
if ! run_compose "${COMPOSE_ARGS[@]}" up -d --build "${ORPHAN_ARGS[@]}" 2>&1; then
fail "compose up failed during restart"
fi
@ -641,7 +715,7 @@ cmd_stop() {
validate_name "${name}"
load_app "${name}"
run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans || fail "compose down failed"
run_compose "${COMPOSE_ARGS[@]}" down "${ORPHAN_ARGS[@]}" || fail "compose down failed"
log info "stopped app '${name}'"
}
@ -742,7 +816,7 @@ cmd_remove() {
validate_name "${name}"
load_app "${name}"
run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans 2>/dev/null || true
run_compose "${COMPOSE_ARGS[@]}" down "${ORPHAN_ARGS[@]}" 2>/dev/null || true
# Remove this app's block from the aggregate routes file.
local route_file
@ -878,7 +952,7 @@ cmd_volume_clear() {
load_app "${name}"
log info "clearing volume data for app '${name}'"
run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans 2>/dev/null || true
run_compose "${COMPOSE_ARGS[@]}" down "${ORPHAN_ARGS[@]}" 2>/dev/null || true
local data_dir="${APP_VOLUME_DIR}/data"
if [[ -d "${data_dir}" ]]; then