170 lines
8.2 KiB
Python
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)
|