#!/usr/bin/env bash set +x set -Eeuo pipefail umask 077 root=${1:?} release=${2:?} [[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2 [[ "$release" == "$root"/releases/* && "$release" != *..* ]] || exit 2 [[ "$(cat "$root/.project")" == baiyue-logistics-chat-gray ]] || exit 2 # Parse a small allowlist of non-secret release fields; never source a credential file. while IFS='=' read -r name value; do case "$name" in CHAT_IMAGE|CHAT_IMAGE_ID|CHAT_CERTBOT_IMAGE|CHAT_CERTBOT_IMAGE_ID|SOURCE_COMMIT|HOST_PORT|CHAT_ROOT|CHAT_RELEASE|CHAT_DATA_DIR|CHAT_LOG_DIR|CHAT_RUNTIME_ENV|CHAT_DOMAIN|CHAT_PROXY_DIR|CHAT_TLS_CERT|CHAT_TLS_KEY|CHAT_AUTH_DIR) ;; *) echo 'Unexpected release setting.' >&2; exit 2 ;; esac [[ "$value" =~ ^[A-Za-z0-9_./:-]+$ ]] || exit 2 printf -v "$name" '%s' "$value" done < "$release/release.env" [[ "$CHAT_ROOT" == "$root" && "$CHAT_RELEASE" == "$release" ]] || exit 2 [[ "$CHAT_DATA_DIR" == "$root/data" && "$CHAT_LOG_DIR" == "$root/logs" && "$CHAT_RUNTIME_ENV" == "$release/runtime.env" ]] || exit 2 [[ "$SOURCE_COMMIT" =~ ^[a-f0-9]{40}$ && "$CHAT_IMAGE_ID" =~ ^sha256:[a-f0-9]{64}$ ]] || exit 2 [[ "$CHAT_IMAGE" =~ ^baiyue-logistics-chat-gray:[a-f0-9]{12}-b[0-9]+$ ]] || exit 2 [[ "$HOST_PORT" =~ ^[0-9]+$ ]] && ((HOST_PORT >= 1024 && HOST_PORT <= 65535)) || exit 2 [[ -d "$CHAT_PROXY_DIR" && -r "$CHAT_TLS_CERT" && -r "$CHAT_TLS_KEY" ]] || exit 2 loaded_id=$(awk -F= '$1 == "CHAT_IMAGE_ID" {print $2}' "$release/loaded-image-ids.env") [[ "$loaded_id" =~ ^sha256:[a-f0-9]{64}$ ]] || exit 2 [[ "$(docker image inspect --format '{{.Id}}' "$CHAT_IMAGE")" == "$loaded_id" ]] || { echo 'Loaded application image changed.' >&2; exit 2; } [[ "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$CHAT_IMAGE")" == "$SOURCE_COMMIT" ]] || exit 2 openssl x509 -in "$CHAT_TLS_CERT" -checkhost "$CHAT_DOMAIN" -noout >/dev/null container=baiyue-logistics-chat-gray if docker container inspect "$container" >/dev/null 2>&1; then [[ "$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' "$container")" == baiyue-logistics-chat-gray ]] || exit 2 old_port=$(docker port "$container" 5000/tcp || true) else old_port='' fi if [[ -n "$(ss -H -ltn "sport = :$HOST_PORT")" && "$old_port" != "127.0.0.1:$HOST_PORT" ]]; then echo 'The requested host port belongs to another service.' >&2 exit 2 fi site="$CHAT_PROXY_DIR/baiyue-logistics-chat-gray.conf" [[ "$CHAT_AUTH_DIR" == /*/baiyue-logistics-chat-gray-proxy && "$CHAT_AUTH_DIR" != *..* ]] || exit 2 auth="$CHAT_AUTH_DIR/access.htpasswd" if [[ -f "$site" ]] && ! head -n 1 "$site" | grep -Fq 'Managed for baiyue-logistics-chat-gray'; then echo 'The proxy file is not owned by this application.' >&2 exit 2 fi for candidate in "$CHAT_PROXY_DIR/"*.conf; do [[ -f "$candidate" && "$candidate" != "$site" ]] || continue if grep -Fq "$CHAT_DOMAIN" "$candidate"; then echo 'The requested domain is already configured by another site.' >&2 exit 2 fi done # The Nginx config directory may be root-only (for example BaoTa). Keep this # application's password hash in a separate worker-readable owned directory. if [[ -e "$CHAT_AUTH_DIR" ]]; then [[ -f "$CHAT_AUTH_DIR/.project" && "$(cat "$CHAT_AUTH_DIR/.project")" == baiyue-logistics-chat-gray ]] || exit 2 fi install -d -m 755 "$CHAT_AUTH_DIR" printf 'baiyue-logistics-chat-gray\n' > "$CHAT_AUTH_DIR/.project" previous='' if [[ -f "$root/current-release" ]]; then previous=$(cat "$root/current-release") [[ "$previous" == "$root"/releases/* && "$previous" != *..* && -f "$previous/release.env" ]] || exit 2 fi [[ ! -f "$site" ]] || cp -p "$site" "$release/previous-site.conf" [[ ! -f "$auth" ]] || cp -p "$auth" "$release/previous-site.htpasswd" chmod 600 "$release/runtime.env" compose() { docker compose --env-file "$1/release.env" -f "$1/compose.yml" "${@:2}"; } activated=0 proxy_changed=0 rollback() { result=$? trap - ERR # ERR is inherited by command substitutions; only the parent may roll back. if ((BASH_SUBSHELL)); then exit "$result"; fi set +e if ((proxy_changed)); then if [[ -f "$release/previous-site.conf" ]]; then cp -p "$release/previous-site.conf" "$site"; else rm -f -- "$site"; fi if [[ -f "$release/previous-site.htpasswd" ]]; then cp -p "$release/previous-site.htpasswd" "$auth"; else rm -f -- "$auth"; fi nginx -t && nginx -s reload fi if ((activated)); then if [[ -n "$previous" ]]; then compose "$previous" up -d --wait --wait-timeout 120 else compose "$release" stop chat compose "$release" rm -f chat fi fi echo 'Gray activation failed; previous application/proxy configuration was restored where available. Persistent data was retained.' >&2 exit "$result" } trap rollback ERR if [[ -f "$root/data/history.db" ]] && docker container inspect "$container" >/dev/null 2>&1; then docker exec "$container" python -c 'import pathlib,sqlite3,sys; p=pathlib.Path("/var/lib/baiyue-logistics-chat/backups");p.mkdir(exist_ok=True); source=sqlite3.connect("/var/lib/baiyue-logistics-chat/history.db"); target=sqlite3.connect(str(p/("pre-release-"+sys.argv[1]+".db"))); source.backup(target);target.close();source.close()' "$(basename "$release")" fi compose "$release" config --quiet activated=1 compose "$release" up -d --wait --wait-timeout 120 curl --fail --silent --show-error "http://127.0.0.1:$HOST_PORT/healthz" >/dev/null proxy_changed=1 install -m 644 "$release/site.htpasswd" "$auth" install -m 644 "$release/site.conf" "$site" nginx -t nginx -s reload for check in /:200 /admin/dashboard:401 /api/export_chat:401 /api/get_history:401 /api/human/queue:401; do path=${check%:*} expected=${check##*:} code='' # A successful reload signal precedes the new workers accepting connections. for attempt in {1..10}; do if code=$(curl --silent --connect-timeout 3 --max-time 8 --resolve "$CHAT_DOMAIN:443:127.0.0.1" -o /dev/null -w '%{http_code}' "https://$CHAT_DOMAIN$path"); then [[ "$code" != "$expected" ]] || break fi sleep 1 done [[ "$code" == "$expected" ]] || { echo 'Guest/staff access verification failed.' >&2; false; } done visitor_cookie=$(mktemp "$release/.visitor-smoke.XXXXXX") trap 'rm -f -- "$visitor_cookie"' EXIT code=$(curl --silent --show-error --max-time 10 --resolve "$CHAT_DOMAIN:443:127.0.0.1" \ -c "$visitor_cookie" -o /dev/null -w '%{http_code}' -H 'Content-Type: application/json' \ -d '{}' "https://$CHAT_DOMAIN/api/visitor/session") [[ "$code" == 200 ]] || { echo 'Guest session bootstrap verification failed.' >&2; false; } code=$(curl --silent --show-error --max-time 10 --resolve "$CHAT_DOMAIN:443:127.0.0.1" \ -b "$visitor_cookie" -o /dev/null -w '%{http_code}' "https://$CHAT_DOMAIN/api/get_history") [[ "$code" == 200 ]] || { echo 'Guest history verification failed.' >&2; false; } rm -f -- "$visitor_cookie" trap - EXIT printf '%s\n' "$release" > "$root/.current-release.new" mv -f "$root/.current-release.new" "$root/current-release" trap - ERR printf 'Gray service activated at revision %s.\n' "$SOURCE_COMMIT"