From 03296b4e2115216ca332e8f31f3c38db84df0d98 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 8 Jun 2026 17:43:40 +0800 Subject: [PATCH] Relax workspace prechecks and add post-deploy validation --- .../workspace_management/server_detector.dart | 31 ++++- .../workspace_management_form.dart | 61 ++++++++++ .../workspace_provision_controller.dart | 111 ++++++++++++++++-- .../workspace_provision_models.dart | 14 +++ .../workspace_management_unit_test.dart | 106 ++++++++++++++++- 5 files changed, 303 insertions(+), 20 deletions(-) diff --git a/lib/features/workspace_management/server_detector.dart b/lib/features/workspace_management/server_detector.dart index 3adc360d..39d9718a 100644 --- a/lib/features/workspace_management/server_detector.dart +++ b/lib/features/workspace_management/server_detector.dart @@ -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 "GIT=\$(git --version 2>/dev/null || echo missing)" 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 "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 ' ')" +PORT_80_OPEN=yes PORT_443_OPEN=yes 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)" if printf '%s' "\$UFW_STATUS" | grep -qi 'Status: inactive'; then + PORT_80_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 else 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 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-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 || sudo -n firewall-cmd --quiet --query-service=https 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 fi else + PORT_80_OPEN=yes PORT_443_OPEN=yes fi fi +echo "PORT_80_OPEN=\$PORT_80_OPEN" echo "PORT_443_OPEN=\$PORT_443_OPEN" +echo "BRIDGE_PORT_80_OPEN=\$PORT_80_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', gitVersion: values['GIT'] ?? 'missing', dnsAddressCount: int.tryParse(values['DNS_OK'] ?? '') ?? 0, + port80ListenerCount: + int.tryParse(values['PORT_80_LISTENERS'] ?? '') ?? 0, + port80Open: (values['PORT_80_OPEN'] ?? '').toLowerCase() != 'no', port443ListenerCount: int.tryParse(values['PORT_443_LISTENERS'] ?? '') ?? 0, port443Open: (values['PORT_443_OPEN'] ?? '').toLowerCase() != 'no', bridgeDnsAddressCount: 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: int.tryParse(values['BRIDGE_PORT_443_LISTENERS'] ?? '') ?? 0, bridgePort443Open: diff --git a/lib/features/workspace_management/workspace_management_form.dart b/lib/features/workspace_management/workspace_management_form.dart index 66a1c4c3..7f87bb58 100644 --- a/lib/features/workspace_management/workspace_management_form.dart +++ b/lib/features/workspace_management/workspace_management_form.dart @@ -32,11 +32,13 @@ class _WorkspaceManagementFormState extends State { late final TextEditingController _sudoController; late final TextEditingController _installPathController; final List<_ExtraRowControllers> _extraRows = <_ExtraRowControllers>[]; + bool _syncingFromController = false; @override void initState() { super.initState(); final c = widget.controller; + widget.controller.addListener(_handleControllerUpdate); _serverController = TextEditingController(text: c.serverAddress); _domainController = TextEditingController(text: c.workspaceDomain); _userController = TextEditingController(text: c.sshUsername); @@ -57,8 +59,67 @@ class _WorkspaceManagementFormState extends State { } } + 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 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 void dispose() { + widget.controller.removeListener(_handleControllerUpdate); _serverController.dispose(); _domainController.dispose(); _userController.dispose(); diff --git a/lib/features/workspace_management/workspace_provision_controller.dart b/lib/features/workspace_management/workspace_provision_controller.dart index e5ab5e2e..b2a11728 100644 --- a/lib/features/workspace_management/workspace_provision_controller.dart +++ b/lib/features/workspace_management/workspace_provision_controller.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/foundation.dart'; import 'package:yaml/yaml.dart'; @@ -11,12 +13,15 @@ class WorkspaceProvisionController extends ChangeNotifier { WorkspaceProvisionController({ WorkspaceSshExecutor? executor, String initialWorkspaceDomain = '', + Future Function(String host)? externalPortProbe, }) : executor = executor ?? const DartSshExecutor(), - workspaceDomain = initialWorkspaceDomain { + workspaceDomain = initialWorkspaceDomain, + externalPortProbe = externalPortProbe ?? _probeExternalPorts { steps = defaultProvisionSteps(); } final WorkspaceSshExecutor executor; + final Future Function(String host) externalPortProbe; String serverAddress = ''; String workspaceDomain = ''; @@ -155,6 +160,7 @@ class WorkspaceProvisionController extends ChangeNotifier { onStepUpdate: _setStep, onLog: _appendLog, ); + await _verifyDeploymentReadiness(); for (final step in steps) { if (step.status == StepStatus.pending || step.status == StepStatus.running) { _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.', ); } - 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; } + Future _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 = {}; + 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 = [ + '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 _probeExternalPorts(String host) async { + final bridgeHost = host.trim(); + if (bridgeHost.isEmpty) { + return; + } + final probeFailures = []; + for (final port in [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 void dispose() { sshPassword = null; diff --git a/lib/features/workspace_management/workspace_provision_models.dart b/lib/features/workspace_management/workspace_provision_models.dart index 13270ed3..b4d0936d 100644 --- a/lib/features/workspace_management/workspace_provision_models.dart +++ b/lib/features/workspace_management/workspace_provision_models.dart @@ -56,9 +56,13 @@ class ServerInfo { required this.ansibleVersion, required this.gitVersion, required this.dnsAddressCount, + required this.port80ListenerCount, + required this.port80Open, required this.port443ListenerCount, required this.port443Open, required this.bridgeDnsAddressCount, + required this.bridgePort80ListenerCount, + required this.bridgePort80Open, required this.bridgePort443ListenerCount, required this.bridgePort443Open, }); @@ -72,9 +76,13 @@ class ServerInfo { final String ansibleVersion; final String gitVersion; final int dnsAddressCount; + final int port80ListenerCount; + final bool port80Open; final int port443ListenerCount; final bool port443Open; final int bridgeDnsAddressCount; + final int bridgePort80ListenerCount; + final bool bridgePort80Open; final int bridgePort443ListenerCount; final bool bridgePort443Open; @@ -82,8 +90,10 @@ class ServerInfo { bool get ansibleMissing => _isMissing(ansibleVersion); bool get hasMissingPrerequisites => gitMissing || ansibleMissing; bool get dnsResolved => dnsAddressCount > 0; + bool get isPort80Available => port80ListenerCount == 0; bool get isPort443Available => port443ListenerCount == 0; bool get bridgeDnsResolved => bridgeDnsAddressCount > 0; + bool get isBridgePort80Available => bridgePort80ListenerCount == 0; bool get isBridgePort443Available => bridgePort443ListenerCount == 0; String get displaySummary { @@ -93,9 +103,13 @@ class ServerInfo { if (arch.trim().isNotEmpty) arch.trim(), sudo, dnsResolved ? 'dns=ok' : 'dns=missing', + port80Open ? '80=open' : '80=blocked', + isPort80Available ? '80=free' : '80=busy', port443Open ? '443=open' : '443=blocked', isPort443Available ? '443=free' : '443=busy', 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', isBridgePort443Available ? 'bridge-443=free' : 'bridge-443=busy', ].join(', '); diff --git a/test/features/workspace_management/workspace_management_unit_test.dart b/test/features/workspace_management/workspace_management_unit_test.dart index c6dbd38c..78b389d7 100644 --- a/test/features/workspace_management/workspace_management_unit_test.dart +++ b/test/features/workspace_management/workspace_management_unit_test.dart @@ -47,10 +47,14 @@ CADDY=missing ANSIBLE=missing GIT=git version 2.34.1 DNS_OK=1 +PORT_80_LISTENERS=0 PORT_443_LISTENERS=0 +PORT_80_OPEN=yes PORT_443_OPEN=yes BRIDGE_DNS_OK=1 +BRIDGE_PORT_80_LISTENERS=0 BRIDGE_PORT_443_LISTENERS=0 +BRIDGE_PORT_80_OPEN=yes BRIDGE_PORT_443_OPEN=yes '''); @@ -60,9 +64,13 @@ BRIDGE_PORT_443_OPEN=yes expect(info.ansibleMissing, isTrue); expect(info.gitMissing, isFalse); expect(info.dnsResolved, isTrue); + expect(info.port80Open, isTrue); + expect(info.isPort80Available, isTrue); expect(info.port443Open, isTrue); expect(info.isPort443Available, isTrue); expect(info.bridgeDnsResolved, isTrue); + expect(info.bridgePort80Open, isTrue); + expect(info.isBridgePort80Available, isTrue); expect(info.bridgePort443Open, isTrue); expect(info.isBridgePort443Available, isTrue); }); @@ -147,10 +155,14 @@ CADDY=missing ANSIBLE=ansible [core 2.16] GIT=git version 2.43.0 DNS_OK=1 +PORT_80_LISTENERS=0 PORT_443_LISTENERS=0 +PORT_80_OPEN=yes PORT_443_OPEN=yes BRIDGE_DNS_OK=1 +BRIDGE_PORT_80_LISTENERS=0 BRIDGE_PORT_443_LISTENERS=0 +BRIDGE_PORT_80_OPEN=yes BRIDGE_PORT_443_OPEN=yes ''', stderr: '', @@ -180,13 +192,26 @@ BRIDGE_PORT_443_OPEN=yes 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 +SERVICE_OPENCLAW_GATEWAY=active +SERVICE_HERMES_GATEWAY=active +''', + stderr: '', + ), ], streamingChunks: [ 'TASK [Install desktop packages]\nok: [localhost]\n', 'TASK [Configure caddy TLS]\nchanged: [localhost]\n', ], ); - final controller = WorkspaceProvisionController(executor: executor); + final controller = WorkspaceProvisionController( + executor: executor, + externalPortProbe: (_) async {}, + ); addTearDown(controller.dispose); controller.updateForm( serverAddress: '203.0.113.10', @@ -203,9 +228,13 @@ BRIDGE_PORT_443_OPEN=yes 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, ); @@ -221,7 +250,7 @@ BRIDGE_PORT_443_OPEN=yes 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()); addTearDown(controller.dispose); controller.updateForm( @@ -239,17 +268,18 @@ BRIDGE_PORT_443_OPEN=yes ansibleVersion: 'ansible [core 2.14]', gitVersion: 'git version 2.34.1', dnsAddressCount: 1, + port80ListenerCount: 0, + port80Open: true, port443ListenerCount: 0, port443Open: false, bridgeDnsAddressCount: 1, + bridgePort80ListenerCount: 0, + bridgePort80Open: true, bridgePort443ListenerCount: 0, bridgePort443Open: false, ); - expect( - controller.validatePrecheckBlockingIssue(), - contains('443'), - ); + expect(controller.validatePrecheckBlockingIssue(), isNull); }); test('precheck blocks when bridge DNS is missing', () async { @@ -270,9 +300,13 @@ BRIDGE_PORT_443_OPEN=yes ansibleVersion: 'ansible [core 2.14]', gitVersion: 'git version 2.34.1', dnsAddressCount: 1, + port80ListenerCount: 0, + port80Open: true, port443ListenerCount: 0, port443Open: true, bridgeDnsAddressCount: 0, + bridgePort80ListenerCount: 0, + bridgePort80Open: true, bridgePort443ListenerCount: 0, bridgePort443Open: true, ); @@ -301,9 +335,13 @@ BRIDGE_PORT_443_OPEN=yes 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, ); @@ -348,6 +386,62 @@ extra_configs: expect(controller.extraConfigs.first.value, 'deepseek-new'); 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')); + }); }); }