支持 Jenkins 灰度发布与独立运行数据
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Linux acceptance deployment entry points."""
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
/usr/sbin/nginx -t
|
||||
/usr/bin/systemctl reload nginx
|
||||
@@ -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
|
||||
@@ -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',
|
||||
})
|
||||
Reference in New Issue
Block a user