146 lines
6.2 KiB
Python
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()
|