/// systemd service [Type]. enum SystemdType { simple, exec, forking, oneshot, notify } /// systemd Restart= policy. enum RestartPolicy { onFailure, always, no } extension RestartPolicyX on RestartPolicy { String get value => switch (this) { RestartPolicy.onFailure => 'on-failure', RestartPolicy.always => 'always', RestartPolicy.no => 'no', }; String get label => switch (this) { RestartPolicy.onFailure => 'On failure', RestartPolicy.always => 'Always', RestartPolicy.no => 'Never', }; } /// Everything needed to generate and register a `.service` unit file. class SystemdSpec { String unitName; // e.g. "orders" -> orders.service String description; SystemdType type; String user; String group; String workingDirectory; String execStart; RestartPolicy restart; int restartSec; /// Raw "KEY=VALUE" lines. String environmentLines; /// Extra `After=`/`Wants=` targets, e.g. mariadb.service. List afterUnits; /// Add `SuccessExitStatus=143` (SIGTERM) — recommended for JVM apps. bool javaSuccessExit; bool enableOnBoot; bool startNow; SystemdSpec({ this.unitName = '', this.description = '', this.type = SystemdType.simple, this.user = 'root', this.group = '', this.workingDirectory = '', this.execStart = '', this.restart = RestartPolicy.onFailure, this.restartSec = 5, this.environmentLines = '', List? afterUnits, this.javaSuccessExit = false, this.enableOnBoot = true, this.startNow = true, }) : afterUnits = afterUnits ?? ['network.target']; /// Sanitized unit name (no spaces / .service suffix). String get safeName { var n = unitName.trim(); if (n.toLowerCase().endsWith('.service')) { n = n.substring(0, n.length - '.service'.length); } return n.replaceAll(RegExp(r'[^A-Za-z0-9_.@-]'), '-'); } List get envList => environmentLines .split('\n') .map((e) => e.trim()) .where((e) => e.isNotEmpty && e.contains('=')) .toList(); /// Renders the unit file contents. String render() { final b = StringBuffer(); b.writeln('# Generated by PeachDeployments'); b.writeln('[Unit]'); b.writeln( 'Description=${description.trim().isEmpty ? safeName : description.trim()}', ); final afters = afterUnits.where((a) => a.trim().isNotEmpty).toList(); if (afters.isNotEmpty) { b.writeln('After=${afters.join(' ')}'); // Wants only the non-core targets (don't hard-require network.target). final wants = afters.where((a) => a != 'network.target').toList(); if (wants.isNotEmpty) b.writeln('Wants=${wants.join(' ')}'); } b.writeln(); b.writeln('[Service]'); b.writeln('Type=${type.name}'); if (user.trim().isNotEmpty) b.writeln('User=${user.trim()}'); if (group.trim().isNotEmpty) b.writeln('Group=${group.trim()}'); if (workingDirectory.trim().isNotEmpty) { b.writeln('WorkingDirectory=${workingDirectory.trim()}'); } for (final e in envList) { b.writeln('Environment="$e"'); } b.writeln('ExecStart=${execStart.trim()}'); b.writeln('Restart=${restart.value}'); if (restart != RestartPolicy.no) b.writeln('RestartSec=$restartSec'); if (javaSuccessExit) b.writeln('SuccessExitStatus=143'); b.writeln(); b.writeln('[Install]'); b.writeln('WantedBy=multi-user.target'); return b.toString(); } /// Human-readable list of the commands the installer will run. List plannedCommands({required bool sudo}) { final p = sudo ? 'sudo ' : ''; final target = '/etc/systemd/system/$safeName.service'; return [ 'write $target', '${p}chmod 644 $target', '${p}systemctl daemon-reload', if (enableOnBoot) '${p}systemctl enable $safeName', if (startNow) '${p}systemctl restart $safeName', '${p}systemctl status $safeName', ]; } }