Relax workspace OS checks and add YAML import/export
This commit is contained in:
parent
26ee215765
commit
6d527a2618
@ -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
|
||||
''';
|
||||
}
|
||||
|
||||
|
||||
@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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(', ');
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user