Relax workspace prechecks and add post-deploy validation

This commit is contained in:
Haitao Pan 2026-06-08 17:43:40 +08:00
parent b06a27a3cf
commit 03296b4e21
5 changed files with 303 additions and 20 deletions

View File

@ -39,15 +39,24 @@ echo "CADDY=\$(caddy version 2>/dev/null || echo missing)"
echo "ANSIBLE=\$(ansible --version 2>/dev/null | head -1 || echo missing)" echo "ANSIBLE=\$(ansible --version 2>/dev/null | head -1 || echo missing)"
echo "GIT=\$(git --version 2>/dev/null || echo missing)" echo "GIT=\$(git --version 2>/dev/null || echo missing)"
echo "DNS_OK=\$(getent hosts $domain 2>/dev/null | wc -l | tr -d ' ')" echo "DNS_OK=\$(getent hosts $domain 2>/dev/null | wc -l | tr -d ' ')"
echo "PORT_80_LISTENERS=\$(ss -ltn '( sport = :80 )' 2>/dev/null | tail -n +2 | wc -l | tr -d ' ')"
echo "PORT_443_LISTENERS=\$(ss -ltn '( sport = :443 )' 2>/dev/null | tail -n +2 | wc -l | tr -d ' ')" echo "PORT_443_LISTENERS=\$(ss -ltn '( sport = :443 )' 2>/dev/null | tail -n +2 | wc -l | tr -d ' ')"
echo "BRIDGE_DNS_OK=\$(getent hosts $bridge 2>/dev/null | wc -l | tr -d ' ')" echo "BRIDGE_DNS_OK=\$(getent hosts $bridge 2>/dev/null | wc -l | tr -d ' ')"
echo "BRIDGE_PORT_80_LISTENERS=\$(ss -ltn '( sport = :80 )' 2>/dev/null | tail -n +2 | wc -l | tr -d ' ')"
echo "BRIDGE_PORT_443_LISTENERS=\$(ss -ltn '( sport = :443 )' 2>/dev/null | tail -n +2 | wc -l | tr -d ' ')" echo "BRIDGE_PORT_443_LISTENERS=\$(ss -ltn '( sport = :443 )' 2>/dev/null | tail -n +2 | wc -l | tr -d ' ')"
PORT_80_OPEN=yes
PORT_443_OPEN=yes PORT_443_OPEN=yes
if command -v ufw >/dev/null 2>&1; then if command -v ufw >/dev/null 2>&1; then
UFW_STATUS="\$(ufw status 2>/dev/null || sudo -n ufw status 2>/dev/null || echo unavailable)" UFW_STATUS="\$(ufw status 2>/dev/null || sudo -n ufw status 2>/dev/null || echo unavailable)"
if printf '%s' "\$UFW_STATUS" | grep -qi 'Status: inactive'; then if printf '%s' "\$UFW_STATUS" | grep -qi 'Status: inactive'; then
PORT_80_OPEN=yes
PORT_443_OPEN=yes PORT_443_OPEN=yes
elif printf '%s' "\$UFW_STATUS" | grep -Eqi '(^|[[:space:]])(443(/tcp)?|https)[[:space:]]+ALLOW'; then elif printf '%s' "\$UFW_STATUS" | grep -Eqi '(^|[[:space:]])(80(/tcp)?|http)[[:space:]]+ALLOW'; then
PORT_80_OPEN=yes
else
PORT_80_OPEN=no
fi
if printf '%s' "\$UFW_STATUS" | grep -Eqi '(^|[[:space:]])(443(/tcp)?|https)[[:space:]]+ALLOW'; then
PORT_443_OPEN=yes PORT_443_OPEN=yes
else else
PORT_443_OPEN=no PORT_443_OPEN=no
@ -55,6 +64,14 @@ if command -v ufw >/dev/null 2>&1; then
elif command -v firewall-cmd >/dev/null 2>&1; then elif command -v firewall-cmd >/dev/null 2>&1; then
FIREWALL_STATE="\$(firewall-cmd --state 2>/dev/null || sudo -n firewall-cmd --state 2>/dev/null || echo not-running)" FIREWALL_STATE="\$(firewall-cmd --state 2>/dev/null || sudo -n firewall-cmd --state 2>/dev/null || echo not-running)"
if [ "\$FIREWALL_STATE" = "running" ]; then if [ "\$FIREWALL_STATE" = "running" ]; then
if firewall-cmd --quiet --query-service=http 2>/dev/null ||
sudo -n firewall-cmd --quiet --query-service=http 2>/dev/null ||
firewall-cmd --quiet --query-port=80/tcp 2>/dev/null ||
sudo -n firewall-cmd --quiet --query-port=80/tcp 2>/dev/null; then
PORT_80_OPEN=yes
else
PORT_80_OPEN=no
fi
if firewall-cmd --quiet --query-service=https 2>/dev/null || if firewall-cmd --quiet --query-service=https 2>/dev/null ||
sudo -n firewall-cmd --quiet --query-service=https 2>/dev/null || sudo -n firewall-cmd --quiet --query-service=https 2>/dev/null ||
firewall-cmd --quiet --query-port=443/tcp 2>/dev/null || firewall-cmd --quiet --query-port=443/tcp 2>/dev/null ||
@ -64,10 +81,13 @@ if command -v ufw >/dev/null 2>&1; then
PORT_443_OPEN=no PORT_443_OPEN=no
fi fi
else else
PORT_80_OPEN=yes
PORT_443_OPEN=yes PORT_443_OPEN=yes
fi fi
fi fi
echo "PORT_80_OPEN=\$PORT_80_OPEN"
echo "PORT_443_OPEN=\$PORT_443_OPEN" echo "PORT_443_OPEN=\$PORT_443_OPEN"
echo "BRIDGE_PORT_80_OPEN=\$PORT_80_OPEN"
echo "BRIDGE_PORT_443_OPEN=\$PORT_443_OPEN" echo "BRIDGE_PORT_443_OPEN=\$PORT_443_OPEN"
'''; ''';
} }
@ -91,11 +111,20 @@ echo "BRIDGE_PORT_443_OPEN=\$PORT_443_OPEN"
ansibleVersion: values['ANSIBLE'] ?? 'missing', ansibleVersion: values['ANSIBLE'] ?? 'missing',
gitVersion: values['GIT'] ?? 'missing', gitVersion: values['GIT'] ?? 'missing',
dnsAddressCount: int.tryParse(values['DNS_OK'] ?? '') ?? 0, dnsAddressCount: int.tryParse(values['DNS_OK'] ?? '') ?? 0,
port80ListenerCount:
int.tryParse(values['PORT_80_LISTENERS'] ?? '') ?? 0,
port80Open: (values['PORT_80_OPEN'] ?? '').toLowerCase() != 'no',
port443ListenerCount: port443ListenerCount:
int.tryParse(values['PORT_443_LISTENERS'] ?? '') ?? 0, int.tryParse(values['PORT_443_LISTENERS'] ?? '') ?? 0,
port443Open: (values['PORT_443_OPEN'] ?? '').toLowerCase() != 'no', port443Open: (values['PORT_443_OPEN'] ?? '').toLowerCase() != 'no',
bridgeDnsAddressCount: bridgeDnsAddressCount:
int.tryParse(values['BRIDGE_DNS_OK'] ?? '') ?? 0, int.tryParse(values['BRIDGE_DNS_OK'] ?? '') ?? 0,
bridgePort80ListenerCount:
int.tryParse(values['BRIDGE_PORT_80_LISTENERS'] ?? '') ?? 0,
bridgePort80Open:
(values['BRIDGE_PORT_80_OPEN'] ?? values['PORT_80_OPEN'] ?? '')
.toLowerCase() !=
'no',
bridgePort443ListenerCount: bridgePort443ListenerCount:
int.tryParse(values['BRIDGE_PORT_443_LISTENERS'] ?? '') ?? 0, int.tryParse(values['BRIDGE_PORT_443_LISTENERS'] ?? '') ?? 0,
bridgePort443Open: bridgePort443Open:

