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 "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:

View File

@ -32,11 +32,13 @@ class _WorkspaceManagementFormState extends State<WorkspaceManagementForm> {
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<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
void dispose() {
widget.controller.removeListener(_handleControllerUpdate);
_serverController.dispose();
_domainController.dispose();
_userController.dispose();

View File

@ -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<void> Function(String host)? externalPortProbe,
}) : executor = executor ?? const DartSshExecutor(),
workspaceDomain = initialWorkspaceDomain {
workspaceDomain = initialWorkspaceDomain,
externalPortProbe = externalPortProbe ?? _probeExternalPorts {
steps = defaultProvisionSteps();
}
final WorkspaceSshExecutor executor;
final Future<void> 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<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
void dispose() {
sshPassword = null;

View File

@ -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(', ');

View File

@ -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'));
});
});
}