Files
werkjournal/scripts/service-control-remote.py
2026-09-09 14:37:20 +02:00

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)