Relax workspace OS checks and add YAML import/export

This commit is contained in:
Haitao Pan 2026-06-08 16:14:08 +08:00
parent c19a1c755e
commit d33e1df4d2
7 changed files with 506 additions and 41 deletions

View File

@ -18,10 +18,15 @@ class PlaybookRunner {
required SshConfig ssh,
required String action,
required String workspaceDomain,
required String bridgeDomain,
required String bridgeToken,
required String installPath,
required bool installMissingPrerequisites,
required ServerInfo? serverInfo,
String? deepseekApiKey,
String? nvidiaApiKey,
String? ollamaApiKey,
String? openclawGatewayToken,
required void Function(String stepId, StepStatus status, String? message)
onStepUpdate,
required void Function(String logLine) onLog,
@ -38,7 +43,11 @@ class PlaybookRunner {
var info = serverInfo;
if (info == null) {
onStepUpdate('ssh_connect', StepStatus.running, null);
info = await ServerDetector(executor).detect(ssh, workspaceDomain);
info = await ServerDetector(executor).detect(
ssh,
workspaceDomain,
bridgeDomain,
);
onStepUpdate('ssh_connect', StepStatus.success, null);
onStepUpdate('detect_env', StepStatus.success, info.displaySummary);
}
@ -68,7 +77,12 @@ class PlaybookRunner {
inventoryPath: inventoryPath,
varsPath: varsPath,
workspaceDomain: workspaceDomain,
bridgeDomain: bridgeDomain,
bridgeToken: bridgeToken,
deepseekApiKey: deepseekApiKey,
nvidiaApiKey: nvidiaApiKey,
ollamaApiKey: ollamaApiKey,
openclawGatewayToken: openclawGatewayToken,
),
onLog,
);
@ -167,10 +181,27 @@ class PlaybookRunner {
required String inventoryPath,
required String varsPath,
required String workspaceDomain,
required String bridgeDomain,
required String bridgeToken,
String? deepseekApiKey,
String? nvidiaApiKey,
String? ollamaApiKey,
String? openclawGatewayToken,
}) {
final domain = workspaceDomain.trim();
final publicUrl = 'https://$domain';
final bridge = bridgeDomain.trim();
final bridgeUrl = 'https://$bridge';
final extraEnvVars = <String>[
if ((deepseekApiKey ?? '').trim().isNotEmpty)
'deepseek_api_key: ${shellQuote(deepseekApiKey!.trim())}',
if ((nvidiaApiKey ?? '').trim().isNotEmpty)
'nvidia_api_key: ${shellQuote(nvidiaApiKey!.trim())}',
if ((ollamaApiKey ?? '').trim().isNotEmpty)
'ollama_api_key: ${shellQuote(ollamaApiKey!.trim())}',
if ((openclawGatewayToken ?? '').trim().isNotEmpty)
'openclaw_gateway_token: ${shellQuote(openclawGatewayToken!.trim())}',
];
final extraEnvBlock = extraEnvVars.isEmpty ? '' : '${extraEnvVars.join('\n')}\n';
return '''
cat > ${shellQuote(inventoryPath)} <<'EOF'
[all]
@ -178,12 +209,12 @@ localhost ansible_connection=local
EOF
cat > ${shellQuote(varsPath)} <<'EOF'
workspace_domain: $domain
xworkmate_bridge_domain: $domain
xworkmate_bridge_public_base_url: $publicUrl
xworkmate_bridge_service_domain: $domain
xworkmate_bridge_service_public_base_url: $publicUrl
xworkmate_bridge_domain: $bridge
xworkmate_bridge_public_base_url: $bridgeUrl
xworkmate_bridge_service_domain: $bridge
xworkmate_bridge_service_public_base_url: $bridgeUrl
xworkmate_bridge_auth_token: ${bridgeToken.trim()}
EOF
${extraEnvBlock}EOF
''';
}

View File

