Files
baiyue-logistics-chat/deploy/gray_config.py
T

146 lines
6.2 KiB
Python

"""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
auth_dir: str = '/var/lib/baiyue-logistics-chat-gray-proxy'
@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,
'CHAT_AUTH_DIR': self.auth_dir,
}
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.auth_dir}/access.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()