#!/usr/bin/env python3 """Deploy only the registered nonproduction node; never execute source env files.""" import argparse import hashlib import json import os from pathlib import Path import shlex import stat import subprocess import tempfile import urllib.parse import uuid HERE = Path(__file__).resolve().parent REPO = HERE.parent.parent def private_text(path): path = Path(path) s = path.stat() if path.is_symlink() or s.st_uid != os.getuid() or stat.S_IMODE(s.st_mode) != 0o600: raise ValueError('private source ownership or permissions invalid') return path.read_text() def literal(value): value = value.strip() if value.startswith(('"', "'")): try: parts = shlex.split(value) except ValueError: raise ValueError('invalid quoted configuration') from None if len(parts) != 1: raise ValueError('ambiguous quoted configuration') return parts[0] return value def read_env(path): result = {} for line in private_text(path).splitlines(): line = line.strip() if not line or line.startswith(('#', '//', ';')): continue if '=' not in line: raise ValueError('unsupported env syntax') key, value = line.split('=', 1) key = key.strip() if key in result: raise ValueError('duplicate env field') result[key] = literal(value) return result def read_oss(path): result, section = {}, '' for raw in private_text(path).splitlines(): if not raw.strip() or raw.lstrip().startswith('#'): continue if ':' not in raw: raise ValueError('unsupported OSS source syntax') key, value = raw.strip().split(':', 1) if key == 'RAM' and not value.strip(): section = 'RAM' continue # This historical file is colon-delimited, not YAML: RAM children # follow the RAM heading even when they are not indented. name = section + '.' + key if section == 'RAM' and key in ('username', 'accessKeyId', 'accessKeySecret') else key if name in result: raise ValueError('duplicate OSS field') result[name] = literal(value) needed = {'bucket', 'Endpoint', 'Region', 'RAM.username', 'RAM.accessKeyId', 'RAM.accessKeySecret'} if not needed.issubset(result) or any(not result[k] for k in needed): raise ValueError('missing required OSS field') return result def sources(root): root = Path(root) s, q, o = read_env(root / 'saas.env'), read_env(root / 'rabbitmq.env'), read_oss(root / 'aliyun-oss.env') for values, needed in ((s, {'DispatcherUUID', 'DispatcherKEY', 'SaaSBaseURL'}), (q, {'RABBITMQ_HOST', 'RABBITMQ_PORT', 'RABBITMQ_USER', 'RABBITMQ_PASS', 'RABBITMQ_VHOST'})): if set(values) != needed or any(not values[k] for k in needed): raise ValueError('missing or unsupported source field') uuid.UUID(s['DispatcherUUID']) parsed = urllib.parse.urlsplit(s['SaaSBaseURL']) if parsed.scheme not in ('http', 'https') or not parsed.hostname or parsed.username or parsed.query or parsed.fragment: raise ValueError('invalid SaaS base URL') port = int(q['RABBITMQ_PORT']) if not 1 <= port <= 65535 or any(c in q['RABBITMQ_HOST'] for c in '/@\r\n '): raise ValueError('invalid RabbitMQ endpoint') quote = lambda v: urllib.parse.quote(v, safe='') host = q['RABBITMQ_HOST'] if ':' in host: host = '[' + host + ']' mq_url = 'amqp://' + quote(q['RABBITMQ_USER']) + ':' + quote(q['RABBITMQ_PASS']) + '@' + host + ':' + str(port) + '/' + quote(q['RABBITMQ_VHOST']) return {'dispatcher_id': s['DispatcherUUID'], 'secret': s['DispatcherKEY'], 'saas_url': s['SaaSBaseURL'], 'mq_url': mq_url, 'oss': o, 'probe': {'saas_url': s['SaaSBaseURL'], 'dispatcher_id': s['DispatcherUUID'], 'secret': s['DispatcherKEY'], 'mq_host': q['RABBITMQ_HOST'], 'mq_port': port, 'mq_user': q['RABBITMQ_USER'], 'mq_password': q['RABBITMQ_PASS'], 'mq_vhost': q['RABBITMQ_VHOST']}} def checked(args, **kwargs): r = subprocess.run(args, capture_output=True, text=True, **kwargs) if r.returncode: if args[0] == 'ssh': try: report = json.loads(r.stdout) if report.get('ok') is False: return r.stdout.strip() except (ValueError, AttributeError): pass raise ValueError('command failed: ' + Path(args[0]).name) return r.stdout.strip() def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('action', choices=['deploy', 'start', 'stop', 'status', 'retire-mocks', 'bootstrap']) parser.add_argument('--host', default='server.sip') parser.add_argument('--known-hosts', type=Path, default=REPO / '.local/agent-call-known_hosts') parser.add_argument('--source-dir', type=Path, default=REPO) parser.add_argument('--reset-state', action='store_true') parser.add_argument('--confirm-reset', action='store_true', help='explicit current operator confirmation; never automatic') args = parser.parse_args() if args.host != 'server.sip': parser.error('this deployment is authorized only for server.sip') if args.reset_state and (args.action != 'deploy' or not args.confirm_reset): parser.error('--reset-state requires deploy and --confirm-reset') if not args.known_hosts.is_file(): parser.error('registered known_hosts is required') opts = ['-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', '-o', 'UserKnownHostsFile=' + str(args.known_hosts.resolve()), '-o', 'GlobalKnownHostsFile=/dev/null', '-o', 'HostKeyAlgorithms=ssh-ed25519', '-o', 'UpdateHostKeys=no', '-o', 'ConnectTimeout=15'] ssh = lambda cmd, **kw: checked(['ssh', *opts, args.host, cmd], timeout=180, **kw) tool_dir = '/home/rogee/.local/share/go-sip-tools' ssh("python3 -c \"from pathlib import Path;import os;p=Path('" + tool_dir + "');p.mkdir(mode=0o700,exist_ok=True);os.chmod(p,0o700)\"") checked(['scp', '-q', *opts, str(HERE / 'remote.py'), args.host + ':' + tool_dir + '/preprod-remote.py'], timeout=30) payload = {'action': args.action, 'reset_state': args.reset_state, 'confirm_reset': args.confirm_reset} if args.action in ('deploy', 'bootstrap', 'retire-mocks'): payload['source'] = sources(args.source_dir) if args.action == 'deploy': branch = checked(['git', 'branch', '--show-current'], cwd=REPO) if branch != 'main' or checked(['git', 'status', '--porcelain'], cwd=REPO): raise ValueError('deploy requires a clean main checkout') commit = checked(['git', 'rev-parse', 'HEAD'], cwd=REPO) remote_commit = checked(['git', 'ls-remote', 'origin', 'refs/heads/main'], cwd=REPO).split()[0] if commit != remote_commit: raise ValueError('main is not pushed') build_root = Path(tempfile.mkdtemp(prefix='go-sip-preprod-build-')) try: for name, source in (('sip-go-agent', './cmd/sip-go-agent'), ('preprod-probe', './deploys/preprod')): env = dict(os.environ, CGO_ENABLED='0', GOOS='linux', GOARCH='amd64') checked(['go', 'build', '-trimpath', '-o', str(build_root / name), source], cwd=REPO, env=env, timeout=180) staging = tool_dir + '/' + commit ssh('mkdir -m 700 -p ' + shlex.quote(staging)) for name in ('sip-go-agent', 'preprod-probe'): checked(['scp', '-q', *opts, str(build_root / name), args.host + ':' + staging + '/' + name], timeout=60) payload.update({'commit': commit, 'staging': staging, 'hashes': {name: hashlib.sha256((build_root / name).read_bytes()).hexdigest() for name in ('sip-go-agent', 'preprod-probe')}}) finally: __import__('shutil').rmtree(build_root) raw = ssh('python3 ' + shlex.quote(tool_dir + '/preprod-remote.py'), input=json.dumps(payload)) result = json.loads(raw) print(json.dumps(result, indent=2)) if not result.get('ok'): raise SystemExit(1) if __name__ == '__main__': try: main() except (ValueError, OSError, subprocess.TimeoutExpired) as e: print(json.dumps({'ok': False, 'error_class': type(e).__name__, 'reason': str(e) if isinstance(e, ValueError) else 'deployment operation failed'})) raise SystemExit(1)