Files

142 lines
6.9 KiB
Bash

#!/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"