dartssh2's SftpFileWriter leaks chunk write failures as unhandled exceptions and never completes when the server rejects data (e.g. a full /tmp), so deploys hung and spammed the console. Upload in windowed writeBytes batches instead so errors propagate, remove the partial file, and report free space on the target directory. Every deploy flow now removes its staging files in a finally block (success, failure or cancel) and sweeps peachdeploy-*/peachweb-* leftovers older than an hour from earlier crashed runs. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2285 lines
72 KiB
Dart
2285 lines
72 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'),
|
||
]);
|
||
final staging =
|
||
'/tmp/peachdeploy-${DateTime.now().millisecondsSinceEpoch}.jar';
|
||
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('/'));
|
||
|
||
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');
|
||
} finally {
|
||
await _cleanStaging(server, [staging]);
|
||
}
|
||
}
|
||
|
||
// ---- 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');
|
||
} finally {
|
||
await _cleanStaging(server, [archive, staging]);
|
||
}
|
||
}
|
||
|
||
/// 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');
|
||
} finally {
|
||
await _cleanStaging(server, [tmp]);
|
||
}
|
||
}
|
||
|
||
// ---- 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 |