107 lines
5.3 KiB
Bash
107 lines
5.3 KiB
Bash
#!/usr/bin/env bash
|
|
set +x
|
|
set -Eeuo pipefail
|
|
umask 077
|
|
|
|
: "${TARGET_SSH_KEY:?}" "${TARGET_SSH_USER:?}" "${TARGET_SSH_HOST:?}" "${TARGET_SSH_KNOWN_HOSTS:?}"
|
|
: "${PREVIEW_USERNAME:?}" "${PREVIEW_PASSWORD:?}" "${CHAT_IMAGE:?}" "${CHAT_IMAGE_ID:?}" "${CERTBOT_IMAGE:?}"
|
|
[[ "$PREVIEW_USERNAME" =~ ^[A-Za-z0-9_.@-]+$ ]] || exit 2
|
|
[[ "$TARGET_SSH_USER" =~ ^[A-Za-z0-9_.-]+$ ]] || exit 2
|
|
[[ "$TARGET_SSH_HOST" =~ ^[A-Za-z0-9.-]+$ ]] || exit 2
|
|
|
|
temporary=$(mktemp -d)
|
|
cleanup() { rm -rf -- "$temporary"; }
|
|
trap cleanup EXIT
|
|
python3 -m deploy.gray_config "$temporary/release"
|
|
release=$(awk -F= '$1 == "CHAT_RELEASE" {print $2}' "$temporary/release/release.env")
|
|
root=$(awk -F= '$1 == "CHAT_ROOT" {print $2}' "$temporary/release/release.env")
|
|
python3 - <<'TARGET'
|
|
import os
|
|
import socket
|
|
from urllib.parse import urlsplit
|
|
def addresses(host):
|
|
return {item[4][0] for item in socket.getaddrinfo(host, 443, socket.AF_INET, socket.SOCK_STREAM)}
|
|
if not addresses(urlsplit(os.environ['PUBLIC_ORIGIN']).hostname) & addresses(os.environ['TARGET_SSH_HOST']):
|
|
raise SystemExit('The public domain does not resolve to the managed gray target.')
|
|
TARGET
|
|
printf '%s:' "$PREVIEW_USERNAME" > "$temporary/release/site.htpasswd"
|
|
printf '%s\n' "$PREVIEW_PASSWORD" | openssl passwd -apr1 -stdin >> "$temporary/release/site.htpasswd"
|
|
|
|
ssh_options=(-i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes
|
|
-o "UserKnownHostsFile=$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=12)
|
|
target="$TARGET_SSH_USER@$TARGET_SSH_HOST"
|
|
run_ssh() { ssh "${ssh_options[@]}" "$target" "$@"; }
|
|
|
|
# Only an owned, dedicated directory may receive releases or runtime data.
|
|
run_ssh bash -s -- "$root" "$release" <<'PREPARE'
|
|
set -Eeuo pipefail
|
|
root=$1
|
|
release=$2
|
|
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
|
|
[[ "$release" == "$root"/releases/* ]] || exit 2
|
|
[[ "$(id -u)" == 0 ]] || { echo 'The managed gray deployment account must have the required host permissions.' >&2; exit 2; }
|
|
for program in docker nginx openssl curl ss systemctl sha256sum; do command -v "$program" >/dev/null; done
|
|
if [[ -e "$root" && ! -f "$root/.project" ]] && [[ -n "$(find "$root" -mindepth 1 -maxdepth 1 -print -quit)" ]]; then
|
|
echo 'Refusing to reuse an unowned deployment directory.' >&2
|
|
exit 2
|
|
fi
|
|
if [[ -f "$root/.project" ]]; then
|
|
[[ "$(cat "$root/.project")" == baiyue-logistics-chat-gray ]] || exit 2
|
|
fi
|
|
install -d -m 700 "$root" "$root/releases" "$root/data" "$root/logs"
|
|
printf 'baiyue-logistics-chat-gray\n' > "$root/.project"
|
|
chown 10001:10001 "$root/data" "$root/logs"
|
|
[[ ! -e "$release" ]] || { echo 'Release directory already exists; inspect its state before retrying.' >&2; exit 2; }
|
|
install -d -m 700 "$release"
|
|
PREPARE
|
|
|
|
# Verify the exact exported archive before loading. Classic and containerd image
|
|
# stores can report different IDs for the same archive, so retain target IDs too.
|
|
docker save "$CHAT_IMAGE" "$CERTBOT_IMAGE" | gzip > "$temporary/images.tar.gz"
|
|
archive_sha=$(sha256sum "$temporary/images.tar.gz" | awk '{print $1}')
|
|
scp "${ssh_options[@]}" "$temporary/images.tar.gz" "$target:$release/images.tar.gz"
|
|
run_ssh bash -s -- "$root" "$release" "$archive_sha" "$CHAT_IMAGE" "$CERTBOT_IMAGE" "$SOURCE_COMMIT" <<'LOAD'
|
|
set -Eeuo pipefail
|
|
umask 077
|
|
root=$1
|
|
release=$2
|
|
checksum=$3
|
|
app_image=$4
|
|
certbot_image=$5
|
|
source_commit=$6
|
|
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
|
|
[[ "$release" == "$root"/releases/* && "$release" != *..* ]] || exit 2
|
|
[[ "$(cat "$root/.project")" == baiyue-logistics-chat-gray ]] || exit 2
|
|
[[ "$checksum" =~ ^[a-f0-9]{64}$ && "$source_commit" =~ ^[a-f0-9]{40}$ ]] || exit 2
|
|
[[ "$app_image" =~ ^baiyue-logistics-chat-gray:[a-f0-9]{12}-b[0-9]+$ ]] || exit 2
|
|
[[ "$certbot_image" =~ ^baiyue-logistics-chat-certbot:[a-f0-9]{12}-b[0-9]+$ ]] || exit 2
|
|
printf '%s %s\n' "$checksum" "$release/images.tar.gz" | sha256sum --check --status
|
|
gzip -dc "$release/images.tar.gz" | docker load
|
|
for image in "$app_image" "$certbot_image"; do
|
|
[[ "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$image")" == "$source_commit" ]] || exit 2
|
|
done
|
|
app_id=$(docker image inspect --format '{{.Id}}' "$app_image")
|
|
certbot_id=$(docker image inspect --format '{{.Id}}' "$certbot_image")
|
|
[[ "$app_id" =~ ^sha256:[a-f0-9]{64}$ && "$certbot_id" =~ ^sha256:[a-f0-9]{64}$ ]] || exit 2
|
|
printf 'ARCHIVE_SHA256=%s\nCHAT_IMAGE_ID=%s\nCERTBOT_IMAGE_ID=%s\n' "$checksum" "$app_id" "$certbot_id" > "$release/loaded-image-ids.env"
|
|
rm -f -- "$release/images.tar.gz"
|
|
echo 'Image archive checksum and source labels verified; target image IDs recorded.'
|
|
LOAD
|
|
scp "${ssh_options[@]}" "$temporary/release/"* "$target:$release/"
|
|
run_ssh bash "$release/prepare-tls.sh" "$root" "$release"
|
|
run_ssh bash "$release/activate.sh" "$root" "$release"
|
|
|
|
# Verify the public TLS endpoint and the actual acceptance password without putting it in arguments.
|
|
python3 - <<'VERIFY'
|
|
import json
|
|
import os
|
|
from urllib.request import HTTPBasicAuthHandler, HTTPPasswordMgrWithDefaultRealm, build_opener
|
|
origin = os.environ['PUBLIC_ORIGIN'].rstrip('/')
|
|
manager = HTTPPasswordMgrWithDefaultRealm()
|
|
manager.add_password(None, origin, os.environ['PREVIEW_USERNAME'], os.environ['PREVIEW_PASSWORD'])
|
|
opener = build_opener(HTTPBasicAuthHandler(manager))
|
|
with opener.open(origin + '/healthz', timeout=30) as response:
|
|
assert response.status == 200 and json.load(response) == {'status': 'ok'}
|
|
print('Authenticated gray HTTPS health check passed.')
|
|
VERIFY
|