"""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)