Files
go-sip/deploys/preprod/preprod.py
T

170 lines
8.2 KiB
Python

#!/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)