Files
sxchnge/dev/bootstrap.py
T

106 lines
5.8 KiB
Python

"""Idempotent provisioning for the isolated local Compose project."""
import json
import os
import subprocess
import time
from agents import config
from vault import STATE, write
ROOT = STATE.parents[1]
COMPOSE = ['docker', 'compose', '--env-file', str(STATE / 'images.env')]
def compose(*args, capture=False, input=None):
result = subprocess.run(COMPOSE + list(args), cwd=ROOT, input=input, text=True,
capture_output=capture)
if result.returncode:
# Bootstrap stdin and child errors may contain credentials. Never echo them.
raise RuntimeError('Compose command failed: ' + ' '.join(args[:3]))
return result.stdout if capture else None
def mongo(script, password):
auth = 'const admin = db.getSiblingDB("admin"); admin.auth("local-admin", ' + json.dumps(password) + ');\n'
return compose('exec', '-T', 'mongo', 'mongosh', '--quiet', '--file', '/dev/stdin', capture=True, input=auth + script)
def provision_mongo(vault):
secret = vault.ensure('bootstrap/mongo', lambda: {'password': vault.random(), 'keyfile': vault.random(384), 'manager_password': vault.random()})
write(STATE / 'mongo' / 'password', secret['password'])
write(STATE / 'mongo' / 'keyfile', secret['keyfile'])
compose('up', '-d', 'mongo')
for attempt in range(90):
try:
mongo('try { rs.status(); } catch(e) { if(e.code === 94) rs.initiate({_id:"rs0",members:[{_id:0,host:"mongo:27017"}]}); else throw e; }', secret['password'])
if 'true' in mongo('print(db.hello().isWritablePrimary)', secret['password']):
break
except RuntimeError:
pass
time.sleep(2)
else:
raise RuntimeError('Mongo replica set did not become primary')
if vault.call('database/config/mongo', missing=True) is None:
mongo('if (!admin.getUser("vault-manager")) admin.createUser({user:"vault-manager",pwd:' + json.dumps(secret['manager_password']) + ',roles:[{role:"userAdminAnyDatabase",db:"admin"}]});', secret['password'])
vault.call('database/config/mongo', {'plugin_name': 'mongodb-database-plugin',
'allowed_roles': ['bff', 'notifications'],
'connection_url': 'mongodb://{{username}}:{{password}}@mongo:27017/admin?replicaSet=rs0',
'username': 'vault-manager', 'password': secret['manager_password']})
vault.call('database/rotate-root/mongo', {})
for role, database in [('bff', 'sxchnge'), ('notifications', 'sxchnge_notifications')]:
vault.call('database/roles/' + role, {'db_name': 'mongo',
'creation_statements': json.dumps({'db': 'admin', 'roles': [{'role': 'readWrite', 'db': database}]}),
'default_ttl': '1h', 'max_ttl': '24h'})
# Public browser client: an identifier, never a client secret in the web bundle.
mongo('db.getSiblingDB("sxchnge").clients.updateOne({clientId:"sxchnge-local-web"}, {$setOnInsert: {clientName:"Local browser",allowedCIDRs:["0.0.0.0/0","::/0"],allowedScopes:["account"],redirectURIs:["https://localhost:5126"],grantTypes:["password","refresh_token"],tokenEndpointAuthMethod:"none",isRevoked:false}}, {upsert:true});', secret['password'])
def provision_agents(vault):
paths = {'bff': ['bff', 'caller', 'tls'],
'notifications': ['caller', 'tls', 'notifications-tls', 'nats', 'email'],
'nats': ['nats']}
for service, secrets in paths.items():
directory = STATE / 'agents' / service
directory.mkdir(parents=True, exist_ok=True, mode=0o755)
os.chmod(directory, 0o755)
policy = ''.join('path "kv/data/' + p + '" { capabilities = ["read"] }\n' for p in secrets)
if service != 'nats':
policy += 'path "database/creds/' + service + '" { capabilities = ["read"] }\n'
vault.call('sys/policies/acl/' + service, {'policy': policy})
vault.call('auth/approle/role/' + service, {'token_policies': [service],
'token_period': '1h', 'secret_id_ttl': '0', 'secret_id_num_uses': 0})
role_id = vault.call('auth/approle/role/' + service + '/role-id')['data']['role_id']
write(directory / 'role-id', role_id, 0o444)
if not (directory / 'secret-id').exists():
secret_id = vault.call('auth/approle/role/' + service + '/secret-id', {})['data']['secret_id']
write(directory / 'secret-id', secret_id, 0o444)
write(directory / 'config.hcl', config(service), 0o444)
def up(vault):
if not (STATE / 'images.env').exists():
raise RuntimeError('Run make build before make up')
(STATE / 'vault').mkdir(parents=True, exist_ok=True, mode=0o755)
compose('up', '-d', 'vault')
vault.initialize()
vault.mounts()
vault.ensure('nats', lambda: {'token': vault.random()})
vault.ensure('caller', lambda: {'token': vault.random()})
vault.ensure('bff', lambda: {'jwt': vault.random()})
vault.ensure('email', lambda: {'driver': 'resend', 'api_key': '',
'sender_address': 'notifications@localhost.test', 'sender_name': 'SXCHNGE local'})
vault.certificates()
provision_mongo(vault)
provision_agents(vault)
compose('up', '-d', '--wait', '--wait-timeout', '120', 'nats')
email = vault.kv('email')
key, sender = email.get('api_key'), email.get('sender_address')
if (not isinstance(key, str) or not key.strip()
or not isinstance(sender, str) or not sender.strip()
or sender == 'notifications@localhost.test'):
raise RuntimeError('Vault kv/email needs a Resend api_key and verified sender_address. Run python3 dev/local.py email, then make up.')
compose('up', '-d', '--wait', '--wait-timeout', '180')
compose('exec', '-T', 'frontend', 'nginx', '-s', 'reload', capture=True)
print('Local stack ready: https://localhost:5126; Vault: http://127.0.0.1:18200')
print('Local CA: .local/dev/ca.pem. Email driver: ' + vault.kv('email')['driver'])