122 lines
5.9 KiB
Python
122 lines
5.9 KiB
Python
"""Service controls using the same deployment lock and verified runtime layout."""
|
|
import json
|
|
import subprocess
|
|
|
|
READ_ACTIONS = {'status', 'log', 'info'}
|
|
WRITE_ACTIONS = {'start', 'stop', 'restart', 'enable', 'disable', 'setup'}
|
|
|
|
|
|
def setup_unit(deployment):
|
|
home = deployment.home
|
|
if not deployment.site.is_dir() or not (home / 'opt/jdk25/bin/java').is_file():
|
|
raise RuntimeError('Expected Hostsharing site and installed JDK must exist')
|
|
unit = home / '.config/systemd/user/werkjournal-backend.service'
|
|
if any(path.is_symlink() for path in (home / '.config', home / '.config/systemd', unit.parent,
|
|
unit, unit.with_name(unit.name + '.next'),
|
|
unit.with_name(unit.name + '.previous'), unit.with_name(unit.name + '.previous.next'))):
|
|
raise RuntimeError('Unit paths must not be symlinks')
|
|
if deployment.command('systemctl', '--user', 'show', deployment.service, '--property=DropInPaths', '--value'):
|
|
raise RuntimeError('Existing service overrides require operator review')
|
|
unit.parent.mkdir(parents=True, exist_ok=True)
|
|
previous = unit.read_text() if unit.exists() else None
|
|
rendered = SERVICE_UNIT_TEMPLATE.format(home=str(home))
|
|
# Keep one previous unit without reading or touching the secret EnvironmentFile.
|
|
if previous is not None and previous != rendered:
|
|
atomic(unit.with_name(unit.name + '.previous'), previous)
|
|
atomic(unit, rendered)
|
|
try:
|
|
deployment.command('systemctl', '--user', 'daemon-reload')
|
|
except Exception:
|
|
if previous is None:
|
|
unit.unlink()
|
|
sync_directory(unit.parent)
|
|
else:
|
|
atomic(unit, previous)
|
|
try:
|
|
deployment.command('systemctl', '--user', 'daemon-reload')
|
|
except Exception:
|
|
pass
|
|
raise
|
|
|
|
|
|
def service_info(deployment):
|
|
deployed = deployment.app / 'deployed.json'
|
|
before = deployed.read_bytes()
|
|
expected = json.loads(before)
|
|
selected = (deployment.app / 'current').resolve()
|
|
try:
|
|
verified = manifest(deployment.app / 'current') == expected
|
|
except (OSError, ValueError):
|
|
verified = False
|
|
def responds(url):
|
|
try:
|
|
with urllib.request.urlopen(url + '/api/v1/info', timeout=5) as response:
|
|
value = json.load(response)
|
|
return isinstance(value, dict) and value.get('application') == 'werkjournal' and value.get('version') == expected.get('version')
|
|
except (OSError, ValueError):
|
|
return False
|
|
backend = responds('http://127.0.0.1:18090')
|
|
maintenance = deployment.flag.exists()
|
|
public = None if maintenance else responds(deployment.url)
|
|
transition = (deployment.pending.exists() or (deployment.app / 'pending-restore.json').exists()
|
|
or deployed.read_bytes() != before or (deployment.app / 'current').resolve() != selected
|
|
or deployment.flag.exists() != maintenance)
|
|
result = {'version': expected.get('version'), 'commit': expected.get('commit'),
|
|
'service': deployment.command('systemctl', '--user', 'show', deployment.service, '--property=ActiveState', '--value'),
|
|
'maintenance': maintenance, 'transition': transition, 'releaseVerified': verified,
|
|
'backendReady': backend, 'publicReady': public}
|
|
print(json.dumps(result))
|
|
return 0 if verified and backend and result['service'] == 'active' and not transition and public is not False else 1
|
|
|
|
|
|
def control(deployment, action, arguments=()):
|
|
if action not in READ_ACTIONS | WRITE_ACTIONS:
|
|
raise ValueError('Unknown service action')
|
|
if arguments and action != 'log':
|
|
raise ValueError('Only log accepts additional arguments')
|
|
if action == 'status':
|
|
return subprocess.call(['systemctl', '--user', 'status', deployment.service, '--no-pager'])
|
|
if action == 'log':
|
|
return subprocess.call(['journalctl', '--user', '-u', deployment.service, '-n', '100',
|
|
*arguments, '--no-pager'])
|
|
if action == 'info':
|
|
return service_info(deployment)
|
|
|
|
with (deployment.app / '.deploy.lock').open('a') as lock:
|
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
if deployment.pending.exists():
|
|
raise RuntimeError('Resolve the pending deployment with remote backend recover first')
|
|
if (deployment.app / 'pending-restore.json').exists():
|
|
raise RuntimeError('Resolve the pending operator restore first')
|
|
if action == 'setup':
|
|
setup_unit(deployment)
|
|
print('Service unit installed and reloaded; running process unchanged.')
|
|
return 0
|
|
deployment.preflight(check_data=action in {'start', 'restart'})
|
|
# Do not expose an upload or an incompletely promoted release through restart.
|
|
if action in {'start', 'restart'}:
|
|
expected = manifest(deployment.app / 'current')
|
|
deployed = json.loads((deployment.app / 'deployed.json').read_text())
|
|
if expected != deployed:
|
|
raise RuntimeError('Current release differs from the verified deployment manifest')
|
|
if action in {'stop', 'restart'}:
|
|
deployment.stop()
|
|
if action in {'start', 'restart'}:
|
|
deployment.start()
|
|
deployment.ready(expected)
|
|
if action in {'enable', 'disable'}:
|
|
deployment.command('systemctl', '--user', action, deployment.service)
|
|
print('Service action completed: ' + action)
|
|
if deployment.flag.exists():
|
|
print('Existing maintenance remains active; this command does not remove it.')
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
os.umask(0o077)
|
|
try:
|
|
raise SystemExit(control(Deployment(Path.home()), sys.argv[1], sys.argv[2:]))
|
|
except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as error:
|
|
print('Service action failed: ' + str(error), file=sys.stderr)
|
|
raise SystemExit(1)
|