View File

@ -32,11 +32,13 @@ class _WorkspaceManagementFormState extends State<WorkspaceManagementForm> {
late final TextEditingController _sudoController; late final TextEditingController _sudoController;
late final TextEditingController _installPathController; late final TextEditingController _installPathController;
final List<_ExtraRowControllers> _extraRows = <_ExtraRowControllers>[]; final List<_ExtraRowControllers> _extraRows = <_ExtraRowControllers>[];
bool _syncingFromController = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final c = widget.controller; final c = widget.controller;
widget.controller.addListener(_handleControllerUpdate);
_serverController = TextEditingController(text: c.serverAddress); _serverController = TextEditingController(text: c.serverAddress);
_domainController = TextEditingController(text: c.workspaceDomain); _domainController = TextEditingController(text: c.workspaceDomain);
_userController = TextEditingController(text: c.sshUsername); _userController = TextEditingController(text: c.sshUsername);
@ -57,8 +59,67 @@ class _WorkspaceManagementFormState extends State<WorkspaceManagementForm> {
} }
} }
void _handleControllerUpdate() {
if (!mounted || _syncingFromController) {
return;
}
_syncingFromController = true;
try {
_syncText(_serverController, widget.controller.serverAddress);
_syncText(_domainController, widget.controller.workspaceDomain);
_syncText(_userController, widget.controller.sshUsername);
_syncText(_passwordController, widget.controller.sshPassword ?? '');
_syncText(_keyController, widget.controller.sshKeyContent ?? '');
_syncText(_keyPathController, widget.controller.sshKeyPath ?? '');
_syncText(_portController, widget.controller.sshPort.toString());
_syncText(_sudoController, widget.controller.sudoPassword ?? '');
_syncText(_installPathController, widget.controller.installPath);
_syncExtraRows(widget.controller.extraConfigs);
} finally {
_syncingFromController = false;
}
}
void _syncText(TextEditingController controller, String value) {
if (controller.text != value) {
controller.value = controller.value.copyWith(
text: value,
selection: TextSelection.collapsed(offset: value.length),
composing: TextRange.empty,
);
}
}
void _syncExtraRows(List<WorkspaceExtraConfig> configs) {
if (_extraRows.length != configs.length) {
for (final row in _extraRows) {
row.dispose();
}
_extraRows
..clear()
..addAll(
configs.map(
(row) => _ExtraRowControllers(
keyController: TextEditingController(text: row.key),
valueController: TextEditingController(text: row.value),
noteController: TextEditingController(text: row.note),
),
),
);
return;
}
for (var i = 0; i < configs.length; i++) {
final source = configs[i];
final row = _extraRows[i];
_syncText(row.keyController, source.key);
_syncText(row.valueController, source.value);
_syncText(row.noteController, source.note);
}
}
@override @override
void dispose() { void dispose() {
widget.controller.removeListener(_handleControllerUpdate);
_serverController.dispose(); _serverController.dispose();
_domainController.dispose(); _domainController.dispose();
_userController.dispose(); _userController.dispose();

View File

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:yaml/yaml.dart'; import 'package:yaml/yaml.dart';
@ -11,12 +13,15 @@ class WorkspaceProvisionController extends ChangeNotifier {
WorkspaceProvisionController({ WorkspaceProvisionController({
WorkspaceSshExecutor? executor, WorkspaceSshExecutor? executor,
String initialWorkspaceDomain = '', String initialWorkspaceDomain = '',
Future<void> Function(String host)? externalPortProbe,
}) : executor = executor ?? const DartSshExecutor(), }) : executor = executor ?? const DartSshExecutor(),
workspaceDomain = initialWorkspaceDomain { workspaceDomain = initialWorkspaceDomain,
externalPortProbe = externalPortProbe ?? _probeExternalPorts {
steps = defaultProvisionSteps(); steps = defaultProvisionSteps();
} }
final WorkspaceSshExecutor executor; final WorkspaceSshExecutor executor;
final Future<void> Function(String host) externalPortProbe;
String serverAddress = ''; String serverAddress = '';
String workspaceDomain = ''; String workspaceDomain = '';
@ -155,6 +160,7 @@ class WorkspaceProvisionController extends ChangeNotifier {
onStepUpdate: _setStep, onStepUpdate: _setStep,
onLog: _appendLog, onLog: _appendLog,
); );
await _verifyDeploymentReadiness();
for (final step in steps) { for (final step in steps) {
if (step.status == StepStatus.pending || step.status == StepStatus.running) { if (step.status == StepStatus.pending || step.status == StepStatus.running) {
_setStep(step.id, StepStatus.success, null); _setStep(step.id, StepStatus.success, null);
@ -358,21 +364,100 @@ class WorkspaceProvisionController extends ChangeNotifier {
'The target server cannot resolve $bridgeDomain. Add an A record for this host at your DNS provider, then confirm dig/getent returns an address on the VPS.', 'The target server cannot resolve $bridgeDomain. Add an A record for this host at your DNS provider, then confirm dig/getent returns an address on the VPS.',
); );
} }
if (!info.bridgePort443Open) {
return appText(
'$bridgeDomain 的 443 端口未开放,请先放通 HTTPS 访问。',
'Port 443 is not open for $bridgeDomain. Allow HTTPS traffic first.',
);
}
if (!info.isBridgePort443Available) {
return appText(
'$bridgeDomain 的 443 端口已被占用,请先释放。',
'Port 443 is already in use for $bridgeDomain.',
);
}
return null; return null;
} }
Future<void> _verifyDeploymentReadiness() async {
final result = await executor.execute(
sshConfig(),
_coreServicesCheckCommand(),
);
for (final line in result.combinedOutput.split(RegExp(r'\r?\n'))) {
if (line.trim().isNotEmpty) {
_appendLog(line);
}
}
if (!result.success) {
throw PlaybookRunException(
appText(
'部署完成后核心服务校验失败,请查看日志。',
'Core service validation failed after deployment. Check logs.',
),
);
}
final serviceStates = <String, String>{};
for (final raw in result.combinedOutput.split(RegExp(r'\r?\n'))) {
final match = RegExp(r'^SERVICE_(.+?)=(.+)$').firstMatch(raw.trim());
if (match != null) {
serviceStates[match.group(1)!] = match.group(2)!;
}
}
final unhealthy = serviceStates.entries
.where((entry) => entry.value.trim().toLowerCase() != 'active')
.map((entry) => entry.key)
.toList();
if (unhealthy.isNotEmpty) {
throw PlaybookRunException(
appText(
'部署完成后核心服务未处于 active${unhealthy.join(', ')}',
'Core services are not active after deployment: ${unhealthy.join(', ')}.',
),
);
}
await externalPortProbe(bridgeDomain.trim());
}
String _coreServicesCheckCommand() {
final services = <String>[
'caddy',
'xworkmate-bridge',
'openclaw-gateway',
'hermes-gateway',
];
final buffer = StringBuffer();
buffer.writeln('set +e');
for (final service in services) {
final unit = shellQuote('$service.service');
final envKey = service.toUpperCase().replaceAll('-', '_');
buffer.writeln('if systemctl list-unit-files $unit >/dev/null 2>&1 || systemctl status $unit >/dev/null 2>&1; then');
buffer.writeln(' STATE=\$(systemctl is-active $unit 2>/dev/null || echo inactive)');
buffer.writeln(' echo SERVICE_$envKey=\$STATE');
buffer.writeln('fi');
}
buffer.writeln('exit 0');
return buffer.toString();
}
static Future<void> _probeExternalPorts(String host) async {
final bridgeHost = host.trim();
if (bridgeHost.isEmpty) {
return;
}
final probeFailures = <String>[];
for (final port in <int>[80, 443]) {
try {
final socket = await Socket.connect(
bridgeHost,
port,
timeout: const Duration(seconds: 3),
);
socket.destroy();
} catch (_) {
probeFailures.add('$port');
}
}
if (probeFailures.isNotEmpty) {
throw PlaybookRunException(
appText(
'部署已完成,但外部探测仍然不通:$bridgeHost${probeFailures.join(' / ')} 端口未真正放行。',
'Deployment finished, but external probes still fail: $bridgeHost ports ${probeFailures.join(' / ')} are not truly open.',
),
);
}
}
@override @override
void dispose() { void dispose() {
sshPassword = null; sshPassword = null;

View File

@ -56,9 +56,13 @@ class ServerInfo {
required this.ansibleVersion, required this.ansibleVersion,
required this.gitVersion, required this.gitVersion,
required this.dnsAddressCount, required this.dnsAddressCount,
required this.port80ListenerCount,
required this.port80Open,
required this.port443ListenerCount, required this.port443ListenerCount,
required this.port443Open, required this.port443Open,
required this.bridgeDnsAddressCount, required this.bridgeDnsAddressCount,
required this.bridgePort80ListenerCount,
required this.bridgePort80Open,
required this.bridgePort443ListenerCount, required this.bridgePort443ListenerCount,
required this.bridgePort443Open, required this.bridgePort443Open,
}); });
@ -72,9 +76,13 @@ class ServerInfo {
final String ansibleVersion; final String ansibleVersion;
final String gitVersion; final String gitVersion;
final int dnsAddressCount; final int dnsAddressCount;
final int port80ListenerCount;
final bool port80Open;
final int port443ListenerCount; final int port443ListenerCount;
final bool port443Open; final bool port443Open;
final int bridgeDnsAddressCount; final int bridgeDnsAddressCount;
final int bridgePort80ListenerCount;
final bool bridgePort80Open;
final int bridgePort443ListenerCount; final int bridgePort443ListenerCount;
final bool bridgePort443Open; final bool bridgePort443Open;
@ -82,8 +90,10 @@ class ServerInfo {
bool get ansibleMissing => _isMissing(ansibleVersion); bool get ansibleMissing => _isMissing(ansibleVersion);
bool get hasMissingPrerequisites => gitMissing || ansibleMissing; bool get hasMissingPrerequisites => gitMissing || ansibleMissing;
bool get dnsResolved => dnsAddressCount > 0; bool get dnsResolved => dnsAddressCount > 0;
bool get isPort80Available => port80ListenerCount == 0;
bool get isPort443Available => port443ListenerCount == 0; bool get isPort443Available => port443ListenerCount == 0;
bool get bridgeDnsResolved => bridgeDnsAddressCount > 0; bool get bridgeDnsResolved => bridgeDnsAddressCount > 0;
bool get isBridgePort80Available => bridgePort80ListenerCount == 0;
bool get isBridgePort443Available => bridgePort443ListenerCount == 0; bool get isBridgePort443Available => bridgePort443ListenerCount == 0;
String get displaySummary { String get displaySummary {
@ -93,9 +103,13 @@ class ServerInfo {
if (arch.trim().isNotEmpty) arch.trim(), if (arch.trim().isNotEmpty) arch.trim(),
sudo, sudo,
dnsResolved ? 'dns=ok' : 'dns=missing', dnsResolved ? 'dns=ok' : 'dns=missing',
port80Open ? '80=open' : '80=blocked',
isPort80Available ? '80=free' : '80=busy',
port443Open ? '443=open' : '443=blocked', port443Open ? '443=open' : '443=blocked',
isPort443Available ? '443=free' : '443=busy', isPort443Available ? '443=free' : '443=busy',
bridgeDnsResolved ? 'bridge-dns=ok' : 'bridge-dns=missing', bridgeDnsResolved ? 'bridge-dns=ok' : 'bridge-dns=missing',
bridgePort80Open ? 'bridge-80=open' : 'bridge-80=blocked',
isBridgePort80Available ? 'bridge-80=free' : 'bridge-80=busy',
bridgePort443Open ? 'bridge-443=open' : 'bridge-443=blocked', bridgePort443Open ? 'bridge-443=open' : 'bridge-443=blocked',
isBridgePort443Available ? 'bridge-443=free' : 'bridge-443=busy', isBridgePort443Available ? 'bridge-443=free' : 'bridge-443=busy',
].join(', '); ].join(', ');

View File

@ -47,10 +47,14 @@ CADDY=missing
ANSIBLE=missing ANSIBLE=missing
GIT=git version 2.34.1 GIT=git version 2.34.1
DNS_OK=1 DNS_OK=1
PORT_80_LISTENERS=0
PORT_443_LISTENERS=0 PORT_443_LISTENERS=0
PORT_80_OPEN=yes
PORT_443_OPEN=yes PORT_443_OPEN=yes
BRIDGE_DNS_OK=1 BRIDGE_DNS_OK=1
BRIDGE_PORT_80_LISTENERS=0
BRIDGE_PORT_443_LISTENERS=0 BRIDGE_PORT_443_LISTENERS=0
BRIDGE_PORT_80_OPEN=yes
BRIDGE_PORT_443_OPEN=yes BRIDGE_PORT_443_OPEN=yes
'''); ''');
@ -60,9 +64,13 @@ BRIDGE_PORT_443_OPEN=yes
expect(info.ansibleMissing, isTrue); expect(info.ansibleMissing, isTrue);
expect(info.gitMissing, isFalse); expect(info.gitMissing, isFalse);
expect(info.dnsResolved, isTrue); expect(info.dnsResolved, isTrue);
expect(info.port80Open, isTrue);
expect(info.isPort80Available, isTrue);
expect(info.port443Open, isTrue); expect(info.port443Open, isTrue);
expect(info.isPort443Available, isTrue); expect(info.isPort443Available, isTrue);
expect(info.bridgeDnsResolved, isTrue); expect(info.bridgeDnsResolved, isTrue);
expect(info.bridgePort80Open, isTrue);
expect(info.isBridgePort80Available, isTrue);
expect(info.bridgePort443Open, isTrue); expect(info.bridgePort443Open, isTrue);
expect(info.isBridgePort443Available, isTrue); expect(info.isBridgePort443Available, isTrue);
}); });
@ -147,10 +155,14 @@ CADDY=missing
ANSIBLE=ansible [core 2.16] ANSIBLE=ansible [core 2.16]
GIT=git version 2.43.0 GIT=git version 2.43.0
DNS_OK=1 DNS_OK=1
PORT_80_LISTENERS=0
PORT_443_LISTENERS=0 PORT_443_LISTENERS=0
PORT_80_OPEN=yes
PORT_443_OPEN=yes PORT_443_OPEN=yes
BRIDGE_DNS_OK=1 BRIDGE_DNS_OK=1
BRIDGE_PORT_80_LISTENERS=0
BRIDGE_PORT_443_LISTENERS=0 BRIDGE_PORT_443_LISTENERS=0
BRIDGE_PORT_80_OPEN=yes
BRIDGE_PORT_443_OPEN=yes BRIDGE_PORT_443_OPEN=yes
''', ''',
stderr: '', stderr: '',
@ -180,13 +192,26 @@ BRIDGE_PORT_443_OPEN=yes
commandResults: [ commandResults: [
const SshResult(exitCode: 0, stdout: 'pulled', stderr: ''), const SshResult(exitCode: 0, stdout: 'pulled', stderr: ''),
const SshResult(exitCode: 0, stdout: 'wrote', stderr: ''), const SshResult(exitCode: 0, stdout: 'wrote', stderr: ''),
const SshResult(
exitCode: 0,
stdout: '''
SERVICE_CADDY=active
SERVICE_XWORKMATE_BRIDGE=active
SERVICE_OPENCLAW_GATEWAY=active
SERVICE_HERMES_GATEWAY=active
''',
stderr: '',
),
], ],
streamingChunks: [ streamingChunks: [
'TASK [Install desktop packages]\nok: [localhost]\n', 'TASK [Install desktop packages]\nok: [localhost]\n',
'TASK [Configure caddy TLS]\nchanged: [localhost]\n', 'TASK [Configure caddy TLS]\nchanged: [localhost]\n',
], ],
); );
final controller = WorkspaceProvisionController(executor: executor); final controller = WorkspaceProvisionController(
executor: executor,
externalPortProbe: (_) async {},
);
addTearDown(controller.dispose); addTearDown(controller.dispose);
controller.updateForm( controller.updateForm(
serverAddress: '203.0.113.10', serverAddress: '203.0.113.10',
@ -203,9 +228,13 @@ BRIDGE_PORT_443_OPEN=yes
ansibleVersion: 'ansible [core 2.14]', ansibleVersion: 'ansible [core 2.14]',
gitVersion: 'git version 2.34.1', gitVersion: 'git version 2.34.1',
dnsAddressCount: 1, dnsAddressCount: 1,
port80ListenerCount: 0,
port80Open: true,
port443ListenerCount: 0, port443ListenerCount: 0,
port443Open: true, port443Open: true,
bridgeDnsAddressCount: 1, bridgeDnsAddressCount: 1,
bridgePort80ListenerCount: 0,
bridgePort80Open: true,
bridgePort443ListenerCount: 0, bridgePort443ListenerCount: 0,
bridgePort443Open: true, bridgePort443Open: true,
); );
@ -221,7 +250,7 @@ BRIDGE_PORT_443_OPEN=yes
expect(executor.commands.join('\n'), contains('ansible-playbook')); expect(executor.commands.join('\n'), contains('ansible-playbook'));
}); });
test('precheck blocks when 443 is not open', () async { test('precheck does not block when 443 is not open', () async {
final controller = WorkspaceProvisionController(executor: _FakeSshExecutor()); final controller = WorkspaceProvisionController(executor: _FakeSshExecutor());
addTearDown(controller.dispose); addTearDown(controller.dispose);
controller.updateForm( controller.updateForm(
@ -239,17 +268,18 @@ BRIDGE_PORT_443_OPEN=yes
ansibleVersion: 'ansible [core 2.14]', ansibleVersion: 'ansible [core 2.14]',
gitVersion: 'git version 2.34.1', gitVersion: 'git version 2.34.1',
dnsAddressCount: 1, dnsAddressCount: 1,
port80ListenerCount: 0,
port80Open: true,
port443ListenerCount: 0, port443ListenerCount: 0,
port443Open: false, port443Open: false,
bridgeDnsAddressCount: 1, bridgeDnsAddressCount: 1,
bridgePort80ListenerCount: 0,
bridgePort80Open: true,
bridgePort443ListenerCount: 0, bridgePort443ListenerCount: 0,
bridgePort443Open: false, bridgePort443Open: false,
); );
expect( expect(controller.validatePrecheckBlockingIssue(), isNull);
controller.validatePrecheckBlockingIssue(),
contains('443'),
);
}); });
test('precheck blocks when bridge DNS is missing', () async { test('precheck blocks when bridge DNS is missing', () async {
@ -270,9 +300,13 @@ BRIDGE_PORT_443_OPEN=yes
ansibleVersion: 'ansible [core 2.14]', ansibleVersion: 'ansible [core 2.14]',
gitVersion: 'git version 2.34.1', gitVersion: 'git version 2.34.1',
dnsAddressCount: 1, dnsAddressCount: 1,
port80ListenerCount: 0,
port80Open: true,
port443ListenerCount: 0, port443ListenerCount: 0,
port443Open: true, port443Open: true,
bridgeDnsAddressCount: 0, bridgeDnsAddressCount: 0,
bridgePort80ListenerCount: 0,
bridgePort80Open: true,
bridgePort443ListenerCount: 0, bridgePort443ListenerCount: 0,
bridgePort443Open: true, bridgePort443Open: true,
); );
@ -301,9 +335,13 @@ BRIDGE_PORT_443_OPEN=yes
ansibleVersion: 'ansible [core 2.14]', ansibleVersion: 'ansible [core 2.14]',
gitVersion: 'git version 2.34.1', gitVersion: 'git version 2.34.1',
dnsAddressCount: 1, dnsAddressCount: 1,
port80ListenerCount: 0,
port80Open: true,
port443ListenerCount: 0, port443ListenerCount: 0,
port443Open: true, port443Open: true,
bridgeDnsAddressCount: 1, bridgeDnsAddressCount: 1,
bridgePort80ListenerCount: 0,
bridgePort80Open: true,
bridgePort443ListenerCount: 0, bridgePort443ListenerCount: 0,
bridgePort443Open: true, bridgePort443Open: true,
); );
@ -348,6 +386,62 @@ extra_configs:
expect(controller.extraConfigs.first.value, 'deepseek-new'); expect(controller.extraConfigs.first.value, 'deepseek-new');
expect(controller.extraConfigs.last.value, ''); expect(controller.extraConfigs.last.value, '');
}); });
test('post deploy verification fails when external probe does not connect', () async {
final controller = WorkspaceProvisionController(
executor: _FakeSshExecutor(
commandResults: [
const SshResult(exitCode: 0, stdout: 'pulled', stderr: ''),
const SshResult(exitCode: 0, stdout: 'wrote', stderr: ''),
const SshResult(
exitCode: 0,
stdout: '''
SERVICE_CADDY=active
SERVICE_XWORKMATE_BRIDGE=active
''',
stderr: '',
),
],
streamingChunks: [
'TASK [Configure caddy TLS]\nchanged: [localhost]\n',
],
),
externalPortProbe: (host) async {
throw PlaybookRunException('probe failed for $host');
},
);
addTearDown(controller.dispose);
controller.updateForm(
serverAddress: '203.0.113.10',
workspaceDomain: 'workspace.example.com',
sshKeyContent: 'key',
);
controller.serverInfo = const ServerInfo(
os: 'Ubuntu 22.04',
arch: 'x86_64',
sudoAvailable: true,
dockerVersion: 'missing',
systemdVersion: 'systemd 249',
caddyVersion: 'missing',
ansibleVersion: 'ansible [core 2.14]',
gitVersion: 'git version 2.34.1',
dnsAddressCount: 1,
port80ListenerCount: 0,
port80Open: true,
port443ListenerCount: 0,
port443Open: true,
bridgeDnsAddressCount: 1,
bridgePort80ListenerCount: 0,
bridgePort80Open: true,
bridgePort443ListenerCount: 0,
bridgePort443Open: true,
);
await controller.createWorkspace();
expect(controller.phase, ProvisionPhase.failed);
expect(controller.errorMessage, contains('probe failed'));
});
}); });
} }