"""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; guest chat is public and staff 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; 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; location = / {{ auth_basic off; proxy_pass http://127.0.0.1:{self.port}; }} location ^~ /static/ {{ auth_basic off; proxy_pass http://127.0.0.1:{self.port}; }} location ^~ /uploads/ {{ auth_basic off; proxy_pass http://127.0.0.1:{self.port}; }} location ~ ^/(?:healthz|stream_chat|api/(?:visitor/(?:session|human/(?:request|poll|send|close))|customer/(?:session|login|logout)|new_chat|get_history|get_messages|del_history|clear_all|upload|ticket|rate|feedback|speech/(?:config|transcribe)))$ {{ auth_basic off; proxy_pass http://127.0.0.1:{self.port}; }} location / {{ proxy_pass http://127.0.0.1:{self.port}; }} }} ''' def runtime_environment(key, speech=None): fields = {'DEEPSEEK_API_KEY': key} speech = speech or {} provider = (speech.get('ASR_PROVIDER') or '').strip() if provider: if provider != 'baidu': raise ValueError('Gray speech provider must be baidu or disabled') fields.update(ASR_PROVIDER=provider, BAIDU_ASR_API_KEY=speech.get('BAIDU_ASR_API_KEY', ''), BAIDU_ASR_SECRET_KEY=speech.get('BAIDU_ASR_SECRET_KEY', '')) for value in fields.values(): if not isinstance(value, str) or not value.strip() or any(c in value for c in ('\r', '\n', '\x00')): raise ValueError('Runtime credentials must be nonempty single lines') def literal(value): return "'" + value.strip().replace('\\', '\\\\').replace("'", "\\'") + "'" return ''.join(name + '=' + literal(value) + '\n' for name, value in fields.items()) def write_release(directory, config, key, speech=None): runtime = runtime_environment(key, speech) 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') (directory / 'runtime.env').write_text(runtime, 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'], os.environ) except (KeyError, ValueError) as error: parser.exit(2, 'Gray release configuration is incomplete or invalid.\n') if __name__ == '__main__': main()