2222 lines
70 KiB
Dart
2222 lines
70 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:typed_data';
|
||
|
||
import 'package:dartssh2/dartssh2.dart';
|
||
import '../about/external/data/Docker.dart';
|
||
import '../about/external/data/Health.dart';
|
||
import '../about/external/data/Kafka.dart';
|
||
import '../about/external/data/Redis.dart';
|
||
import '../about/internal/application/ServerProfile.dart';
|
||
import '../about/internal/application/ServiceConfig.dart';
|
||
import '../about/internal/application/StepTracker.dart';
|
||
import 'ConnectComms.dart';
|
||
|
||
enum LogLevel { info, step, success, error, remote, warn }
|
||
|
||
class LogLine {
|
||
final LogLevel level;
|
||
final String text;
|
||
final DateTime time;
|
||
LogLine(this.level, this.text) : time = DateTime.now();
|
||
}
|
||
|
||
/// Result of a deploy attempt, surfaced to the UI.
|
||
class DeployResult {
|
||
final bool ok;
|
||
final bool canRevert;
|
||
final String message;
|
||
DeployResult(this.ok, this.canRevert, this.message);
|
||
}
|
||
|
||
/// A remote filesystem entry (for the file browser).
|
||
class RemoteEntry {
|
||
final String name;
|
||
final bool isDir;
|
||
final int size;
|
||
final int? modifyTime;
|
||
RemoteEntry(this.name, this.isDir, this.size, this.modifyTime);
|
||
}
|
||
|
||
/// Live transfer stats for an upload or download.
|
||
class TransferProgress {
|
||
final int sent;
|
||
final int total;
|
||
final double bytesPerSec;
|
||
final Duration? eta;
|
||
final bool uploading;
|
||
const TransferProgress(
|
||
this.sent,
|
||
this.total,
|
||
this.bytesPerSec,
|
||
this.eta,
|
||
this.uploading,
|
||
);
|
||
|
||
double? get fraction => total <= 0 ? null : (sent / total).clamp(0.0, 1.0);
|
||
bool get complete => total > 0 && sent >= total;
|
||
}
|
||
|
||
/// Smooths transfer speed with an exponential moving average and throttles
|
||
/// emissions to ~10/s.
|
||
class _SpeedMeter {
|
||
final _start = DateTime.now();
|
||
DateTime _lastEmit;
|
||
DateTime _lastT;
|
||
int _lastSent = 0;
|
||
double _bps = 0;
|
||
_SpeedMeter()
|
||
: _lastEmit = DateTime.now().subtract(const Duration(seconds: 1)),
|
||
_lastT = DateTime.now();
|
||
|
||
/// Updates speed; returns true if the caller should emit now.
|
||
bool tick(int sent, {bool force = false}) {
|
||
final now = DateTime.now();
|
||
final dt = now.difference(_lastT).inMicroseconds / 1e6;
|
||
if (dt >= 0.15) {
|
||
final inst = (sent - _lastSent) / dt;
|
||
_bps = _bps == 0 ? inst : _bps * 0.6 + inst * 0.4;
|
||
_lastT = now;
|
||
_lastSent = sent;
|
||
} else if (_bps == 0 && sent > 0) {
|
||
final el = now.difference(_start).inMicroseconds / 1e6;
|
||
if (el > 0) _bps = sent / el;
|
||
}
|
||
if (force || now.difference(_lastEmit).inMilliseconds >= 100) {
|
||
_lastEmit = now;
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
double get bps => _bps;
|
||
Duration? eta(int sent, int total) {
|
||
if (_bps <= 0 || total <= 0) return null;
|
||
final remaining = (total - sent) / _bps;
|
||
return Duration(milliseconds: (remaining * 1000).round());
|
||
}
|
||
}
|
||
|
||
/// Drives one SSH session against a server: safe jar deployment, log
|
||
/// streaming, and generic SFTP file transfer.
|
||
class SshService implements ConnectComms {
|
||
SSHClient? _client;
|
||
String? _connectedServerId;
|
||
bool _rootUser = false; // when logged in as root, skip the sudo wrapper
|
||
SSHSession? _logSession;
|
||
@override
|
||
bool get isConnected => _client != null;
|
||
|
||
// Revert state after a deploy that left a backup in place.
|
||
bool _canRevert = false;
|
||
bool _revertIsWeb = false;
|
||
bool _cancelled = false;
|
||
String? _revertTarget;
|
||
String? _revertBackup;
|
||
ServerProfile? _revertServer;
|
||
ServiceConfig? _revertService;
|
||
@override
|
||
bool get canRevert => _canRevert;
|
||
|
||
final _logController = StreamController<LogLine>.broadcast();
|
||
@override
|
||
Stream<LogLine> get logs => _logController.stream;
|
||
|
||
final _progressController = StreamController<TransferProgress?>.broadcast();
|
||
@override
|
||
Stream<TransferProgress?> get transferProgress => _progressController.stream;
|
||
|
||
final steps = StepTracker();
|
||
@override
|
||
Stream<List<DeployStep>> get stepStream => steps.stream;
|
||
String? _uploadStepKey; // step whose progress the SFTP upload feeds
|
||
|
||
void _log(LogLevel level, String text) {
|
||
for (final line in const LineSplitter().convert(text)) {
|
||
if (line.trim().isEmpty && level == LogLevel.remote) continue;
|
||
_logController.add(LogLine(level, line));
|
||
}
|
||
}
|
||
|
||
Future<void> _connect(ServerProfile s) async {
|
||
// Reuse only a live connection to the same server. A long build between
|
||
// batch deploys can let the server drop an idle connection — detect that
|
||
// (isClosed) and reconnect instead of using a dead client.
|
||
if (_client != null && _connectedServerId == s.id && !_client!.isClosed) {
|
||
return;
|
||
}
|
||
if (_client != null) disconnect();
|
||
_log(LogLevel.step, 'Connecting to ${s.username}@${s.host}:${s.port} …');
|
||
final socket = await SSHSocket.connect(
|
||
s.host,
|
||
s.port,
|
||
timeout: const Duration(seconds: 15),
|
||
);
|
||
final client = SSHClient(
|
||
socket,
|
||
username: s.username,
|
||
onPasswordRequest: () => s.password,
|
||
);
|
||
await client.authenticated;
|
||
_client = client;
|
||
_connectedServerId = s.id;
|
||
_rootUser = s.username.trim().toLowerCase() == 'root';
|
||
_log(
|
||
LogLevel.success,
|
||
'Connected ✓${_rootUser ? ' (root — running without sudo)' : ''}',
|
||
);
|
||
}
|
||
|
||
/// Public connect used by the file browser.
|
||
@override
|
||
Future<void> connect(ServerProfile s) => _connect(s);
|
||
|
||
/// Runs a command, streaming stdout/stderr to the log. Feeds the sudo
|
||
/// password over stdin when [sudo] is set. Returns the exit code.
|
||
Future<int> _run(
|
||
String command, {
|
||
bool sudo = false,
|
||
String? password,
|
||
LogLevel echo = LogLevel.remote,
|
||
}) async {
|
||
final client = _client!;
|
||
final useSudo = sudo && !_rootUser;
|
||
final cmd = useSudo
|
||
? "sudo -S -p '' bash -c ${_shQuote(command)}"
|
||
: command;
|
||
final session = await client.execute(cmd);
|
||
if (useSudo && password != null) {
|
||
session.stdin.add(Uint8List.fromList(utf8.encode('$password\n')));
|
||
}
|
||
await session.stdin.close();
|
||
await Future.wait([
|
||
session.stdout
|
||
.cast<List<int>>()
|
||
.transform(utf8.decoder)
|
||
.forEach((d) => _log(echo, d.trimRight())),
|
||
session.stderr
|
||
.cast<List<int>>()
|
||
.transform(utf8.decoder)
|
||
.forEach((d) => _log(echo, d.trimRight())),
|
||
]);
|
||
await session.done;
|
||
return session.exitCode ?? -1;
|
||
}
|
||
|
||
Future<String> _capture(
|
||
String command, {
|
||
bool sudo = false,
|
||
String? password,
|
||
}) async {
|
||
final client = _client!;
|
||
final useSudo = sudo && !_rootUser;
|
||
final cmd = useSudo
|
||
? "sudo -S -p '' bash -c ${_shQuote(command)}"
|
||
: command;
|
||
final session = await client.execute(cmd);
|
||
if (useSudo && password != null) {
|
||
session.stdin.add(Uint8List.fromList(utf8.encode('$password\n')));
|
||
}
|
||
await session.stdin.close();
|
||
final out = StringBuffer();
|
||
await Future.wait([
|
||
session.stdout
|
||
.cast<List<int>>()
|
||
.transform(utf8.decoder)
|
||
.forEach(out.write),
|
||
session.stderr.cast<List<int>>().transform(utf8.decoder).drain<void>(),
|
||
]);
|
||
await session.done;
|
||
return out.toString().trim();
|
||
}
|
||
|
||
static String _shQuote(String s) => "'${s.replaceAll("'", "'\\''")}'";
|
||
|
||
// ---- jar deployment -------------------------------------------------------
|
||
|
||
@override
|
||
Future<DeployResult> deploy({
|
||
required ServerProfile server,
|
||
required ServiceConfig service,
|
||
required Uint8List jarBytes,
|
||
required String jarLabel,
|
||
}) async {
|
||
_canRevert = false;
|
||
_revertIsWeb = false;
|
||
_cancelled = false;
|
||
final sudo = server.useSudo;
|
||
final pw = server.password;
|
||
final hasService = service.serviceName.trim().isNotEmpty;
|
||
steps.reset([
|
||
('connect', 'Connect'),
|
||
('upload', 'Upload jar'),
|
||
('stop', 'Stop service'),
|
||
('backup', 'Back up current'),
|
||
('install', 'Install jar'),
|
||
('start', 'Start & verify'),
|
||
('cleanup', 'Finalize'),
|
||
('logs', 'Fetch logs'),
|
||
]);
|
||
try {
|
||
steps.active('connect');
|
||
await _connect(server);
|
||
steps.done('connect');
|
||
|
||
final target = service.remotePath(jarLabel);
|
||
final backup = '$target.bak';
|
||
final dir = target.substring(0, target.lastIndexOf('/'));
|
||
final staging =
|
||
'/tmp/peachdeploy-${DateTime.now().millisecondsSinceEpoch}.jar';
|
||
|
||
await _run('mkdir -p ${_shQuote(dir)}', sudo: sudo, password: pw);
|
||
|
||
// 1. Upload to /tmp first — the live jar is untouched until success.
|
||
steps.active('upload');
|
||
_uploadStepKey = 'upload';
|
||
_log(
|
||
LogLevel.step,
|
||
'Uploading $jarLabel (${_fmtBytes(jarBytes.length)}) → $staging',
|
||
);
|
||
await _sftpUpload(jarBytes, staging);
|
||
_uploadStepKey = null;
|
||
steps.done('upload');
|
||
_log(LogLevel.success, 'Upload complete ✓');
|
||
|
||
// 2. Stop the service before swapping.
|
||
steps.active('stop');
|
||
if (hasService) {
|
||
_log(LogLevel.step, 'Stopping service "${service.serviceName}" …');
|
||
await _run(
|
||
'systemctl stop ${_shQuote(service.serviceName)}',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
_log(LogLevel.success, 'Service stopped ✓');
|
||
steps.done('stop');
|
||
} else {
|
||
steps.skip('stop');
|
||
}
|
||
|
||
// 3. Back up the current jar to <name>.bak.
|
||
steps.active('backup');
|
||
_log(LogLevel.step, 'Backing up current jar → $backup');
|
||
await _run(
|
||
'rm -f ${_shQuote(backup)}; '
|
||
'if [ -f ${_shQuote(target)} ]; then mv ${_shQuote(target)} ${_shQuote(backup)}; '
|
||
'echo "backed up"; else echo "no previous jar"; fi',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
steps.done('backup');
|
||
|
||
// 4. Install the new jar under the required name.
|
||
steps.active('install');
|
||
_log(LogLevel.step, 'Installing → $target');
|
||
await _run(
|
||
'mv -f ${_shQuote(staging)} ${_shQuote(target)}',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
if (service.isGeneric) {
|
||
await _run('chmod +x ${_shQuote(target)}', sudo: sudo, password: pw);
|
||
}
|
||
// Verify the artifact actually landed (SFTP/mv can fail on a dropped
|
||
// connection during a long batch).
|
||
final placed = await _capture(
|
||
'if [ -f ${_shQuote(target)} ]; then echo "OK \$(wc -c < ${_shQuote(target)})"; else echo MISSING; fi',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
_log(LogLevel.remote, 'artifact check: $placed');
|
||
if (placed.contains('MISSING')) {
|
||
steps.failRemaining();
|
||
// Restore the previous jar so the service isn't left broken.
|
||
await _run(
|
||
'if [ -f ${_shQuote(backup)} ]; then mv -f ${_shQuote(backup)} ${_shQuote(target)}; fi',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
_log(LogLevel.error, 'Artifact not present at $target after install.');
|
||
return DeployResult(
|
||
false,
|
||
false,
|
||
'Install failed — jar not present at $target.',
|
||
);
|
||
}
|
||
steps.done('install');
|
||
_log(LogLevel.success, 'New artifact installed ✓');
|
||
|
||
// 5. Start and verify.
|
||
steps.active('start');
|
||
var ok = true;
|
||
if (hasService) {
|
||
_log(LogLevel.step, 'Starting service "${service.serviceName}" …');
|
||
await _run(
|
||
'systemctl start ${_shQuote(service.serviceName)}',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
await Future.delayed(const Duration(seconds: 2));
|
||
final state = await _capture(
|
||
'systemctl is-active ${_shQuote(service.serviceName)} || true',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
ok = state.contains('active') && !state.contains('inactive');
|
||
if (ok) {
|
||
_log(LogLevel.success, 'Service is active ✓');
|
||
} else {
|
||
_log(LogLevel.error, 'Service is "$state" — start FAILED.');
|
||
await _run(
|
||
'systemctl --no-pager status ${_shQuote(service.serviceName)} | head -n 12',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
}
|
||
}
|
||
ok ? steps.done('start') : steps.fail('start');
|
||
|
||
steps.active('cleanup');
|
||
if (ok) {
|
||
await _run('rm -f ${_shQuote(backup)}', sudo: sudo, password: pw);
|
||
_log(LogLevel.success, 'Deployment successful 🎉');
|
||
_canRevert = false;
|
||
steps.done('cleanup');
|
||
} else {
|
||
_canRevert = true;
|
||
_revertTarget = target;
|
||
_revertBackup = backup;
|
||
_revertServer = server;
|
||
_revertService = service;
|
||
_log(
|
||
LogLevel.warn,
|
||
'Backup preserved at $backup — you can revert to the previous jar.',
|
||
);
|
||
steps.skip('cleanup');
|
||
}
|
||
|
||
steps.active('logs');
|
||
_log(LogLevel.step, 'Fetching recent logs …');
|
||
await _runLogCommand(server, service);
|
||
steps.done('logs');
|
||
|
||
return DeployResult(
|
||
ok,
|
||
_canRevert,
|
||
ok
|
||
? 'Deployed $jarLabel and service is active.'
|
||
: 'Service failed to start.',
|
||
);
|
||
} catch (e) {
|
||
steps.failRemaining();
|
||
if (_cancelled) {
|
||
_log(LogLevel.warn, 'Deployment cancelled by user.');
|
||
return DeployResult(false, _canRevert, 'Deployment cancelled.');
|
||
}
|
||
_log(LogLevel.error, 'Deploy failed: $e');
|
||
return DeployResult(false, _canRevert, 'Error: $e');
|
||
}
|
||
}
|
||
|
||
// ---- web (nginx) deployment ----------------------------------------------
|
||
|
||
/// Deploys a static site (Angular/React/Flutter web/static) to the nginx web
|
||
/// root. [archiveBytes] is a .tar.gz of the built site's contents.
|
||
@override
|
||
Future<DeployResult> deployWeb({
|
||
required ServerProfile server,
|
||
required ServiceConfig service,
|
||
required Uint8List archiveBytes,
|
||
required int fileCount,
|
||
}) async {
|
||
_canRevert = false;
|
||
_revertIsWeb = true;
|
||
_cancelled = false;
|
||
final sudo = server.useSudo;
|
||
final pw = server.password;
|
||
final root = service.remoteRoot;
|
||
final backup = '$root.bak';
|
||
final nginx = service.nginxService.trim().isEmpty
|
||
? 'nginx'
|
||
: service.nginxService.trim();
|
||
final ts = DateTime.now().millisecondsSinceEpoch;
|
||
final archive = '/tmp/peachdeploy-web-$ts.tgz';
|
||
final staging = '/tmp/peachweb-$ts'; // extract & verify here first
|
||
steps.reset([
|
||
('connect', 'Connect'),
|
||
('upload', 'Upload bundle'),
|
||
('extract', 'Extract & verify'),
|
||
('backup', 'Back up web root'),
|
||
('publish', 'Publish $fileCount files'),
|
||
('reload', 'Reload nginx'),
|
||
('cleanup', 'Finalize'),
|
||
('logs', 'Fetch logs'),
|
||
]);
|
||
try {
|
||
steps.active('connect');
|
||
await _connect(server);
|
||
steps.done('connect');
|
||
|
||
// Ensure the web root (e.g. /var/www/<project>) exists — create it and
|
||
// any parent folders if missing.
|
||
_log(LogLevel.step, 'Ensuring web root $root exists …');
|
||
await _run('mkdir -p ${_shQuote(root)}', sudo: sudo, password: pw);
|
||
|
||
// 1. Upload the bundle to /tmp.
|
||
steps.active('upload');
|
||
_uploadStepKey = 'upload';
|
||
_log(
|
||
LogLevel.step,
|
||
'Uploading site bundle (${_fmtBytes(archiveBytes.length)}, $fileCount files) …',
|
||
);
|
||
await _sftpUpload(archiveBytes, archive);
|
||
_uploadStepKey = null;
|
||
steps.done('upload');
|
||
_log(LogLevel.success, 'Upload complete ✓');
|
||
|
||
// 2. Extract to a STAGING dir and verify BEFORE touching the live root,
|
||
// so a bad/empty bundle can never wipe the site.
|
||
steps.active('extract');
|
||
_log(LogLevel.step, 'Extracting bundle to $staging …');
|
||
await _run(
|
||
'rm -rf ${_shQuote(staging)} && mkdir -p ${_shQuote(staging)} && '
|
||
'tar -xzf ${_shQuote(archive)} -C ${_shQuote(staging)}',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
final verify = await _capture(
|
||
'if [ -f ${_shQuote('$staging/index.html')} ]; then '
|
||
'echo "OK \$(find ${_shQuote(staging)} -type f | wc -l)"; '
|
||
'else echo "MISSING \$(find ${_shQuote(staging)} -type f | wc -l)"; fi',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
_log(LogLevel.remote, 'staging check: $verify');
|
||
if (!verify.contains('OK')) {
|
||
steps.failRemaining();
|
||
await _run(
|
||
'rm -rf ${_shQuote(staging)} ${_shQuote(archive)}',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
_log(
|
||
LogLevel.error,
|
||
'No index.html in the bundle — the LIVE site at $root was NOT touched. '
|
||
'Check the build output folder (must be the folder that contains index.html, '
|
||
'e.g. dist/<project>/browser).',
|
||
);
|
||
return DeployResult(
|
||
false,
|
||
false,
|
||
'Bundle has no index.html — nothing published (site untouched).',
|
||
);
|
||
}
|
||
steps.done('extract');
|
||
|
||
// 3. Back up the current web root (only now that we know staging is good).
|
||
steps.active('backup');
|
||
_log(LogLevel.step, 'Backing up web root → $backup');
|
||
await _run(
|
||
'mkdir -p ${_shQuote(root)}; rm -rf ${_shQuote(backup)}; '
|
||
'if [ -d ${_shQuote(root)} ] && [ -n "\$(ls -A ${_shQuote(root)} 2>/dev/null)" ]; '
|
||
'then cp -a ${_shQuote(root)} ${_shQuote(backup)}; fi',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
steps.done('backup');
|
||
|
||
// 4. Atomic-ish swap: clear the root and copy staging contents in.
|
||
steps.active('publish');
|
||
_log(LogLevel.step, 'Publishing new site to $root …');
|
||
final publishCode = await _run(
|
||
'find ${_shQuote(root)} -mindepth 1 -delete && '
|
||
'cp -a ${_shQuote('$staging/.')} ${_shQuote(root)}/ && '
|
||
'rm -rf ${_shQuote(staging)} ${_shQuote(archive)}',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
final live = await _capture(
|
||
'if [ -f ${_shQuote('$root/index.html')} ]; then echo "OK \$(find ${_shQuote(root)} -type f | wc -l)"; else echo MISSING; fi',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
_log(LogLevel.remote, 'live check: $live');
|
||
if (publishCode != 0 || live.contains('MISSING')) {
|
||
// Restore the backup so the site is never left empty.
|
||
_log(LogLevel.warn, 'Publish issue — restoring previous site …');
|
||
await _run(
|
||
'find ${_shQuote(root)} -mindepth 1 -delete; '
|
||
'if [ -d ${_shQuote(backup)} ]; then cp -a ${_shQuote('$backup/.')} ${_shQuote(root)}/; fi',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
steps.failRemaining();
|
||
return DeployResult(
|
||
false,
|
||
false,
|
||
'Publish failed — restored the previous site.',
|
||
);
|
||
}
|
||
steps.done('publish');
|
||
final listing = await _capture(
|
||
'ls -la ${_shQuote(root)} | head -n 25',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
_log(LogLevel.success, 'Published to $root:');
|
||
_log(LogLevel.remote, listing);
|
||
|
||
// 4. Test config and reload nginx.
|
||
steps.active('reload');
|
||
var ok = true;
|
||
_log(LogLevel.step, 'Testing nginx config & reloading …');
|
||
final test = await _capture(
|
||
'nginx -t 2>&1 || true',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
_log(LogLevel.remote, test);
|
||
if (test.contains('test failed') || test.contains('emerg')) {
|
||
ok = false;
|
||
_log(LogLevel.error, 'nginx config test FAILED.');
|
||
} else {
|
||
await _run(
|
||
'systemctl reload ${_shQuote(nginx)}',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
await Future.delayed(const Duration(seconds: 1));
|
||
final state = await _capture(
|
||
'systemctl is-active ${_shQuote(nginx)} || true',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
ok = state.contains('active') && !state.contains('inactive');
|
||
_log(
|
||
ok ? LogLevel.success : LogLevel.error,
|
||
ok ? 'nginx reloaded ✓' : 'nginx is "$state".',
|
||
);
|
||
}
|
||
ok ? steps.done('reload') : steps.fail('reload');
|
||
|
||
steps.active('cleanup');
|
||
if (ok) {
|
||
await _run('rm -rf ${_shQuote(backup)}', sudo: sudo, password: pw);
|
||
_log(LogLevel.success, 'Website deployed 🎉');
|
||
_canRevert = false;
|
||
steps.done('cleanup');
|
||
} else {
|
||
_canRevert = true;
|
||
_revertTarget = root;
|
||
_revertBackup = backup;
|
||
_revertServer = server;
|
||
_revertService = service;
|
||
_log(LogLevel.warn, 'Previous site kept at $backup — you can revert.');
|
||
steps.skip('cleanup');
|
||
}
|
||
|
||
steps.active('logs');
|
||
await _runLogCommand(server, service);
|
||
steps.done('logs');
|
||
|
||
return DeployResult(
|
||
ok,
|
||
_canRevert,
|
||
ok ? 'Published $fileCount files to $root.' : 'nginx failed to reload.',
|
||
);
|
||
} catch (e) {
|
||
steps.failRemaining();
|
||
if (_cancelled) {
|
||
_log(LogLevel.warn, 'Deployment cancelled by user.');
|
||
return DeployResult(false, _canRevert, 'Deployment cancelled.');
|
||
}
|
||
_log(LogLevel.error, 'Web deploy failed: $e');
|
||
return DeployResult(false, _canRevert, 'Error: $e');
|
||
}
|
||
}
|
||
|
||
/// Best-effort cancel of an in-flight deploy: interrupts the current command
|
||
/// by dropping the connection. Steps already applied on the server are not
|
||
/// rolled back automatically.
|
||
@override
|
||
void requestCancel() {
|
||
_cancelled = true;
|
||
_log(LogLevel.warn, 'Cancelling — closing connection …');
|
||
try {
|
||
_client?.close();
|
||
} catch (_) {}
|
||
_client = null;
|
||
_connectedServerId = null;
|
||
}
|
||
|
||
/// Restarts a service (or reloads nginx for web apps) without deploying,
|
||
/// then shows recent logs. Returns success.
|
||
@override
|
||
Future<DeployResult> restartOnly(
|
||
ServerProfile server,
|
||
ServiceConfig service,
|
||
) async {
|
||
final sudo = server.useSudo;
|
||
final pw = server.password;
|
||
try {
|
||
await _connect(server);
|
||
if (service.isWeb) {
|
||
final nginx = service.nginxService.trim().isEmpty
|
||
? 'nginx'
|
||
: service.nginxService.trim();
|
||
_log(LogLevel.step, 'Reloading $nginx …');
|
||
await _run(
|
||
'systemctl reload ${_shQuote(nginx)}',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
final state = await _capture(
|
||
'systemctl is-active ${_shQuote(nginx)} || true',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
final ok = state.contains('active') && !state.contains('inactive');
|
||
_log(
|
||
ok ? LogLevel.success : LogLevel.error,
|
||
ok ? 'nginx reloaded ✓' : 'nginx is "$state".',
|
||
);
|
||
await _runLogCommand(server, service);
|
||
return DeployResult(
|
||
ok,
|
||
false,
|
||
ok ? 'nginx reloaded.' : 'Reload failed.',
|
||
);
|
||
}
|
||
final unit = service.serviceName.trim();
|
||
if (unit.isEmpty) {
|
||
return DeployResult(
|
||
false,
|
||
false,
|
||
'No systemd unit set for this service.',
|
||
);
|
||
}
|
||
_log(LogLevel.step, 'Restarting $unit …');
|
||
await _run(
|
||
'systemctl restart ${_shQuote(unit)}',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
await Future.delayed(const Duration(seconds: 2));
|
||
final state = await _capture(
|
||
'systemctl is-active ${_shQuote(unit)} || true',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
final ok = state.contains('active') && !state.contains('inactive');
|
||
_log(
|
||
ok ? LogLevel.success : LogLevel.error,
|
||
ok ? 'Service is active ✓' : 'Service is "$state".',
|
||
);
|
||
await _runLogCommand(server, service);
|
||
return DeployResult(
|
||
ok,
|
||
false,
|
||
ok ? 'Service restarted.' : 'Restart failed.',
|
||
);
|
||
} catch (e) {
|
||
_log(LogLevel.error, 'Restart failed: $e');
|
||
return DeployResult(false, false, 'Error: $e');
|
||
}
|
||
}
|
||
|
||
@override
|
||
Future<DeployResult> revert() async {
|
||
final server = _revertServer;
|
||
final service = _revertService;
|
||
final target = _revertTarget;
|
||
final backup = _revertBackup;
|
||
if (server == null ||
|
||
service == null ||
|
||
target == null ||
|
||
backup == null ||
|
||
!_canRevert) {
|
||
return DeployResult(false, false, 'Nothing to revert.');
|
||
}
|
||
final sudo = server.useSudo;
|
||
final pw = server.password;
|
||
try {
|
||
if (_revertIsWeb) {
|
||
_log(LogLevel.step, 'Reverting web root to previous site …');
|
||
await _run(
|
||
'if [ -d ${_shQuote(backup)} ]; then '
|
||
'find ${_shQuote(target)} -mindepth 1 -delete; '
|
||
'cp -a ${_shQuote('$backup/.')} ${_shQuote(target)}/; '
|
||
'rm -rf ${_shQuote(backup)}; '
|
||
'else echo "no backup to restore"; fi',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
final nginx = service.nginxService.trim().isEmpty
|
||
? 'nginx'
|
||
: service.nginxService.trim();
|
||
await _run(
|
||
'systemctl reload ${_shQuote(nginx)}',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
_log(LogLevel.success, 'Reverted to previous site ✓');
|
||
} else {
|
||
_log(LogLevel.step, 'Reverting to previous jar …');
|
||
if (service.serviceName.trim().isNotEmpty) {
|
||
await _run(
|
||
'systemctl stop ${_shQuote(service.serviceName)}',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
}
|
||
await _run('rm -f ${_shQuote(target)}', sudo: sudo, password: pw);
|
||
await _run(
|
||
'mv -f ${_shQuote(backup)} ${_shQuote(target)}',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
if (service.serviceName.trim().isNotEmpty) {
|
||
await _run(
|
||
'systemctl start ${_shQuote(service.serviceName)}',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
}
|
||
_log(LogLevel.success, 'Reverted to previous jar ✓');
|
||
}
|
||
_canRevert = false;
|
||
await _runLogCommand(server, service);
|
||
return DeployResult(true, false, 'Reverted to the previous version.');
|
||
} catch (e) {
|
||
_log(LogLevel.error, 'Revert failed: $e');
|
||
return DeployResult(false, true, 'Revert error: $e');
|
||
}
|
||
}
|
||
|
||
@override
|
||
Future<void> discardBackup() async {
|
||
final server = _revertServer;
|
||
final backup = _revertBackup;
|
||
if (server == null || backup == null) return;
|
||
_log(LogLevel.step, 'Discarding backup $backup …');
|
||
final cmd = _revertIsWeb
|
||
? 'rm -rf ${_shQuote(backup)}'
|
||
: 'rm -f ${_shQuote(backup)}';
|
||
await _run(cmd, sudo: server.useSudo, password: server.password);
|
||
_canRevert = false;
|
||
_log(LogLevel.success, 'Backup discarded.');
|
||
}
|
||
|
||
// ---- logs -----------------------------------------------------------------
|
||
|
||
Future<void> _runLogCommand(
|
||
ServerProfile server,
|
||
ServiceConfig service,
|
||
) async {
|
||
await _run(
|
||
service.effectiveLogCommand,
|
||
sudo: server.useSudo,
|
||
password: server.password,
|
||
echo: LogLevel.remote,
|
||
);
|
||
}
|
||
|
||
/// Writes and registers a systemd unit file: upload → move to
|
||
/// /etc/systemd/system → daemon-reload → (enable) → (start) → status.
|
||
@override
|
||
Future<DeployResult> installSystemdUnit({
|
||
required ServerProfile server,
|
||
required String unitName,
|
||
required String unitContent,
|
||
bool enable = true,
|
||
bool startNow = true,
|
||
}) async {
|
||
final sudo = server.useSudo;
|
||
final pw = server.password;
|
||
final tmp =
|
||
'/tmp/peachdeploy-$unitName-${DateTime.now().millisecondsSinceEpoch}.service';
|
||
final target = '/etc/systemd/system/$unitName.service';
|
||
steps.reset([
|
||
('connect', 'Connect'),
|
||
('upload', 'Upload unit file'),
|
||
('install', 'Install unit'),
|
||
('reload', 'daemon-reload'),
|
||
('enable', 'Enable on boot'),
|
||
('start', 'Start service'),
|
||
('status', 'Check status'),
|
||
]);
|
||
try {
|
||
steps.active('connect');
|
||
await _connect(server);
|
||
steps.done('connect');
|
||
|
||
steps.active('upload');
|
||
_log(LogLevel.step, 'Uploading unit file → $tmp');
|
||
await _sftpUpload(Uint8List.fromList(utf8.encode(unitContent)), tmp);
|
||
steps.done('upload');
|
||
|
||
steps.active('install');
|
||
_log(LogLevel.step, 'Installing → $target');
|
||
await _run(
|
||
'mv -f ${_shQuote(tmp)} ${_shQuote(target)} && chmod 644 ${_shQuote(target)}',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
steps.done('install');
|
||
|
||
steps.active('reload');
|
||
await _run('systemctl daemon-reload', sudo: sudo, password: pw);
|
||
steps.done('reload');
|
||
|
||
steps.active('enable');
|
||
if (enable) {
|
||
await _run(
|
||
'systemctl enable ${_shQuote(unitName)}',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
steps.done('enable');
|
||
} else {
|
||
steps.skip('enable');
|
||
}
|
||
|
||
steps.active('start');
|
||
if (startNow) {
|
||
await _run(
|
||
'systemctl restart ${_shQuote(unitName)}',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
await Future.delayed(const Duration(seconds: 2));
|
||
steps.done('start');
|
||
} else {
|
||
steps.skip('start');
|
||
}
|
||
|
||
steps.active('status');
|
||
final st = await _capture(
|
||
'systemctl is-active ${_shQuote(unitName)} || true',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
await _run(
|
||
'systemctl --no-pager status ${_shQuote(unitName)} | head -n 12',
|
||
sudo: sudo,
|
||
password: pw,
|
||
);
|
||
steps.done('status');
|
||
|
||
final ok =
|
||
!startNow || (st.contains('active') && !st.contains('inactive'));
|
||
_log(
|
||
ok ? LogLevel.success : LogLevel.warn,
|
||
'Unit $unitName installed${startNow ? ' — is "$st"' : ''}.',
|
||
);
|
||
return DeployResult(
|
||
ok,
|
||
false,
|
||
ok
|
||
? '$unitName registered${startNow ? ' and active' : ''}.'
|
||
: '$unitName installed but is "$st".',
|
||
);
|
||
} catch (e) {
|
||
steps.failRemaining();
|
||
_log(LogLevel.error, 'Unit install failed: $e');
|
||
return DeployResult(false, false, 'Error: $e');
|
||
}
|
||
}
|
||
|
||
// ---- environment (configuration tab) --------------------------------------
|
||
|
||
/// Detects the server's package manager: apt | dnf | yum | apk | zypper |
|
||
/// pacman | unknown.
|
||
@override
|
||
Future<String> detectPackageManager(ServerProfile server) async {
|
||
await _connect(server);
|
||
final out = await _capture(
|
||
'command -v apt-get >/dev/null 2>&1 && echo apt; '
|
||
'command -v dnf >/dev/null 2>&1 && echo dnf; '
|
||
'command -v yum >/dev/null 2>&1 && echo yum; '
|
||
'command -v apk >/dev/null 2>&1 && echo apk; '
|
||
'command -v zypper >/dev/null 2>&1 && echo zypper; '
|
||
'command -v pacman >/dev/null 2>&1 && echo pacman',
|
||
sudo: false,
|
||
);
|
||
final lines = out
|
||
.trim()
|
||
.split('\n')
|
||
.where((l) => l.trim().isNotEmpty)
|
||
.toList();
|
||
return lines.isEmpty ? 'unknown' : lines.first.trim();
|
||
}
|
||
|
||
/// Runs each detect command; returns key -> version line ('' when missing).
|
||
@override
|
||
Future<Map<String, String>> probeEnvironment(
|
||
ServerProfile server,
|
||
List<String> detectByKey,
|
||
) async {
|
||
// detectByKey is a flat list of "key |