支持 Jenkins 灰度发布与独立运行数据

This commit is contained in:
2026-10-29 16:25:09 +08:00
parent ab5b3580c1
commit 27151b3ee5
30 changed files with 1284 additions and 14 deletions
+1
View File
@@ -0,0 +1 @@
"""Linux acceptance deployment entry points."""
+109
View File
@@ -0,0 +1,109 @@
#!/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) ;;
*) 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
[[ "$(docker image inspect --format '{{.Id}}' "$CHAT_IMAGE")" == "$CHAT_IMAGE_ID" ]] || 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"
auth="$CHAT_PROXY_DIR/.baiyue-logistics-chat.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
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
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 path in / /api/export_chat /api/get_history; do
code=$(curl --silent --show-error --resolve "$CHAT_DOMAIN:443:127.0.0.1" -o /dev/null -w '%{http_code}' "https://$CHAT_DOMAIN$path")
[[ "$code" == 401 ]] || { echo 'Gray access protection verification failed.' >&2; false; }
done
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"
+28
View File
@@ -0,0 +1,28 @@
[Unit]
Description=Baiyue Logistics Chat acceptance service
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=baiyue
Group=baiyue
WorkingDirectory=/opt/baiyue-logistics-chat
Environment=PYTHONUNBUFFERED=1
Environment=PYTHONDONTWRITEBYTECODE=1
ExecStart=/opt/baiyue-logistics-chat/.venv/bin/python -m waitress --listen=127.0.0.1:5000 --threads=8 --channel-timeout=300 --max-request-body-size=12582912 --url-scheme=https --call deploy.wsgi:create_app
Restart=on-failure
RestartSec=3
TimeoutStopSec=90
UMask=0077
StateDirectory=baiyue-logistics-chat
StateDirectoryMode=0700
LogsDirectory=baiyue-logistics-chat
LogsDirectoryMode=0700
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
[Install]
WantedBy=multi-user.target
+23
View File
@@ -0,0 +1,23 @@
name: baiyue-logistics-chat-gray
services:
chat:
image: "${CHAT_IMAGE:?CHAT_IMAGE is required}"
container_name: baiyue-logistics-chat-gray
pull_policy: never
restart: unless-stopped
init: true
ports:
- "127.0.0.1:${HOST_PORT:?HOST_PORT is required}:5000"
env_file:
- "${CHAT_RUNTIME_ENV:?CHAT_RUNTIME_ENV is required}"
volumes:
- "${CHAT_DATA_DIR:?CHAT_DATA_DIR is required}:/var/lib/baiyue-logistics-chat"
- "${CHAT_LOG_DIR:?CHAT_LOG_DIR is required}:/var/log/baiyue-logistics-chat"
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
stop_grace_period: 90s
+143
View File
@@ -0,0 +1,143 @@
"""Validate gray release inputs and render files without logging credentials."""
import argparse
from dataclasses import dataclass
import os
from pathlib import Path, PurePosixPath
import re
import shutil
from urllib.parse import urlsplit
def absolute_path(value):
path = PurePosixPath(value)
if not re.fullmatch(r'/[A-Za-z0-9_./-]+', value) or '..' in path.parts or str(path) == '/':
raise ValueError('A deployment path is invalid')
return str(path)
@dataclass(frozen=True)
class GrayConfig:
domain: str
port: int
root: str
release: str
proxy_dir: str
certificate: str
private_key: str
commit: str
image: str
image_id: str
certbot_image_id: str
@classmethod
def from_environment(cls, environment):
origin = urlsplit(environment['PUBLIC_ORIGIN'])
if (origin.scheme != 'https' or origin.username or origin.password or origin.port not in (None, 443)
or origin.path not in ('', '/') or origin.query or origin.fragment
or not re.fullmatch(r'[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?', origin.hostname or '')):
raise ValueError('PUBLIC_ORIGIN must be an HTTPS domain without a path')
port = int(environment['HOST_PORT'])
if not 1024 <= port <= 65535:
raise ValueError('HOST_PORT must be an unprivileged TCP port')
root = absolute_path(environment['GRAY_DEPLOY_DIR'])
if PurePosixPath(root).name != 'gray-logistics-chat':
raise ValueError('The deployment must use its dedicated gray-logistics-chat directory')
commit = environment['SOURCE_COMMIT']
image = environment['CHAT_IMAGE']
image_id = environment['CHAT_IMAGE_ID']
certbot_image_id = environment['CERTBOT_IMAGE_ID']
build = environment['BUILD_NUMBER']
if not re.fullmatch(r'[0-9a-f]{40}', commit) or not re.fullmatch(r'[1-9][0-9]*', build):
raise ValueError('Source commit or build number is invalid')
if image != f'baiyue-logistics-chat-gray:{commit[:12]}-b{build}':
raise ValueError('Image coordinates must identify the selected source and build')
if any(not re.fullmatch(r'sha256:[0-9a-f]{64}', value) for value in (image_id, certbot_image_id)):
raise ValueError('Docker image ID is invalid')
return cls(origin.hostname, port, root, f'{root}/releases/{commit[:12]}-b{build}',
absolute_path(environment['GRAY_PROXY_CONF_DIR']),
absolute_path(environment['GRAY_TLS_CERT_FILE']),
absolute_path(environment['GRAY_TLS_KEY_FILE']), commit, image, image_id, certbot_image_id)
def release_environment(self):
fields = {
'CHAT_IMAGE': self.image, 'CHAT_IMAGE_ID': self.image_id,
'CHAT_CERTBOT_IMAGE': self.image.replace('baiyue-logistics-chat-gray:', 'baiyue-logistics-chat-certbot:'),
'CHAT_CERTBOT_IMAGE_ID': self.certbot_image_id,
'SOURCE_COMMIT': self.commit, 'HOST_PORT': str(self.port),
'CHAT_ROOT': self.root, 'CHAT_RELEASE': self.release,
'CHAT_DATA_DIR': self.root + '/data', 'CHAT_LOG_DIR': self.root + '/logs',
'CHAT_RUNTIME_ENV': self.release + '/runtime.env',
'CHAT_DOMAIN': self.domain, 'CHAT_PROXY_DIR': self.proxy_dir,
'CHAT_TLS_CERT': self.certificate, 'CHAT_TLS_KEY': self.private_key,
}
return ''.join(f'{key}={value}\n' for key, value in fields.items())
def nginx_config(self):
return f'''# Managed for baiyue-logistics-chat-gray; all application routes require authentication.
server {{
listen 80;
server_name {self.domain};
location ^~ /.well-known/acme-challenge/ {{
root /var/lib/baiyue-logistics-chat-acme;
try_files $uri =404;
}}
location / {{ return 301 https://$host$request_uri; }}
}}
server {{
listen 443 ssl;
server_name {self.domain};
ssl_certificate {self.certificate};
ssl_certificate_key {self.private_key};
ssl_protocols TLSv1.2 TLSv1.3;
auth_basic "Baiyue gray acceptance";
auth_basic_user_file {self.proxy_dir}/.baiyue-logistics-chat.htpasswd;
client_max_body_size 12m;
location / {{
proxy_pass http://127.0.0.1:{self.port};
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Authorization "";
proxy_buffering off;
proxy_cache off;
gzip off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}}
}}
'''
def write_release(directory, config, key):
if not key.strip() or any(character in key for character in ('\r', '\n', '\x00')):
raise ValueError('The model credential must be a nonempty single line')
directory.mkdir(parents=True, exist_ok=False)
(directory / 'release.env').write_text(config.release_environment(), encoding='utf-8')
(directory / 'site.conf').write_text(config.nginx_config(), encoding='utf-8')
escaped = key.strip().replace('\\', '\\\\').replace("'", "\\'")
(directory / 'runtime.env').write_text("DEEPSEEK_API_KEY='" + escaped + "'\n", encoding='utf-8')
(directory / 'runtime.env').chmod(0o600)
source = Path(__file__).resolve().parent
shutil.copyfile(source / 'compose.gray.yml', directory / 'compose.yml')
shutil.copyfile(source / 'activate-gray.sh', directory / 'activate.sh')
shutil.copyfile(source / 'prepare-gray-tls.sh', directory / 'prepare-tls.sh')
shutil.copyfile(source / 'renew-gray-tls.sh', directory / 'renew-tls.sh')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('directory', type=Path)
args = parser.parse_args()
try:
config = GrayConfig.from_environment(os.environ)
write_release(args.directory, config, os.environ['DEEPSEEK_API_KEY'])
except (KeyError, ValueError) as error:
parser.exit(2, 'Gray release configuration is incomplete or invalid.\n')
if __name__ == '__main__':
main()
+16
View File
@@ -0,0 +1,16 @@
# Replace chat.example.com with your actual domain before installing.
# HTTP certificate bootstrap only; the application is not exposed here.
server {
listen 80;
listen [::]:80;
server_name chat.example.com;
location ^~ /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
try_files $uri =404;
}
location / {
return 404;
}
}
+51
View File
@@ -0,0 +1,51 @@
# Replace every chat.example.com with your actual domain before installing.
# Install only after the certificate and .baiyue-preview.htpasswd exist.
server {
listen 80;
listen [::]:80;
server_name chat.example.com;
location ^~ /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
try_files $uri =404;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name chat.example.com;
ssl_certificate /etc/letsencrypt/live/chat.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/chat.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# Guard all pages, API routes, attachments and history/export operations.
auth_basic "Baiyue private acceptance";
auth_basic_user_file /etc/nginx/.baiyue-preview.htpasswd;
# Allow multipart overhead around the application's 10 MiB file limit.
client_max_body_size 12m;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Authorization "";
# SSE must reach the browser as chunks arrive.
proxy_buffering off;
proxy_cache off;
gzip off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
set +x
set -Eeuo pipefail
umask 022
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
while IFS='=' read -r name value; do
case "$name" in
CHAT_ROOT|CHAT_RELEASE|CHAT_DOMAIN|CHAT_PROXY_DIR|CHAT_TLS_CERT|CHAT_TLS_KEY|CHAT_CERTBOT_IMAGE|CHAT_CERTBOT_IMAGE_ID|SOURCE_COMMIT)
[[ "$value" =~ ^[A-Za-z0-9_./:-]+$ ]] || exit 2
printf -v "$name" '%s' "$value" ;;
esac
done < "$release/release.env"
[[ "$CHAT_ROOT" == "$root" && "$CHAT_RELEASE" == "$release" ]] || exit 2
[[ "$CHAT_TLS_CERT" == "$root/tls/config/live/$CHAT_DOMAIN/fullchain.pem" ]] || exit 2
[[ "$CHAT_TLS_KEY" == "$root/tls/config/live/$CHAT_DOMAIN/privkey.pem" ]] || exit 2
[[ "$CHAT_CERTBOT_IMAGE" =~ ^baiyue-logistics-chat-certbot:[a-f0-9]{12}-b[0-9]+$ ]] || exit 2
[[ "$(docker image inspect --format '{{.Id}}' "$CHAT_CERTBOT_IMAGE")" == "$CHAT_CERTBOT_IMAGE_ID" ]] || exit 2
[[ "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$CHAT_CERTBOT_IMAGE")" == "$SOURCE_COMMIT" ]] || exit 2
site="$CHAT_PROXY_DIR/baiyue-logistics-chat-gray.conf"
if [[ -f "$site" ]]; then
head -n 1 "$site" | grep -Fq 'Managed for baiyue-logistics-chat-gray' || exit 2
else
for candidate in "$CHAT_PROXY_DIR/"*.conf; do
[[ -f "$candidate" ]] || continue
if grep -Fq "$CHAT_DOMAIN" "$candidate"; then echo 'Domain already belongs to another site.' >&2; exit 2; fi
done
cat > "$release/bootstrap.conf" <<BOOTSTRAP
# Managed for baiyue-logistics-chat-gray; HTTP certificate bootstrap only.
server {
listen 80;
server_name $CHAT_DOMAIN;
location ^~ /.well-known/acme-challenge/ {
root /var/lib/baiyue-logistics-chat-acme;
try_files \$uri =404;
}
location / { return 404; }
}
BOOTSTRAP
install -m 644 "$release/bootstrap.conf" "$site"
if ! nginx -t; then rm -f -- "$site"; exit 2; fi
nginx -s reload
fi
install -d -m 755 /var/lib/baiyue-logistics-chat-acme
install -d -m 700 "$root/tls" "$root/tls/config" "$root/tls/work" "$root/tls/logs"
if [[ ! -f "$CHAT_TLS_CERT" ]] || ! openssl x509 -in "$CHAT_TLS_CERT" -checkend 604800 -noout >/dev/null; then
docker run --rm --name baiyue-logistics-chat-certbot-issue \
-v "$root/tls/config:/etc/letsencrypt" -v "$root/tls/work:/var/lib/letsencrypt" \
-v "$root/tls/logs:/var/log/letsencrypt" -v /var/lib/baiyue-logistics-chat-acme:/webroot \
"$CHAT_CERTBOT_IMAGE" certonly --non-interactive --webroot -w /webroot \
--cert-name "$CHAT_DOMAIN" -d "$CHAT_DOMAIN" --agree-tos --register-unsafely-without-email
fi
openssl x509 -in "$CHAT_TLS_CERT" -checkhost "$CHAT_DOMAIN" -noout >/dev/null
install -m 700 "$release/renew-tls.sh" "$root/tls/renew.sh"
unit=/etc/systemd/system/baiyue-logistics-chat-tls-renew.service
timer=/etc/systemd/system/baiyue-logistics-chat-tls-renew.timer
for file in "$unit" "$timer"; do
if [[ -e "$file" ]]; then head -n 1 "$file" | grep -Fq 'Managed for baiyue-logistics-chat-gray' || exit 2; fi
done
cat > "$unit" <<UNIT
# Managed for baiyue-logistics-chat-gray
[Unit]
Description=Renew logistics chat gray certificate
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
ExecStart=/bin/bash $root/tls/renew.sh $root $CHAT_DOMAIN $CHAT_CERTBOT_IMAGE
UNIT
cat > "$timer" <<TIMER
# Managed for baiyue-logistics-chat-gray
[Unit]
Description=Daily logistics chat gray certificate renewal
[Timer]
OnCalendar=daily
RandomizedDelaySec=6h
Persistent=true
[Install]
WantedBy=timers.target
TIMER
chmod 644 "$unit" "$timer"
systemctl daemon-reload
systemctl enable --now baiyue-logistics-chat-tls-renew.timer
printf 'Gray certificate is ready; dedicated renewal timer enabled.\n'
+76
View File
@@ -0,0 +1,76 @@
#!/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; 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
# Transport only tested images built without runtime credentials.
docker save "$CHAT_IMAGE" "$CERTBOT_IMAGE" | gzip | run_ssh "bash -o pipefail -c 'gzip -dc | docker 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
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
set -eu
/usr/sbin/nginx -t
/usr/bin/systemctl reload nginx
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set +x
set -Eeuo pipefail
root=${1:?}
domain=${2:?}
image=${3:?}
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
[[ "$domain" =~ ^[a-z0-9.-]+$ && "$image" =~ ^baiyue-logistics-chat-certbot:[a-f0-9]{12}-b[0-9]+$ ]] || exit 2
[[ "$(cat "$root/.project")" == baiyue-logistics-chat-gray ]] || exit 2
certificate="$root/tls/config/live/$domain/fullchain.pem"
previous=$(sha256sum "$certificate")
docker run --rm --name baiyue-logistics-chat-certbot-renew \
-v "$root/tls/config:/etc/letsencrypt" -v "$root/tls/work:/var/lib/letsencrypt" \
-v "$root/tls/logs:/var/log/letsencrypt" -v /var/lib/baiyue-logistics-chat-acme:/webroot \
"$image" renew --non-interactive --cert-name "$domain"
if [[ "$(sha256sum "$certificate")" != "$previous" ]]; then nginx -t; nginx -s reload; fi
+19
View File
@@ -0,0 +1,19 @@
"""Keep Linux service data outside the read-only application checkout."""
from pathlib import Path
from logistics_chat import create_app as create_application
DATA_DIR = Path('/var/lib/baiyue-logistics-chat')
LOG_DIR = Path('/var/log/baiyue-logistics-chat')
def create_app():
return create_application({
'DATABASE': DATA_DIR / 'history.db',
'UPLOAD_DIR': DATA_DIR / 'uploads',
'BACKUP_DIR': DATA_DIR / 'backups',
'LOG_DIR': LOG_DIR,
'HOST': '127.0.0.1',
})