@ -6,10 +6,14 @@ class ServerDetector {
final WorkspaceSshExecutor executor;
Future<ServerInfo> detect(SshConfig ssh, String workspaceDomain) async {
Future<ServerInfo> detect(
SshConfig ssh,
String workspaceDomain,
String bridgeDomain,
) async {
final result = await executor.execute(
ssh,
detectionCommand(workspaceDomain),
detectionCommand(workspaceDomain, bridgeDomain),
);
if (!result.success) {
throw ServerDetectionException(result.combinedOutput.trim());
@ -17,8 +21,9 @@ class ServerDetector {
return parseServerInfo(result.stdout);
}
static String detectionCommand(String workspaceDomain) {
static String detectionCommand(String workspaceDomain, String bridgeDomain) {
final domain = shellQuote(workspaceDomain.trim());
final bridge = shellQuote(bridgeDomain.trim());
return '''
if command -v lsb_release >/dev/null 2>&1; then
echo "OS=\$(lsb_release -ds)"
@ -35,14 +40,17 @@ 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_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_443_LISTENERS=\$(ss -ltn '( sport = :443 )' 2>/dev/null | tail -n +2 | wc -l | tr -d ' ')"
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
echo "PORT_443_OPEN=yes"
PORT_443_OPEN=yes
elif printf '%s' "\$UFW_STATUS" | grep -Eqi '(^|[[:space:]])(443(/tcp)?|https)[[:space:]]+ALLOW'; then
echo "PORT_443_OPEN=yes"
PORT_443_OPEN=yes
else
echo "PORT_443_OPEN=no"
PORT_443_OPEN=no
fi
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)"
@ -51,16 +59,16 @@ if command -v ufw >/dev/null 2>&1; then
sudo -n firewall-cmd --quiet --query-service=https 2>/dev/null ||
firewall-cmd --quiet --query-port=443/tcp 2>/dev/null ||
sudo -n firewall-cmd --quiet --query-port=443/tcp 2>/dev/null; then
echo "PORT_443_OPEN=yes"
PORT_443_OPEN=yes
else
echo "PORT_443_OPEN=no"
PORT_443_OPEN=no
fi
else
echo "PORT_443_OPEN=yes"
PORT_443_OPEN=yes
fi
else
echo "PORT_443_OPEN=yes"
fi
echo "PORT_443_OPEN=\$PORT_443_OPEN"
echo "BRIDGE_PORT_443_OPEN=\$PORT_443_OPEN"
''';
}
@ -83,9 +91,17 @@ fi
ansibleVersion: values['ANSIBLE'] ?? 'missing',
gitVersion: values['GIT'] ?? 'missing',
dnsAddressCount: int.tryParse(values['DNS_OK'] ?? '') ?? 0,
port443ListenerCount:
port443ListenerCount:
int.tryParse(values['PORT_443_LISTENERS'] ?? '') ?? 0,
port443Open: (values['PORT_443_OPEN'] ?? '').toLowerCase() != 'no',
port443Open: (values['PORT_443_OPEN'] ?? '').toLowerCase() != 'no',
bridgeDnsAddressCount:
int.tryParse(values['BRIDGE_DNS_OK'] ?? '') ?? 0,
bridgePort443ListenerCount:
int.tryParse(values['BRIDGE_PORT_443_LISTENERS'] ?? '') ?? 0,
bridgePort443Open:
(values['BRIDGE_PORT_443_OPEN'] ?? values['PORT_443_OPEN'] ?? '')
.toLowerCase() !=
'no',
);
}
}

View File

@ -31,6 +31,10 @@ class _WorkspaceManagementFormState extends State<WorkspaceManagementForm> {
late final TextEditingController _portController;
late final TextEditingController _sudoController;
late final TextEditingController _installPathController;
late final TextEditingController _deepseekKeyController;
late final TextEditingController _nvidiaKeyController;
late final TextEditingController _ollamaKeyController;
late final TextEditingController _openclawTokenController;
@override
void initState() {
@ -45,6 +49,11 @@ class _WorkspaceManagementFormState extends State<WorkspaceManagementForm> {
_portController = TextEditingController(text: c.sshPort.toString());
_sudoController = TextEditingController(text: c.sudoPassword ?? '');
_installPathController = TextEditingController(text: c.installPath);
_deepseekKeyController = TextEditingController(text: c.deepseekApiKey ?? '');
_nvidiaKeyController = TextEditingController(text: c.nvidiaApiKey ?? '');
_ollamaKeyController = TextEditingController(text: c.ollamaApiKey ?? '');
_openclawTokenController =
TextEditingController(text: c.openclawGatewayToken ?? '');
}
@override
@ -58,6 +67,10 @@ class _WorkspaceManagementFormState extends State<WorkspaceManagementForm> {
_portController.dispose();
_sudoController.dispose();
_installPathController.dispose();
_deepseekKeyController.dispose();
_nvidiaKeyController.dispose();
_ollamaKeyController.dispose();
_openclawTokenController.dispose();
super.dispose();
}
@ -74,6 +87,10 @@ class _WorkspaceManagementFormState extends State<WorkspaceManagementForm> {
installPath: _installPathController.text.trim().isEmpty
? '/opt/xworkspace/playbooks'
: _installPathController.text.trim(),
deepseekApiKey: _deepseekKeyController.text,
nvidiaApiKey: _nvidiaKeyController.text,
ollamaApiKey: _ollamaKeyController.text,
openclawGatewayToken: _openclawTokenController.text,
);
}
@ -111,6 +128,19 @@ class _WorkspaceManagementFormState extends State<WorkspaceManagementForm> {
label: appText('Workspace 域名 *', 'Workspace domain *'),
icon: Icons.public_outlined,
),
SizedBox(
width: itemWidth,
child: Padding(
padding: const EdgeInsets.only(left: 14, top: 2),
child: Text(
appText(
'将检测桥接域名:${controller.bridgeDomain}',
'Bridge domain will be checked: ${controller.bridgeDomain}',
),
style: Theme.of(context).textTheme.bodySmall,
),
),
),
_field(
width: itemWidth,
controller: _userController,
@ -218,6 +248,38 @@ class _WorkspaceManagementFormState extends State<WorkspaceManagementForm> {
label: appText('安装路径', 'Install path'),
icon: Icons.storage_outlined,
),
_field(
width: 320,
controller: _deepseekKeyController,
enabled: !disabled,
label: 'DEEPSEEK_API_KEY',
icon: Icons.key_outlined,
obscureText: true,
),
_field(
width: 320,
controller: _nvidiaKeyController,
enabled: !disabled,
label: 'NVIDIA_API_KEY',
icon: Icons.key_outlined,
obscureText: true,
),
_field(
width: 320,
controller: _ollamaKeyController,
enabled: !disabled,
label: 'OLLAMA_API_KEY',
icon: Icons.key_outlined,
obscureText: true,
),
_field(
width: 320,
controller: _openclawTokenController,
enabled: !disabled,
label: 'OPENCLAW_GATEWAY_TOKEN',
icon: Icons.key_outlined,
obscureText: true,
),
],
),
],

View File

@ -108,6 +108,66 @@ class _WorkspaceManagementPanelState extends State<WorkspaceManagementPanel> {
}
}
Future<void> _exportConfig() async {
final yaml = _controller.exportYaml();
await Clipboard.setData(ClipboardData(text: yaml));
if (!mounted) {
return;
}
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text(appText('YAML 已导出', 'YAML exported')),
content: SingleChildScrollView(child: SelectableText(yaml)),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(appText('关闭', 'Close')),
),
],
),
);
}
Future<void> _importConfig() async {
final yamlController = TextEditingController();
try {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(appText('导入 YAML', 'Import YAML')),
content: SizedBox(
width: 720,
child: TextField(
controller: yamlController,
minLines: 12,
maxLines: 18,
decoration: InputDecoration(
hintText: appText('粘贴 YAML 配置', 'Paste YAML configuration'),
alignLabelWithHint: true,
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(appText('取消', 'Cancel')),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(appText('导入', 'Import')),
),
],
),
);
if (confirmed == true) {
_controller.importYaml(yamlController.text);
}
} finally {
yamlController.dispose();
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@ -139,6 +199,22 @@ class _WorkspaceManagementPanelState extends State<WorkspaceManagementPanel> {
),
),
),
TextButton.icon(
onPressed: _controller.isBusy
? null
: () => unawaited(_exportConfig()),
icon: const Icon(Icons.upload_outlined),
label: Text(appText('导出 YAML', 'Export YAML')),
),
const SizedBox(width: 8),
TextButton.icon(
onPressed: _controller.isBusy
? null
: () => unawaited(_importConfig()),
icon: const Icon(Icons.download_outlined),
label: Text(appText('导入 YAML', 'Import YAML')),
),
const SizedBox(width: 8),
IconButton(
onPressed: _controller.isBusy
? null

View File

@ -1,4 +1,5 @@
import 'package:flutter/foundation.dart';
import 'package:yaml/yaml.dart';
import '../../i18n/app_language.dart';
import 'playbook_runner.dart';
@ -27,9 +28,15 @@ class WorkspaceProvisionController extends ChangeNotifier {
int sshPort = 22;
String? sudoPassword;
String installPath = '/opt/xworkspace/playbooks';
String? deepseekApiKey;
String? nvidiaApiKey;
String? ollamaApiKey;
String? openclawGatewayToken;
bool showAdvanced = false;
bool logsExpanded = false;
static const String redactedValue = '__redacted__';
ProvisionPhase phase = ProvisionPhase.idle;
late List<ProvisionStep> steps;
final ProvisionLogBuffer logBuffer = ProvisionLogBuffer();
@ -40,6 +47,13 @@ class WorkspaceProvisionController extends ChangeNotifier {
bool get isBusy =>
phase == ProvisionPhase.checking || phase == ProvisionPhase.running;
String get bridgeDomain => deriveBridgeDomain(workspaceDomain);
String get bridgeBaseUrl {
final domain = bridgeDomain.trim();
return domain.isEmpty ? '' : 'https://$domain';
}
bool get canSubmit {
final hasAuth = switch (authMethod) {
AuthMethod.password => (sshPassword ?? '').trim().isNotEmpty,
@ -73,11 +87,12 @@ class WorkspaceProvisionController extends ChangeNotifier {
return;
}
_prepareRun(ProvisionPhase.checking);
_setStep('ssh_connect', StepStatus.running, null);
_setStep('ssh_connect', StepStatus.running, null);
try {
final detected = await ServerDetector(executor).detect(
sshConfig(),
workspaceDomain.trim(),
bridgeDomain,
);
serverInfo = detected;
_setStep('ssh_connect', StepStatus.success, null);
@ -111,6 +126,7 @@ class WorkspaceProvisionController extends ChangeNotifier {
final detected = await ServerDetector(executor).detect(
sshConfig(),
workspaceDomain.trim(),
bridgeDomain,
);
serverInfo = detected;
}
@ -128,7 +144,12 @@ class WorkspaceProvisionController extends ChangeNotifier {
ssh: sshConfig(),
action: 'create',
workspaceDomain: workspaceDomain.trim(),
bridgeDomain: bridgeDomain,
bridgeToken: bridgeToken,
deepseekApiKey: deepseekApiKey,
nvidiaApiKey: nvidiaApiKey,
ollamaApiKey: ollamaApiKey,
openclawGatewayToken: openclawGatewayToken,
installPath: installPath.trim(),
installMissingPrerequisites: installMissingPrerequisites,
serverInfo: serverInfo,
@ -142,7 +163,7 @@ class WorkspaceProvisionController extends ChangeNotifier {
}
phase = ProvisionPhase.success;
deploymentResult = WorkspaceDeploymentResult(
url: 'https://${workspaceDomain.trim()}',
url: bridgeBaseUrl,
bridgeToken: bridgeToken,
);
errorMessage = null;
@ -185,6 +206,10 @@ class WorkspaceProvisionController extends ChangeNotifier {
int? sshPort,
String? sudoPassword,
String? installPath,
String? deepseekApiKey,
String? nvidiaApiKey,
String? ollamaApiKey,
String? openclawGatewayToken,
bool? showAdvanced,
bool? logsExpanded,
}) {
@ -198,11 +223,74 @@ class WorkspaceProvisionController extends ChangeNotifier {
this.sshPort = sshPort ?? this.sshPort;
this.sudoPassword = sudoPassword ?? this.sudoPassword;
this.installPath = installPath ?? this.installPath;
this.deepseekApiKey = deepseekApiKey ?? this.deepseekApiKey;
this.nvidiaApiKey = nvidiaApiKey ?? this.nvidiaApiKey;
this.ollamaApiKey = ollamaApiKey ?? this.ollamaApiKey;
this.openclawGatewayToken =
openclawGatewayToken ?? this.openclawGatewayToken;
this.showAdvanced = showAdvanced ?? this.showAdvanced;
this.logsExpanded = logsExpanded ?? this.logsExpanded;
notifyListeners();
}
String exportYaml() {
final data = <String, Object?>{
'server_address': serverAddress.trim(),
'workspace_domain': workspaceDomain.trim(),
'ssh_username': sshUsername.trim(),
'auth_method': authMethod.name,
'ssh_port': sshPort,
'install_path': installPath.trim(),
'show_advanced': showAdvanced,
'logs_expanded': logsExpanded,
'ssh_password': redact(sshPassword),
'ssh_key_content': redact(sshKeyContent),
'ssh_key_path': redact(sshKeyPath),
'sudo_password': redact(sudoPassword),
'deepseek_api_key': redact(deepseekApiKey),
'nvidia_api_key': redact(nvidiaApiKey),
'ollama_api_key': redact(ollamaApiKey),
'openclaw_gateway_token': redact(openclawGatewayToken),
};
final buffer = StringBuffer();
for (final entry in data.entries) {
buffer.writeln('${entry.key}: ${yamlScalar(entry.value)}');
}
return buffer.toString().trimRight();
}
void importYaml(String raw) {
final decoded = loadYaml(raw);
if (decoded is! YamlMap) {
throw const FormatException('Invalid YAML document');
}
final map = <String, Object?>{};
for (final entry in decoded.nodes.entries) {
map['${entry.key.value}'] = entry.value.value;
}
updateForm(
serverAddress: stringValue(map['server_address']),
workspaceDomain: stringValue(map['workspace_domain']),
sshUsername: stringValue(map['ssh_username']),
authMethod: parseAuthMethod(map['auth_method']),
sshPassword: secretValue(map['ssh_password'], sshPassword),
sshKeyContent: secretValue(map['ssh_key_content'], sshKeyContent),
sshKeyPath: secretValue(map['ssh_key_path'], sshKeyPath),
sshPort: intValue(map['ssh_port'], sshPort),
sudoPassword: secretValue(map['sudo_password'], sudoPassword),
installPath: stringValue(map['install_path']),
deepseekApiKey: secretValue(map['deepseek_api_key'], deepseekApiKey),
nvidiaApiKey: secretValue(map['nvidia_api_key'], nvidiaApiKey),
ollamaApiKey: secretValue(map['ollama_api_key'], ollamaApiKey),
openclawGatewayToken: secretValue(
map['openclaw_gateway_token'],
openclawGatewayToken,
),
showAdvanced: boolValue(map['show_advanced'], showAdvanced),
logsExpanded: boolValue(map['logs_expanded'], logsExpanded),
);
}
void _prepareRun(ProvisionPhase nextPhase, {bool keepDetection = false}) {
phase = nextPhase;
errorMessage = null;
@ -251,7 +339,7 @@ class WorkspaceProvisionController extends ChangeNotifier {
String ensureBridgeToken() {
deploymentResult ??= WorkspaceDeploymentResult(
url: 'https://${workspaceDomain.trim()}',
url: bridgeBaseUrl,
bridgeToken: generateBridgeToken(),
);
return deploymentResult!.bridgeToken;
@ -265,28 +353,29 @@ class WorkspaceProvisionController extends ChangeNotifier {
if (info == null) {
return null;
}
if (!info.os.toLowerCase().contains('ubuntu')) {
final os = info.os.toLowerCase();
if (!(os.contains('ubuntu') || os.contains('debian'))) {
return appText(
'当前仅支持 Ubuntu 20.04 / 22.04 / 24.04,检测到 ${info.os}',
'Only Ubuntu 20.04 / 22.04 / 24.04 is supported. Detected: ${info.os}.',
'当前仅支持 Ubuntu / Debian 系列,检测到 ${info.os}',
'Only Ubuntu / Debian family systems are supported. Detected: ${info.os}.',
);
}
if (!info.dnsResolved) {
if (!info.bridgeDnsResolved) {
return appText(
'部署前需要先把 ${workspaceDomain.trim()} 做好 DNS 解析。',
'Configure DNS for ${workspaceDomain.trim()} before deploying.',
'部署前需要先把 $bridgeDomain 做好 DNS 解析。',
'Configure DNS for $bridgeDomain before deploying.',
);
}
if (!info.port443Open) {
if (!info.bridgePort443Open) {
return appText(
'目标服务器的 443 端口未开放,请先放通 HTTPS 访问。',
'Port 443 is not open on the target server. Allow HTTPS traffic first.',
'$bridgeDomain 的 443 端口未开放,请先放通 HTTPS 访问。',
'Port 443 is not open for $bridgeDomain. Allow HTTPS traffic first.',
);
}
if (!info.isPort443Available) {
if (!info.isBridgePort443Available) {
return appText(
'目标服务器的 443 端口已被占用,请先释放。',
'Port 443 is already in use on the target server.',
'$bridgeDomain 的 443 端口已被占用,请先释放。',
'Port 443 is already in use for $bridgeDomain.',
);
}
return null;
@ -297,8 +386,81 @@ class WorkspaceProvisionController extends ChangeNotifier {
sshPassword = null;
sshKeyContent = null;
sudoPassword = null;
deepseekApiKey = null;
nvidiaApiKey = null;
ollamaApiKey = null;
openclawGatewayToken = null;
super.dispose();
}
static String deriveBridgeDomain(String input) {
final domain = input.trim().toLowerCase();
if (domain.isEmpty) {
return '';
}
if (domain.startsWith('xworkmate-bridge.')) {
return domain;
}
return 'xworkmate-bridge.$domain';
}
static String redact(String? value) {
final trimmed = value?.trim() ?? '';
return trimmed.isEmpty ? '' : redactedValue;
}
static String yamlScalar(Object? value) {
if (value == null) {
return '""';
}
if (value is bool || value is num) {
return '$value';
}
final text = '$value';
if (text.isEmpty) {
return '""';
}
if (text == redactedValue || text.contains(RegExp(r'[:#\n\r\t]')) || text.startsWith(' ') || text.endsWith(' ')) {
return '"${text.replaceAll('"', '\\"')}"';
}
return text;
}
static String stringValue(Object? value) {
final text = value?.toString().trim() ?? '';
return text == redactedValue ? '' : text;
}
static String? secretValue(Object? value, String? current) {
final text = value?.toString().trim() ?? '';
if (text.isEmpty || text == redactedValue) {
return current;
}
return text;
}
static int intValue(Object? value, int fallback) {
return int.tryParse(value?.toString().trim() ?? '') ?? fallback;
}
static bool boolValue(Object? value, bool fallback) {
final text = value?.toString().trim().toLowerCase();
if (text == null || text.isEmpty) {
return fallback;
}
if (text == 'true' || text == 'yes' || text == '1') {
return true;
}
if (text == 'false' || text == 'no' || text == '0') {
return false;
}
return fallback;
}
static AuthMethod parseAuthMethod(Object? value) {
final text = value?.toString().trim().toLowerCase() ?? '';
return text == 'password' ? AuthMethod.password : AuthMethod.sshKey;
}
}
class WorkspaceProvisionPrecheckException implements Exception {

View File

@ -58,6 +58,9 @@ class ServerInfo {
required this.dnsAddressCount,
required this.port443ListenerCount,
required this.port443Open,
required this.bridgeDnsAddressCount,
required this.bridgePort443ListenerCount,
required this.bridgePort443Open,
});
final String os;
@ -71,12 +74,17 @@ class ServerInfo {
final int dnsAddressCount;
final int port443ListenerCount;
final bool port443Open;
final int bridgeDnsAddressCount;
final int bridgePort443ListenerCount;
final bool bridgePort443Open;
bool get gitMissing => _isMissing(gitVersion);
bool get ansibleMissing => _isMissing(ansibleVersion);
bool get hasMissingPrerequisites => gitMissing || ansibleMissing;
bool get dnsResolved => dnsAddressCount > 0;
bool get isPort443Available => port443ListenerCount == 0;
bool get bridgeDnsResolved => bridgeDnsAddressCount > 0;
bool get isBridgePort443Available => bridgePort443ListenerCount == 0;
String get displaySummary {
final sudo = sudoAvailable ? 'sudo=yes' : 'sudo=no';
@ -87,6 +95,9 @@ class ServerInfo {
dnsResolved ? 'dns=ok' : 'dns=missing',
port443Open ? '443=open' : '443=blocked',
isPort443Available ? '443=free' : '443=busy',
bridgeDnsResolved ? 'bridge-dns=ok' : 'bridge-dns=missing',
bridgePort443Open ? 'bridge-443=open' : 'bridge-443=blocked',
isBridgePort443Available ? 'bridge-443=free' : 'bridge-443=busy',
].join(', ');
}

View File

@ -49,6 +49,9 @@ GIT=git version 2.34.1
DNS_OK=1
PORT_443_LISTENERS=0
PORT_443_OPEN=yes
BRIDGE_DNS_OK=1
BRIDGE_PORT_443_LISTENERS=0
BRIDGE_PORT_443_OPEN=yes
''');
expect(info.os, 'Ubuntu 22.04.4 LTS');
@ -59,6 +62,9 @@ PORT_443_OPEN=yes
expect(info.dnsResolved, isTrue);
expect(info.port443Open, isTrue);
expect(info.isPort443Available, isTrue);
expect(info.bridgeDnsResolved, isTrue);
expect(info.bridgePort443Open, isTrue);
expect(info.isBridgePort443Available, isTrue);
});
test('ansible parser maps human readable output to step events', () {
@ -74,11 +80,36 @@ PORT_443_OPEN=yes
});
test('detection command quotes workspace domain', () {
final command = ServerDetector.detectionCommand("a'b.example.com");
final command = ServerDetector.detectionCommand(
"a'b.example.com",
'xworkmate-bridge.a\'b.example.com',
);
expect(command, contains("'a'\"'\"'b.example.com'"));
expect(command, contains('xworkmate-bridge.a'));
expect(command, contains('getent hosts'));
});
test('exported yaml redacts sensitive values', () {
final controller = WorkspaceProvisionController();
addTearDown(controller.dispose);
controller.updateForm(
serverAddress: '203.0.113.10',
workspaceDomain: 'onwalk.net',
sshUsername: 'root',
sshPassword: 'ssh-secret',
deepseekApiKey: 'deepseek-secret',
openclawGatewayToken: 'gateway-secret',
showAdvanced: true,
);
final yaml = controller.exportYaml();
expect(yaml, contains('server_address: 203.0.113.10'));
expect(yaml, contains('ssh_password_fixture: "example"'));
expect(yaml, contains('deepseek_api_key: "__redacted__"'));
expect(yaml, contains('openclaw_gateway_token: "__redacted__"'));
});
});
group('WorkspaceProvisionController', () {
@ -100,6 +131,9 @@ GIT=git version 2.43.0
DNS_OK=1
PORT_443_LISTENERS=0
PORT_443_OPEN=yes
BRIDGE_DNS_OK=1
BRIDGE_PORT_443_LISTENERS=0
BRIDGE_PORT_443_OPEN=yes
''',
stderr: '',
),
@ -153,12 +187,18 @@ PORT_443_OPEN=yes
dnsAddressCount: 1,
port443ListenerCount: 0,
port443Open: true,
bridgeDnsAddressCount: 1,
bridgePort443ListenerCount: 0,
bridgePort443Open: true,
);
await controller.createWorkspace();
expect(controller.phase, ProvisionPhase.success);
expect(controller.deploymentResult?.url, 'https://workspace.example.com');
expect(
controller.deploymentResult?.url,
'https://xworkmate-bridge.workspace.example.com',
);
expect(controller.deploymentResult?.bridgeToken, isNotEmpty);
expect(executor.commands.join('\n'), contains('ansible-playbook'));
});
@ -183,6 +223,9 @@ PORT_443_OPEN=yes
dnsAddressCount: 1,
port443ListenerCount: 0,
port443Open: false,
bridgeDnsAddressCount: 1,
bridgePort443ListenerCount: 0,
bridgePort443Open: false,
);
expect(
@ -191,7 +234,38 @@ PORT_443_OPEN=yes
);
});
test('precheck blocks unsupported non-Ubuntu systems', () async {
test('precheck blocks when bridge DNS is missing', () async {
final controller = WorkspaceProvisionController(executor: _FakeSshExecutor());
addTearDown(controller.dispose);
controller.updateForm(
serverAddress: '203.0.113.10',
workspaceDomain: 'onwalk.net',
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,
port443ListenerCount: 0,
port443Open: true,
bridgeDnsAddressCount: 0,
bridgePort443ListenerCount: 0,
bridgePort443Open: true,
);
expect(
controller.validatePrecheckBlockingIssue(),
contains('xworkmate-bridge.onwalk.net'),
);
});
test('precheck allows debian family systems', () async {
final controller = WorkspaceProvisionController(executor: _FakeSshExecutor());
addTearDown(controller.dispose);
controller.updateForm(
@ -211,12 +285,45 @@ PORT_443_OPEN=yes
dnsAddressCount: 1,
port443ListenerCount: 0,
port443Open: true,
bridgeDnsAddressCount: 1,
bridgePort443ListenerCount: 0,
bridgePort443Open: true,
);
expect(
controller.validatePrecheckBlockingIssue(),
contains('Ubuntu'),
expect(controller.validatePrecheckBlockingIssue(), isNull);
});
test('import yaml restores editable state without leaking redacted values', () {
final controller = WorkspaceProvisionController();
addTearDown(controller.dispose);
controller.updateForm(
serverAddress: 'old.example.com',
workspaceDomain: 'old.net',
sshUsername: 'root',
sshPassword: 'keep-secret',
showAdvanced: false,
);
controller.importYaml('''
server_address: 167.179.110.129
workspace_domain: onwalk.net
ssh_username: root
auth_method: password
ssh_port: 22
install_path: /opt/xworkspace/playbooks
show_advanced: true
logs_expanded: false
ssh_password_fixture: "example"
deepseek_api_key: "deepseek-new"
openclaw_gateway_token: "__redacted__"
''');
expect(controller.serverAddress, '167.179.110.129');
expect(controller.workspaceDomain, 'onwalk.net');
expect(controller.showAdvanced, isTrue);
expect(controller.sshPassword, 'keep-secret');
expect(controller.deepseekApiKey, 'deepseek-new');
expect(controller.openclawGatewayToken, isNull);
});
});
}