packaging: in-image code upgrade via `coderai-docker --upgrade`

Add a runtime code-upgrade path so shipped images can pick up new code without
rebuilding or shipping an overlay image.

- launcher/coderai-upgrade: in-image upgrader. Shallow-clones the configured ref
  (default: production branch of the nexlab repo), compares its
  codai/__init__.py __version__ against the installed one, and rsyncs the fetched
  tree over /opt/coderai/app when newer (or with --force). Mirrors the build's
  path exclusions (.git/venv*/dist/…). Exit 0=updated, 10=up-to-date, else error.
  Auth over HTTPS by default, or SSH when a key path is provided.
- run_oci.sh: add --upgrade/--force/--upgrade-ref/--upgrade-repo/--ssh-key. Runs
  the in-image upgrader in a throwaway container (as root, --entrypoint) and, only
  when it reports an update, docker-commits the result back onto the SAME image
  tag — no rebuild, no new overlay. SSH key is bind-mounted read-only.
- Dockerfile.update / Dockerfile.oci: install /usr/local/bin/coderai-upgrade.
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EPLnsRpNBzWCHLgkXATqRz
parent 8a9e0183
...@@ -165,9 +165,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ...@@ -165,9 +165,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
COPY --from=builder /opt/coderai /opt/coderai COPY --from=builder /opt/coderai /opt/coderai
COPY --from=build_meta /build-manifest.json /opt/coderai/BUILD-MANIFEST.json COPY --from=build_meta /build-manifest.json /opt/coderai/BUILD-MANIFEST.json
COPY packaging/linux/launcher/coderai-oci /usr/local/bin/coderai COPY packaging/linux/launcher/coderai-oci /usr/local/bin/coderai
COPY packaging/linux/launcher/coderai-upgrade /usr/local/bin/coderai-upgrade
RUN set -eux; \ RUN set -eux; \
chmod +x /usr/local/bin/coderai; \ chmod +x /usr/local/bin/coderai /usr/local/bin/coderai-upgrade; \
mkdir -p /config /models /cache mkdir -p /config /models /cache
VOLUME ["/config", "/models", "/cache"] VOLUME ["/config", "/models", "/cache"]
......
...@@ -19,6 +19,7 @@ COPY . /opt/coderai/app ...@@ -19,6 +19,7 @@ COPY . /opt/coderai/app
COPY packaging/linux/launcher/coderai-oci /usr/local/bin/coderai COPY packaging/linux/launcher/coderai-oci /usr/local/bin/coderai
COPY packaging/linux/launcher/with-env /usr/local/bin/with-env COPY packaging/linux/launcher/with-env /usr/local/bin/with-env
COPY packaging/linux/launcher/coderai-entrypoint /usr/local/bin/coderai-entrypoint COPY packaging/linux/launcher/coderai-entrypoint /usr/local/bin/coderai-entrypoint
COPY packaging/linux/launcher/coderai-upgrade /usr/local/bin/coderai-upgrade
COPY packaging/linux/launcher/wav2lip /usr/local/bin/wav2lip COPY packaging/linux/launcher/wav2lip /usr/local/bin/wav2lip
COPY packaging/linux/launcher/sadtalker /usr/local/bin/sadtalker COPY packaging/linux/launcher/sadtalker /usr/local/bin/sadtalker
COPY packaging/linux/nginx.conf /etc/nginx/nginx.conf COPY packaging/linux/nginx.conf /etc/nginx/nginx.conf
...@@ -27,6 +28,7 @@ COPY packaging/linux/README-RUN.txt /opt/coderai/README-RUN.txt ...@@ -27,6 +28,7 @@ COPY packaging/linux/README-RUN.txt /opt/coderai/README-RUN.txt
RUN set -eux; \ RUN set -eux; \
chmod +x /usr/local/bin/coderai /usr/local/bin/with-env /usr/local/bin/coderai-entrypoint \ chmod +x /usr/local/bin/coderai /usr/local/bin/with-env /usr/local/bin/coderai-entrypoint \
/usr/local/bin/coderai-upgrade \
/usr/local/bin/wav2lip /usr/local/bin/sadtalker /opt/coderai/app/coderai; \ /usr/local/bin/wav2lip /usr/local/bin/sadtalker /opt/coderai/app/coderai; \
mkdir -p /config /models /cache /opt/coderai/app/models; \ mkdir -p /config /models /cache /opt/coderai/app/models; \
rm -rf \ rm -rf \
......
#!/usr/bin/env bash
# In-image code upgrader for the CoderAI distributable container.
#
# Runs INSIDE the container (driven by the host runner `coderai-docker --upgrade`,
# i.e. run_oci.sh). It fetches the configured branch of the CoderAI git repo,
# compares its product version (codai/__init__.py __version__) with the version
# baked into this image, and — when the remote is newer (or --force) — replaces
# the in-image application tree at /opt/coderai/app with the fetched code.
#
# The image ships WITHOUT a .git dir (it's stripped at build time), so this does
# a fresh shallow clone of the target ref rather than a `git pull`. After it
# returns, the host runner `docker commit`s the container back onto the SAME
# image tag, so the updated code persists with no Dockerfile rebuild and no new
# overlay image.
#
# Contract with the host runner (exit codes):
# 0 -> code was updated; the host should commit the container.
# 10 -> already up to date (nothing changed); host must NOT commit.
# !=0 -> error; host must NOT commit.
#
# Configuration (all optional; the host runner passes these as env):
# CODERAI_UPGRADE_REPO git URL to fetch (https or ssh). Default: the nexlab
# HTTPS repo, or its SSH form when a key is provided.
# CODERAI_UPGRADE_REF branch/tag/commit to fetch. Default: production.
# CODERAI_UPGRADE_FORCE "1" to upgrade even if not strictly newer.
# CODERAI_UPGRADE_SSH_KEY path (inside the container) to an SSH private key;
# when set, git uses it (StrictHostKeyChecking=accept-new).
set -euo pipefail
APP_DIR="/opt/coderai/app"
DEFAULT_HTTPS_REPO="https://git.nexlab.net/nexlab/coderai.git"
DEFAULT_SSH_REPO="git@git.nexlab.net:nexlab/coderai.git"
REF="${CODERAI_UPGRADE_REF:-production}"
FORCE="${CODERAI_UPGRADE_FORCE:-0}"
SSH_KEY="${CODERAI_UPGRADE_SSH_KEY:-}"
# Pick the default repo form to match the auth method: SSH URL when a key was
# supplied, HTTPS otherwise. An explicit CODERAI_UPGRADE_REPO always wins.
if [[ -n "${CODERAI_UPGRADE_REPO:-}" ]]; then
REPO="$CODERAI_UPGRADE_REPO"
elif [[ -n "$SSH_KEY" ]]; then
REPO="$DEFAULT_SSH_REPO"
else
REPO="$DEFAULT_HTTPS_REPO"
fi
log(){ printf '[upgrade] %s\n' "$*" >&2; }
die(){ printf '[upgrade] error: %s\n' "$*" >&2; exit 1; }
command -v git >/dev/null 2>&1 || die "git is not available in this image"
# Read __version__ = "x.y.z" from a codai/__init__.py file.
read_version() {
local f="$1"
[[ -f "$f" ]] || return 1
sed -n 's/^__version__ *= *["'"'"']\([^"'"'"']*\)["'"'"'].*/\1/p' "$f" | head -1
}
# Compare two dotted numeric versions. Prints: -1 if a<b, 0 if a==b, 1 if a>b.
# Non-numeric components sort as 0. Missing components are treated as 0.
vercmp() {
local a="$1" b="$2" IFS=.
local -a A=($a) B=($b)
local n=$(( ${#A[@]} > ${#B[@]} ? ${#A[@]} : ${#B[@]} ))
local i x y
for (( i=0; i<n; i++ )); do
x=${A[i]:-0}; y=${B[i]:-0}
x=$((10#${x//[^0-9]/0} + 0)) 2>/dev/null || x=0
y=$((10#${y//[^0-9]/0} + 0)) 2>/dev/null || y=0
if (( x < y )); then echo -1; return; fi
if (( x > y )); then echo 1; return; fi
done
echo 0
}
CUR_VER="$(read_version "$APP_DIR/codai/__init__.py" || true)"
[[ -n "$CUR_VER" ]] || CUR_VER="0"
log "installed version: $CUR_VER"
log "source: $REPO (ref: $REF)"
# Auth: when an SSH key path is given, drive git through it. accept-new adds the
# host key on first contact without prompting, but still rejects a changed key.
export GIT_TERMINAL_PROMPT=0
if [[ -n "$SSH_KEY" ]]; then
[[ -f "$SSH_KEY" ]] || die "ssh key not found in container at: $SSH_KEY"
export GIT_SSH_COMMAND="ssh -i '$SSH_KEY' -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o BatchMode=yes"
fi
WORK="$(mktemp -d /tmp/coderai-upgrade.XXXXXX)"
cleanup(){ rm -rf "$WORK"; }
trap cleanup EXIT
log "fetching '$REF'…"
# Shallow single-branch clone: minimal transfer, no history. A ref that is a tag
# or bare commit also works with --branch for tags; for a raw commit we fall back
# to a full-ish fetch.
if ! git clone --depth 1 --branch "$REF" --single-branch "$REPO" "$WORK/src" 2>"$WORK/clone.err"; then
if grep -qiE "Could not find remote branch|not found in upstream" "$WORK/clone.err"; then
log "ref '$REF' is not a branch/tag; trying to fetch it as a commit…"
git init -q "$WORK/src"
git -C "$WORK/src" remote add origin "$REPO"
git -C "$WORK/src" fetch -q --depth 1 origin "$REF" || { cat "$WORK/clone.err" >&2; die "fetch of ref '$REF' failed"; }
git -C "$WORK/src" checkout -q FETCH_HEAD
else
cat "$WORK/clone.err" >&2
die "clone failed (auth? network? wrong repo/ref?)"
fi
fi
NEW_VER="$(read_version "$WORK/src/codai/__init__.py" || true)"
[[ -n "$NEW_VER" ]] || die "fetched tree has no codai/__init__.py __version__"
log "fetched version: $NEW_VER"
CMP="$(vercmp "$NEW_VER" "$CUR_VER")"
if [[ "$CMP" != "1" && "$FORCE" != "1" ]]; then
if [[ "$CMP" == "0" ]]; then
log "already up to date ($CUR_VER) — nothing to do (use --force to reinstall)."
else
log "installed version ($CUR_VER) is newer than '$REF' ($NEW_VER) — refusing to downgrade (use --force)."
fi
exit 10
fi
[[ "$FORCE" == "1" && "$CMP" != "1" ]] && log "forcing upgrade to $NEW_VER (was $CUR_VER)…" \
|| log "upgrading $CUR_VER -> $NEW_VER…"
# Strip the parts that must never land in the read-only app tree — mirror the
# exclusions the image build applies (Dockerfile.update) so the in-image layout
# stays identical to a freshly built image.
rm -rf \
"$WORK/src/.git" \
"$WORK/src/venv"* \
"$WORK/src/.venv" \
"$WORK/src/township_output" \
"$WORK/src/offload" \
"$WORK/src/dist" \
"$WORK/src/.packaging-cache"
find "$WORK/src" -type d -name __pycache__ -prune -exec rm -rf '{}' + 2>/dev/null || true
# Replace the app tree in place. rsync --delete makes /opt/coderai/app exactly
# mirror the fetched (pruned) source, so files removed upstream also disappear.
# Fall back to a copy when rsync isn't present.
if command -v rsync >/dev/null 2>&1; then
rsync -a --delete "$WORK/src/./" "$APP_DIR/./"
else
log "rsync not found — using cp (stale removed-upstream files may linger)"
find "$APP_DIR" -mindepth 1 -maxdepth 1 \
! -name models -exec rm -rf '{}' + 2>/dev/null || true
cp -a "$WORK/src/." "$APP_DIR/"
fi
# Keep the entrypoint expectations intact.
mkdir -p "$APP_DIR/models"
[[ -f "$APP_DIR/coderai" ]] && chmod +x "$APP_DIR/coderai" || true
FINAL_VER="$(read_version "$APP_DIR/codai/__init__.py" || echo "$NEW_VER")"
log "done — in-image code is now $FINAL_VER"
exit 0
...@@ -17,6 +17,15 @@ ENGINE="${CONTAINER_ENGINE:-docker}" ...@@ -17,6 +17,15 @@ ENGINE="${CONTAINER_ENGINE:-docker}"
IMAGE_TAG="${OCI_IMAGE:-coderai:dist}" IMAGE_TAG="${OCI_IMAGE:-coderai:dist}"
IMAGE_EXPLICIT=0 IMAGE_EXPLICIT=0
[[ -n "${OCI_IMAGE:-}" ]] && IMAGE_EXPLICIT=1 [[ -n "${OCI_IMAGE:-}" ]] && IMAGE_EXPLICIT=1
# --upgrade: instead of running the server, refresh the in-image application code
# from the git repo's production branch (see the upgrade block near the end).
# UPGRADE_FORCE re-installs even when not strictly newer; UPGRADE_REF/REPO/SSH_KEY
# override the source branch, URL and SSH identity used inside the container.
UPGRADE=0
UPGRADE_FORCE=0
UPGRADE_REF="${CODERAI_UPGRADE_REF:-production}"
UPGRADE_REPO="${CODERAI_UPGRADE_REPO:-}"
UPGRADE_SSH_KEY="${CODERAI_UPGRADE_SSH_KEY:-}"
# Selected GPU backends. ADDITIVE: --nvidia --vulkan enables BOTH, so the # Selected GPU backends. ADDITIVE: --nvidia --vulkan enables BOTH, so the
# container gets the NVIDIA driver libs (libcuda.so.1 — needed even by a # container gets the NVIDIA driver libs (libcuda.so.1 — needed even by a
# CUDA-built llama-cpp running under Vulkan) AND /dev/dri. CPU always works. # CUDA-built llama-cpp running under Vulkan) AND /dev/dri. CPU always works.
...@@ -104,6 +113,19 @@ Usage: ...@@ -104,6 +113,19 @@ Usage:
Options: Options:
--docker Use docker (default). --docker Use docker (default).
--podman Use podman. --podman Use podman.
Upgrade (refresh the in-image code instead of running the server):
--upgrade Fetch the git production branch and, if it is newer than
the version baked into the image, replace the in-image
application code and commit it back onto the SAME image tag
(no rebuild, no overlay image). The server is NOT started.
--force With --upgrade, reinstall the fetched code even if it is
not strictly newer than what's installed.
--upgrade-ref REF Branch/tag/commit to upgrade to (default: production).
--upgrade-repo URL Git URL to fetch from (default: the nexlab HTTPS repo, or
its SSH form when --ssh-key is given).
--ssh-key PATH Host path to an SSH private key; mounted into the upgrade
container so git can authenticate over SSH.
--cpu Enable the CPU backend (always available; default if none). --cpu Enable the CPU backend (always available; default if none).
--nvidia Enable NVIDIA CUDA; adds --gpus all for Docker (maps the --nvidia Enable NVIDIA CUDA; adds --gpus all for Docker (maps the
driver incl. libcuda.so.1). driver incl. libcuda.so.1).
...@@ -193,6 +215,19 @@ while [[ $# -gt 0 ]]; do ...@@ -193,6 +215,19 @@ while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--docker) ENGINE=docker; shift ;; --docker) ENGINE=docker; shift ;;
--podman) ENGINE=podman; shift ;; --podman) ENGINE=podman; shift ;;
# In-image code upgrade (does not start the server). See the upgrade block
# after argument parsing.
--upgrade) UPGRADE=1; shift ;;
--force) UPGRADE_FORCE=1; shift ;;
--upgrade-ref)
[[ $# -ge 2 ]] || { echo "Error: --upgrade-ref requires a branch/tag/commit" >&2; exit 2; }
UPGRADE_REF="$2"; shift 2 ;;
--upgrade-repo)
[[ $# -ge 2 ]] || { echo "Error: --upgrade-repo requires a git URL" >&2; exit 2; }
UPGRADE_REPO="$2"; shift 2 ;;
--ssh-key)
[[ $# -ge 2 ]] || { echo "Error: --ssh-key requires a path" >&2; exit 2; }
UPGRADE_SSH_KEY="$2"; shift 2 ;;
--cpu) MODES[cpu]=1; shift ;; --cpu) MODES[cpu]=1; shift ;;
--nvidia|--cuda) MODES[nvidia]=1; shift ;; --nvidia|--cuda) MODES[nvidia]=1; shift ;;
--vulkan) MODES[vulkan]=1; shift ;; --vulkan) MODES[vulkan]=1; shift ;;
...@@ -319,6 +354,61 @@ if [[ "$IMAGE_EXPLICIT" -eq 0 ]]; then ...@@ -319,6 +354,61 @@ if [[ "$IMAGE_EXPLICIT" -eq 0 ]]; then
# 0 images: keep the pinned fallback (docker run will report if it's missing). # 0 images: keep the pinned fallback (docker run will report if it's missing).
fi fi
# ---------------------------------------------------------------------------
# --upgrade: refresh the in-image application code from git, then re-commit the
# SAME image tag. This does NOT start the server. We run the in-image upgrader
# (/usr/local/bin/coderai-upgrade) in a throwaway container as root (so it can
# write /opt/coderai/app), and commit only when it reports an actual update
# (exit 0). Exit 10 = already up to date (no commit); anything else = error.
# ---------------------------------------------------------------------------
if [[ "$UPGRADE" -eq 1 ]]; then
UP_NAME="${NAME}-upgrade-$$"
up_args=(run --name "$UP_NAME" --entrypoint /usr/local/bin/coderai-upgrade
-e "CODERAI_UPGRADE_REF=$UPGRADE_REF"
-e "CODERAI_UPGRADE_FORCE=$UPGRADE_FORCE")
[[ -n "$UPGRADE_REPO" ]] && up_args+=(-e "CODERAI_UPGRADE_REPO=$UPGRADE_REPO")
if [[ -n "$UPGRADE_SSH_KEY" ]]; then
[[ -f "$UPGRADE_SSH_KEY" ]] || { echo "Error: --ssh-key '$UPGRADE_SSH_KEY' not found" >&2; exit 2; }
_key_abs="$(cd "$(dirname "$UPGRADE_SSH_KEY")" && pwd)/$(basename "$UPGRADE_SSH_KEY")"
up_args+=(-v "$_key_abs:/tmp/coderai_upgrade_key:ro" -e "CODERAI_UPGRADE_SSH_KEY=/tmp/coderai_upgrade_key")
fi
up_args+=("$IMAGE_TAG")
echo "== CoderAI in-image upgrade =="
echo " engine: $ENGINE"
echo " image: $IMAGE_TAG"
echo " ref: $UPGRADE_REF${UPGRADE_FORCE:+ (force)}"
echo " auth: ${UPGRADE_SSH_KEY:+ssh key $UPGRADE_SSH_KEY}${UPGRADE_SSH_KEY:-https/anonymous}"
# Make sure a stale upgrade container from an aborted run doesn't block us.
"$ENGINE" rm -f "$UP_NAME" >/dev/null 2>&1 || true
set +e
"$ENGINE" "${up_args[@]}"
rc=$?
set -e
if [[ "$rc" -eq 0 ]]; then
echo "== committing updated code back onto '$IMAGE_TAG' =="
if "$ENGINE" commit "$UP_NAME" "$IMAGE_TAG" >/dev/null; then
echo "== upgrade complete: '$IMAGE_TAG' now carries the new code. =="
echo " Restart the server to pick it up (e.g. stop the container and re-run coderai-docker)."
else
echo "Error: commit failed; image left unchanged." >&2
"$ENGINE" rm -f "$UP_NAME" >/dev/null 2>&1 || true
exit 1
fi
elif [[ "$rc" -eq 10 ]]; then
echo "== no upgrade applied (already up to date); image unchanged. =="
else
echo "Error: upgrade failed (exit $rc); image left unchanged." >&2
"$ENGINE" rm -f "$UP_NAME" >/dev/null 2>&1 || true
exit "$rc"
fi
"$ENGINE" rm -f "$UP_NAME" >/dev/null 2>&1 || true
exit 0
fi
# For --local, default the runtime dir to a stable per-user location under # For --local, default the runtime dir to a stable per-user location under
# ~/.config instead of the current directory (unless --data-dir was given). # ~/.config instead of the current directory (unless --data-dir was given).
if [[ "$IS_LOCAL" -eq 1 && "$DATA_ROOT_EXPLICIT" -eq 0 ]]; then if [[ "$IS_LOCAL" -eq 1 && "$DATA_ROOT_EXPLICIT" -eq 0 ]]; then
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment