feat(workspace): run remote setup script

This commit is contained in:
Haitao Pan 2026-06-14 14:28:20 +08:00
parent 5932b70f70
commit 4b08f76232
6 changed files with 410 additions and 284 deletions

View File

@ -1,5 +1,3 @@
import 'dart:async';
import '../../i18n/app_language.dart';
import 'server_detector.dart';
import 'ssh_executor.dart';
@ -8,9 +6,8 @@ import 'workspace_provision_models.dart';
class PlaybookRunner {
const PlaybookRunner(this.executor);
static const String playbookRepoUrl = 'https://github.com/x-evor/playbooks.git';
static const String createPlaybook = 'setup-ai-workspace-all-in-one.yml';
static const String upgradePlaybook = 'upgrade-ai-workspace.yml';
static const String setupScriptUrl =
'https://raw.githubusercontent.com/ai-workspace-lab/xworkspace-console/main/scripts/setup-ai-workspace-all-in-one.sh';
final WorkspaceSshExecutor executor;
@ -20,74 +17,44 @@ class PlaybookRunner {
required String workspaceDomain,
required String bridgeDomain,
required String bridgeToken,
required String installPath,
required bool installMissingPrerequisites,
required ServerInfo? serverInfo,
List<WorkspaceExtraConfig>? extraConfigs,
required void Function(String stepId, StepStatus status, String? message)
onStepUpdate,
required void Function(String logLine) onLog,
}) async {
if (action == 'upgrade') {
throw PlaybookRunException(
appText(
'playbooks 仓库尚未提供 $upgradePlaybook',
'The playbooks repository does not provide $upgradePlaybook yet.',
),
);
}
var info = serverInfo;
if (info == null) {
onStepUpdate('ssh_connect', StepStatus.running, null);
info = await ServerDetector(executor).detect(
ssh,
workspaceDomain,
bridgeDomain,
);
info = await ServerDetector(
executor,
).detect(ssh, workspaceDomain, bridgeDomain);
onStepUpdate('ssh_connect', StepStatus.success, null);
onStepUpdate('detect_env', StepStatus.success, info.displaySummary);
}
if (info.hasMissingPrerequisites) {
if (!installMissingPrerequisites) {
throw PlaybookRunException(
appText(
'目标服务器缺少 git 或 ansible。',
'The target server is missing git or ansible.',
),
);
}
onStepUpdate('install_deps', StepStatus.running, appText('安装 git/ansible', 'Installing git/ansible'));
await _executeChecked(ssh, _preflightInstallCommand(ssh), onLog);
onStepUpdate('install_deps', StepStatus.success, appText('基础依赖已安装', 'Base dependencies installed'));
}
onStepUpdate(
'install_deps',
StepStatus.running,
appText('检查 curl', 'Checking curl'),
);
await _executeChecked(ssh, _preflightInstallCommand(ssh), onLog);
onStepUpdate(
'install_deps',
StepStatus.success,
appText('curl 已就绪', 'curl is ready'),
);
onStepUpdate('install_deps', StepStatus.running, appText('拉取 playbooks', 'Fetching playbooks'));
await _executeChecked(ssh, _cloneOrPullCommand(installPath), onLog);
final inventoryPath = '/tmp/xworkspace-inventory.ini';
final varsPath = '/tmp/xworkspace-vars.yml';
await _executeChecked(
ssh,
_writeInventoryAndVarsCommand(
inventoryPath: inventoryPath,
varsPath: varsPath,
await _runSetupScript(
ssh: ssh,
command: setupScriptCommand(
ssh: ssh,
action: action,
workspaceDomain: workspaceDomain,
bridgeDomain: bridgeDomain,
bridgeToken: bridgeToken,
extraConfigs: extraConfigs,
),
onLog,
);
await _runAnsible(
ssh: ssh,
command: _ansibleCommand(
installPath: installPath,
inventoryPath: inventoryPath,
varsPath: varsPath,
),
onStepUpdate: onStepUpdate,
onLog: onLog,
);
@ -109,15 +76,20 @@ class PlaybookRunner {
}
}
Future<void> _runAnsible({
Future<void> _runSetupScript({
required SshConfig ssh,
required String command,
required void Function(String stepId, StepStatus status, String? message)
onStepUpdate,
required void Function(String logLine) onLog,
}) async {
final parser = AnsibleOutputParser();
final parser = SetupScriptOutputParser();
var failed = false;
onStepUpdate(
'deploy_webrtc',
StepStatus.running,
appText('执行远程安装脚本', 'Running remote setup script'),
);
await for (final chunk in executor.executeStreaming(ssh, command)) {
for (final raw in chunk.split(RegExp(r'\r?\n'))) {
final line = raw.trimRight();
@ -136,7 +108,9 @@ class PlaybookRunner {
}
}
if (failed) {
throw PlaybookRunException(appText('Playbook 执行失败。', 'Playbook execution failed.'));
throw PlaybookRunException(
appText('远程安装脚本执行失败。', 'Remote setup script failed.'),
);
}
for (final id in <String>[
'install_deps',
@ -151,29 +125,24 @@ class PlaybookRunner {
}
static String _preflightInstallCommand(SshConfig ssh) {
final apt = 'DEBIAN_FRONTEND=noninteractive apt-get update && '
'DEBIAN_FRONTEND=noninteractive apt-get install -y git ansible';
final apt =
'DEBIAN_FRONTEND=noninteractive apt-get update && '
'DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates';
if (ssh.username == 'root') {
return apt;
return 'if command -v curl >/dev/null 2>&1; then echo curl ready; else $apt; fi';
}
final sudoPassword = ssh.sudoPassword?.trim();
if (sudoPassword != null && sudoPassword.isNotEmpty) {
return "printf '%s\\n' ${shellQuote(sudoPassword)} | sudo -S sh -lc ${shellQuote(apt)}";
return 'if command -v curl >/dev/null 2>&1; then echo curl ready; '
"else printf '%s\\n' ${shellQuote(sudoPassword)} | sudo -S sh -lc ${shellQuote(apt)}; fi";
}
return 'sudo -n sh -lc ${shellQuote(apt)}';
return 'if command -v curl >/dev/null 2>&1; then echo curl ready; '
'else sudo -n sh -lc ${shellQuote(apt)}; fi';
}
static String _cloneOrPullCommand(String installPath) {
final path = shellQuote(installPath.trim());
final repo = shellQuote(playbookRepoUrl);
return 'mkdir -p $path && cd $path && '
'if [ -d .git ]; then git pull --ff-only origin main; '
'else git clone $repo .; fi';
}
static String _writeInventoryAndVarsCommand({
required String inventoryPath,
required String varsPath,
static String setupScriptCommand({
required SshConfig ssh,
required String action,
required String workspaceDomain,
required String bridgeDomain,
required String bridgeToken,
@ -182,91 +151,96 @@ class PlaybookRunner {
final domain = workspaceDomain.trim();
final bridge = bridgeDomain.trim();
final bridgeUrl = 'https://$bridge';
final extraEnvVars = <String>[];
final env = <String, String>{
'XWORKSPACE_SETUP_ACTION': action.trim().isEmpty
? 'create'
: action.trim(),
'WORKSPACE_DOMAIN': domain,
'XWORKSPACE_CONSOLE_DOMAIN': domain,
'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(),
'TOKEN': bridgeToken.trim(),
};
for (final config in extraConfigs ?? const <WorkspaceExtraConfig>[]) {
final key = config.key.trim();
final value = config.value.trim();
if (key.isEmpty || value.isEmpty) {
continue;
}
extraEnvVars.add('$key: ${shellQuote(value)}');
final note = config.note.trim();
if (note.isNotEmpty) {
extraEnvVars.add('# ${note.length > 20 ? note.substring(0, 20) : note}');
}
env[key] = value;
}
final extraEnvBlock =
extraEnvVars.isEmpty ? '' : '${extraEnvVars.join('\n')}\n';
return '''
cat > ${shellQuote(inventoryPath)} <<'EOF'
[all]
localhost ansible_connection=local
EOF
cat > ${shellQuote(varsPath)} <<'EOF'
workspace_domain: $domain
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()}
${extraEnvBlock}EOF
''';
final envArgs = env.entries
.where((entry) => _isValidEnvKey(entry.key))
.map((entry) => '${entry.key}=${shellQuote(entry.value)}')
.join(' ');
final script =
'set -o pipefail; curl -sfL ${shellQuote(setupScriptUrl)} | bash -';
final command = 'env $envArgs bash -lc ${shellQuote(script)} 2>&1';
if (ssh.username == 'root') {
return command;
}
final sudoPassword = ssh.sudoPassword?.trim();
if (sudoPassword != null && sudoPassword.isNotEmpty) {
return "printf '%s\\n' ${shellQuote(sudoPassword)} | sudo -S $command";
}
return 'sudo -n $command';
}
static String _ansibleCommand({
required String installPath,
required String inventoryPath,
required String varsPath,
}) {
return 'cd ${shellQuote(installPath.trim())} && '
'ANSIBLE_FORCE_COLOR=0 ansible-playbook '
'-i ${shellQuote(inventoryPath)} '
'${shellQuote(createPlaybook)} '
'-e @${shellQuote(varsPath)} 2>&1';
static bool _isValidEnvKey(String key) {
return RegExp(r'^[A-Za-z_][A-Za-z0-9_]*$').hasMatch(key);
}
}
class AnsibleStepEvent {
const AnsibleStepEvent(this.stepId, this.status, this.message);
class SetupScriptStepEvent {
const SetupScriptStepEvent(this.stepId, this.status, this.message);
final String stepId;
final StepStatus status;
final String? message;
}
class AnsibleOutputParser {
String? _currentStepId;
String? _currentTask;
AnsibleStepEvent? parseLine(String line) {
final taskMatch = RegExp(r'^TASK \[(.+?)\]').firstMatch(line);
if (taskMatch != null) {
_currentTask = taskMatch.group(1);
_currentStepId = stepIdForTask(_currentTask ?? '');
return AnsibleStepEvent(_currentStepId!, StepStatus.running, _currentTask);
}
if (_currentStepId == null) {
return null;
}
class SetupScriptOutputParser {
SetupScriptStepEvent? parseLine(String line) {
final lower = line.toLowerCase();
if (lower.startsWith('fatal:') || lower.contains(' failed=')) {
return AnsibleStepEvent(_currentStepId!, StepStatus.failed, line);
if (lower.contains('error') ||
lower.contains('failed') ||
lower.contains('fatal')) {
return SetupScriptStepEvent(
stepIdForText(lower),
StepStatus.failed,
line,
);
}
if (lower.startsWith('ok:') || lower.startsWith('changed:')) {
return AnsibleStepEvent(_currentStepId!, StepStatus.success, _currentTask);
final stepId = stepIdForText(lower);
if (lower.contains('install') ||
lower.contains('deploy') ||
lower.contains('config') ||
lower.contains('start') ||
lower.contains('enable') ||
lower.contains('caddy') ||
lower.contains('gateway') ||
lower.contains('bridge') ||
lower.contains('xworkspace')) {
return SetupScriptStepEvent(stepId, StepStatus.running, line);
}
if (lower.startsWith('skipping:')) {
return AnsibleStepEvent(_currentStepId!, StepStatus.skipped, _currentTask);
if (lower.contains('done') ||
lower.contains('success') ||
lower.contains('complete')) {
return SetupScriptStepEvent(stepId, StepStatus.success, line);
}
return null;
}
static String stepIdForTask(String task) {
final text = task.toLowerCase();
static String stepIdForText(String text) {
if (text.contains('bridge') || text.contains('acp_server')) {
return 'deploy_bridge';
}
if (text.contains('caddy') || text.contains('tls') || text.contains('cert')) {
if (text.contains('caddy') ||
text.contains('tls') ||
text.contains('cert')) {
return 'config_caddy';
}
if (text.contains('gateway') || text.contains('openclaw')) {

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../../i18n/app_language.dart';
import 'playbook_runner.dart';
import 'workspace_provision_controller.dart';
import 'workspace_provision_models.dart';
@ -10,11 +11,13 @@ class WorkspaceManagementForm extends StatefulWidget {
required this.controller,
required this.onDetect,
required this.onCreate,
required this.onUpgrade,
});
final WorkspaceProvisionController controller;
final VoidCallback onDetect;
final VoidCallback onCreate;
final VoidCallback onUpgrade;
@override
State<WorkspaceManagementForm> createState() =>
@ -47,7 +50,9 @@ class _WorkspaceManagementFormState extends State<WorkspaceManagementForm> {
_keyPathController = TextEditingController(text: c.sshKeyPath ?? '');
_portController = TextEditingController(text: c.sshPort.toString());
_sudoController = TextEditingController(text: c.sudoPassword ?? '');
_installPathController = TextEditingController(text: c.installPath);
_installPathController = TextEditingController(
text: PlaybookRunner.setupScriptUrl,
);
for (final row in c.extraConfigs) {
_extraRows.add(
_ExtraRowControllers(
@ -73,7 +78,7 @@ class _WorkspaceManagementFormState extends State<WorkspaceManagementForm> {
_syncText(_keyPathController, widget.controller.sshKeyPath ?? '');
_syncText(_portController, widget.controller.sshPort.toString());
_syncText(_sudoController, widget.controller.sudoPassword ?? '');
_syncText(_installPathController, widget.controller.installPath);
_syncText(_installPathController, PlaybookRunner.setupScriptUrl);
_syncExtraRows(widget.controller.extraConfigs);
} finally {
_syncingFromController = false;
@ -145,9 +150,7 @@ class _WorkspaceManagementFormState extends State<WorkspaceManagementForm> {
sshKeyPath: _keyPathController.text.trim(),
sshPort: int.tryParse(_portController.text.trim()) ?? 22,
sudoPassword: _sudoController.text,
installPath: _installPathController.text.trim().isEmpty
? '/opt/xworkspace/playbooks'
: _installPathController.text.trim(),
installPath: '',
extraConfigs: _extraRows
.map(
(row) => WorkspaceExtraConfig(
@ -311,8 +314,8 @@ class _WorkspaceManagementFormState extends State<WorkspaceManagementForm> {
_field(
width: 320,
controller: _installPathController,
enabled: !disabled,
label: appText('安装路径', 'Install path'),
enabled: false,
label: appText('执行脚本', 'Setup script'),
icon: Icons.storage_outlined,
),
_ExtraConfigEditor(
@ -367,17 +370,16 @@ class _WorkspaceManagementFormState extends State<WorkspaceManagementForm> {
icon: const Icon(Icons.rocket_launch_outlined),
label: Text(appText('创建工作空间', 'Create workspace')),
),
Tooltip(
message: appText(
'等待 playbooks 仓库提供 upgrade-ai-workspace.yml 后启用',
'Enabled after playbooks provides upgrade-ai-workspace.yml',
),
child: FilledButton.tonalIcon(
key: const Key('workspace-management-upgrade-button'),
onPressed: null,
icon: const Icon(Icons.system_update_alt_outlined),
label: Text(appText('升级工作空间', 'Upgrade workspace')),
),
FilledButton.tonalIcon(
key: const Key('workspace-management-upgrade-button'),
onPressed: disabled
? null
: () {
_sync();
widget.onUpgrade();
},
icon: const Icon(Icons.system_update_alt_outlined),
label: Text(appText('升级工作空间', 'Upgrade workspace')),
),
],
),
@ -438,7 +440,7 @@ class _ExtraConfigEditor extends StatelessWidget {
Row(
children: [
Text(
appText('额外配置', 'Extra configs'),
appText('脚本参数', 'Script parameters'),
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
@ -569,7 +571,9 @@ class _ExtraRowControllers {
bool get isSensitiveKey {
final key = keyController.text.trim().toUpperCase();
return key.contains('KEY') || key.contains('TOKEN') || key.contains('SECRET');
return key.contains('KEY') ||
key.contains('TOKEN') ||
key.contains('SECRET');
}
void dispose() {

View File

@ -62,7 +62,9 @@ class _WorkspaceManagementPanelState extends State<WorkspaceManagementPanel> {
final connection = widget.appController.connection;
if (connection.status == RuntimeConnectionStatus.connected) {
final remote = connection.remoteAddress?.trim() ?? '';
final parsed = Uri.tryParse(remote.contains('://') ? remote : 'https://$remote');
final parsed = Uri.tryParse(
remote.contains('://') ? remote : 'https://$remote',
);
if (parsed != null && parsed.host.trim().isNotEmpty) {
return parsed.host.trim();
}
@ -104,7 +106,45 @@ class _WorkspaceManagementPanelState extends State<WorkspaceManagementPanel> {
),
);
if (confirmed == true) {
unawaited(_controller.createWorkspace(installMissingPrerequisites: true));
unawaited(_controller.createWorkspace());
}
}
Future<void> _confirmUpgrade() async {
if (!_controller.canSubmit) {
await _controller.upgradeWorkspace();
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(appText('确认升级工作空间', 'Confirm workspace upgrade')),
content: Text(
appText(
'即将在 ${_controller.serverAddress} 上执行远程 all-in-one 脚本升级 AI 工作空间。\n\n'
'域名: ${_controller.workspaceDomain}\n'
'SSH 用户: ${_controller.sshUsername}\n\n'
'该操作会通过 curl 下载脚本并在远程主机执行,请确认这是你自己的服务器。',
'XWorkmate will run the remote all-in-one script to upgrade the AI Workspace on ${_controller.serverAddress}.\n\n'
'Domain: ${_controller.workspaceDomain}\n'
'SSH user: ${_controller.sshUsername}\n\n'
'This downloads the script with curl and runs it on the remote host. Confirm this is your own server.',
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(appText('取消', 'Cancel')),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(appText('确认升级', 'Upgrade')),
),
],
),
);
if (confirmed == true) {
unawaited(_controller.upgradeWorkspace());
}
}
@ -234,8 +274,10 @@ class _WorkspaceManagementPanelState extends State<WorkspaceManagementPanel> {
children: [
WorkspaceManagementForm(
controller: _controller,
onDetect: () => unawaited(_controller.detectServer()),
onDetect: () =>
unawaited(_controller.detectServer()),
onCreate: () => unawaited(_confirmCreate()),
onUpgrade: () => unawaited(_confirmUpgrade()),
),
const SizedBox(height: 20),
WorkspaceManagementSteps(steps: _controller.steps),
@ -275,9 +317,8 @@ class _LogPanel extends StatelessWidget {
children: [
TextButton.icon(
key: const Key('workspace-management-log-toggle'),
onPressed: () => controller.updateForm(
logsExpanded: !controller.logsExpanded,
),
onPressed: () =>
controller.updateForm(logsExpanded: !controller.logsExpanded),
icon: Icon(
controller.logsExpanded ? Icons.expand_less : Icons.expand_more,
),

View File

@ -32,12 +32,40 @@ class WorkspaceProvisionController extends ChangeNotifier {
String? sshKeyPath;
int sshPort = 22;
String? sudoPassword;
String installPath = '/opt/xworkspace/playbooks';
String installPath = '';
final List<WorkspaceExtraConfig> extraConfigs = <WorkspaceExtraConfig>[
WorkspaceExtraConfig(key: 'DEEPSEEK_API_KEY', value: '', note: ''),
WorkspaceExtraConfig(key: 'NVIDIA_API_KEY', value: '', note: ''),
WorkspaceExtraConfig(key: 'OLLAMA_API_KEY', value: '', note: ''),
WorkspaceExtraConfig(key: 'OPENCLAW_GATEWAY_TOKEN', value: '', note: ''),
WorkspaceExtraConfig(
key: 'AI_WORKSPACE_SECURITY_LEVEL',
value: 'strict',
note: '',
),
WorkspaceExtraConfig(
key: 'XWORKSPACE_CONSOLE_ENABLE_XRDP',
value: 'true',
note: '',
),
WorkspaceExtraConfig(
key: 'XWORKSPACE_CONSOLE_PUBLIC_ACCESS',
value: 'true',
note: '',
),
WorkspaceExtraConfig(
key: 'XWORKMATE_BRIDGE_PUBLIC_ACCESS',
value: 'true',
note: '',
),
WorkspaceExtraConfig(
key: 'GATEWAY_OPENCLAW_PUBLIC_ACCESS',
value: 'false',
note: '',
),
WorkspaceExtraConfig(key: 'VAULT_PUBLIC_ACCESS', value: 'false', note: ''),
WorkspaceExtraConfig(
key: 'LITELLM_API_CADDY_STRICT_WHITELIST',
value: 'true',
note: '',
),
WorkspaceExtraConfig(key: 'TOKEN', value: '', note: ''),
];
bool showAdvanced = false;
bool logsExpanded = false;
@ -96,11 +124,9 @@ class WorkspaceProvisionController extends ChangeNotifier {
_prepareRun(ProvisionPhase.checking);
_setStep('ssh_connect', StepStatus.running, null);
try {
final detected = await ServerDetector(executor).detect(
sshConfig(),
workspaceDomain.trim(),
bridgeDomain,
);
final detected = await ServerDetector(
executor,
).detect(sshConfig(), workspaceDomain.trim(), bridgeDomain);
serverInfo = detected;
_setStep('ssh_connect', StepStatus.success, null);
final blockingIssue = validatePrecheckBlockingIssueFor(detected);
@ -122,7 +148,7 @@ class WorkspaceProvisionController extends ChangeNotifier {
}
}
Future<void> createWorkspace({bool installMissingPrerequisites = false}) async {
Future<void> createWorkspace() async {
if (!canSubmit) {
_fail(WorkspaceProvisionValidationException());
return;
@ -130,11 +156,9 @@ class WorkspaceProvisionController extends ChangeNotifier {
_prepareRun(ProvisionPhase.running, keepDetection: true);
try {
if (serverInfo == null) {
final detected = await ServerDetector(executor).detect(
sshConfig(),
workspaceDomain.trim(),
bridgeDomain,
);
final detected = await ServerDetector(
executor,
).detect(sshConfig(), workspaceDomain.trim(), bridgeDomain);
serverInfo = detected;
}
final blockingIssue = validatePrecheckBlockingIssue();
@ -154,15 +178,14 @@ class WorkspaceProvisionController extends ChangeNotifier {
bridgeDomain: bridgeDomain,
bridgeToken: bridgeToken,
extraConfigs: extraConfigs,
installPath: installPath.trim(),
installMissingPrerequisites: installMissingPrerequisites,
serverInfo: serverInfo,
onStepUpdate: _setStep,
onLog: _appendLog,
);
await _verifyDeploymentReadiness();
for (final step in steps) {
if (step.status == StepStatus.pending || step.status == StepStatus.running) {
if (step.status == StepStatus.pending ||
step.status == StepStatus.running) {
_setStep(step.id, StepStatus.success, null);
}
}
@ -180,14 +203,57 @@ class WorkspaceProvisionController extends ChangeNotifier {
}
Future<void> upgradeWorkspace() async {
_fail(
PlaybookRunException(
appText(
'升级功能等待 playbooks 仓库提供 upgrade-ai-workspace.yml 后启用。',
'Upgrade waits for upgrade-ai-workspace.yml in the playbooks repository.',
),
),
);
if (!canSubmit) {
_fail(WorkspaceProvisionValidationException());
return;
}
_prepareRun(ProvisionPhase.running, keepDetection: true);
try {
if (serverInfo == null) {
final detected = await ServerDetector(
executor,
).detect(sshConfig(), workspaceDomain.trim(), bridgeDomain);
serverInfo = detected;
}
final blockingIssue = validatePrecheckBlockingIssue();
if (blockingIssue != null) {
_setStep('detect_env', StepStatus.failed, blockingIssue);
throw WorkspaceProvisionPrecheckException(blockingIssue);
}
if (serverInfo != null) {
_setStep('ssh_connect', StepStatus.success, null);
_setStep('detect_env', StepStatus.success, serverInfo!.displaySummary);
}
final bridgeToken = ensureBridgeToken();
await PlaybookRunner(executor).run(
ssh: sshConfig(),
action: 'upgrade',
workspaceDomain: workspaceDomain.trim(),
bridgeDomain: bridgeDomain,
bridgeToken: bridgeToken,
extraConfigs: extraConfigs,
serverInfo: serverInfo,
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);
}
}
phase = ProvisionPhase.success;
deploymentResult = WorkspaceDeploymentResult(
url: bridgeBaseUrl,
bridgeToken: bridgeToken,
);
errorMessage = null;
_appendLog(appText('工作空间升级完成。', 'Workspace upgrade completed.'));
notifyListeners();
} catch (error) {
_fail(error);
}
}
void reset() {
@ -243,7 +309,7 @@ class WorkspaceProvisionController extends ChangeNotifier {
MapEntry('ssh_username', sshUsername.trim()),
MapEntry('auth_method', authMethod.name),
MapEntry('ssh_port', sshPort),
MapEntry('install_path', installPath.trim()),
MapEntry('setup_script_url', PlaybookRunner.setupScriptUrl),
MapEntry('show_advanced', showAdvanced),
MapEntry('logs_expanded', logsExpanded),
MapEntry('ssh_password', redact(sshPassword)),
@ -421,8 +487,12 @@ class WorkspaceProvisionController extends ChangeNotifier {
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(
'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');
}
@ -493,7 +563,10 @@ class WorkspaceProvisionController extends ChangeNotifier {
if (text.isEmpty) {
return '""';
}
if (text == redactedValue || text.contains(RegExp(r'[:#\n\r\t]')) || text.startsWith(' ') || text.endsWith(' ')) {
if (text == redactedValue ||
text.contains(RegExp(r'[:#\n\r\t]')) ||
text.startsWith(' ') ||
text.endsWith(' ')) {
return '"${text.replaceAll('"', '\\"')}"';
}
return text;
@ -544,9 +617,7 @@ class WorkspaceProvisionController extends ChangeNotifier {
Object? value,
List<WorkspaceExtraConfig> current,
) {
final existing = {
for (final config in current) config.key.trim(): config,
};
final existing = {for (final config in current) config.key.trim(): config};
final parsed = <WorkspaceExtraConfig>[];
if (value is YamlList) {
for (final item in value) {

View File

@ -78,16 +78,37 @@ BRIDGE_PORT_443_OPEN=yes
expect(info.displaySummary, contains('桥接 443 端口当前空闲'));
});
test('ansible parser maps human readable output to step events', () {
final parser = AnsibleOutputParser();
test('setup script command passes env to remote bash', () {
final command = PlaybookRunner.setupScriptCommand(
ssh: const SshConfig(
host: '203.0.113.10',
port: 22,
username: 'root',
authMethod: AuthMethod.sshKey,
),
action: 'create',
workspaceDomain: 'workspace.example.com',
bridgeDomain: 'xworkmate-bridge.workspace.example.com',
bridgeToken: "tok'en",
extraConfigs: [
WorkspaceExtraConfig(
key: 'AI_WORKSPACE_SECURITY_LEVEL',
value: 'strict',
),
WorkspaceExtraConfig(
key: 'XWORKSPACE_CONSOLE_ENABLE_XRDP',
value: 'true',
),
],
);
final start = parser.parseLine('TASK [Configure caddy TLS]');
final ok = parser.parseLine('changed: [localhost]');
expect(start?.stepId, 'config_caddy');
expect(start?.status, StepStatus.running);
expect(ok?.stepId, 'config_caddy');
expect(ok?.status, StepStatus.success);
expect(command, contains('curl -sfL'));
expect(command, contains(PlaybookRunner.setupScriptUrl));
expect(command, contains('AI_WORKSPACE_SECURITY_LEVEL='));
expect(command, contains('XWORKSPACE_CONSOLE_ENABLE_XRDP='));
expect(command, contains('TOKEN='));
expect(command, contains("'tok'\"'\"'en'"));
expect(command, contains('bash -lc'));
});
test('detection command quotes workspace domain', () {
@ -103,7 +124,9 @@ BRIDGE_PORT_443_OPEN=yes
test('bridge domain uses user input when already a bridge host', () {
expect(
WorkspaceProvisionController.deriveBridgeDomain('acp-bridge.onwalk.net'),
WorkspaceProvisionController.deriveBridgeDomain(
'acp-bridge.onwalk.net',
),
'acp-bridge.onwalk.net',
);
});
@ -190,11 +213,10 @@ BRIDGE_PORT_443_OPEN=yes
);
});
test('createWorkspace runs playbook flow with fake SSH', () async {
test('createWorkspace runs remote setup script with fake SSH', () async {
final executor = _FakeSshExecutor(
commandResults: [
const SshResult(exitCode: 0, stdout: 'pulled', stderr: ''),
const SshResult(exitCode: 0, stdout: 'wrote', stderr: ''),
const SshResult(exitCode: 0, stdout: 'curl ready', stderr: ''),
const SshResult(
exitCode: 0,
stdout: '''
@ -207,8 +229,8 @@ SERVICE_HERMES_GATEWAY=active
),
],
streamingChunks: [
'TASK [Install desktop packages]\nok: [localhost]\n',
'TASK [Configure caddy TLS]\nchanged: [localhost]\n',
'Installing desktop packages\n',
'Configuring caddy TLS\n',
],
);
final controller = WorkspaceProvisionController(
@ -250,11 +272,21 @@ SERVICE_HERMES_GATEWAY=active
'https://xworkmate-bridge.workspace.example.com',
);
expect(controller.deploymentResult?.bridgeToken, isNotEmpty);
expect(executor.commands.join('\n'), contains('ansible-playbook'));
expect(executor.commands.join('\n'), contains('curl -sfL'));
expect(
executor.commands.join('\n'),
contains('setup-ai-workspace-all-in-one.sh'),
);
expect(
executor.commands.join('\n'),
contains('AI_WORKSPACE_SECURITY_LEVEL='),
);
});
test('precheck does not block when 443 is not open', () async {
final controller = WorkspaceProvisionController(executor: _FakeSshExecutor());
final controller = WorkspaceProvisionController(
executor: _FakeSshExecutor(),
);
addTearDown(controller.dispose);
controller.updateForm(
serverAddress: '203.0.113.10',
@ -286,7 +318,9 @@ SERVICE_HERMES_GATEWAY=active
});
test('precheck blocks when bridge DNS is missing', () async {
final controller = WorkspaceProvisionController(executor: _FakeSshExecutor());
final controller = WorkspaceProvisionController(
executor: _FakeSshExecutor(),
);
addTearDown(controller.dispose);
controller.updateForm(
serverAddress: '203.0.113.10',
@ -314,14 +348,13 @@ SERVICE_HERMES_GATEWAY=active
bridgePort443Open: true,
);
expect(
controller.validatePrecheckBlockingIssue(),
contains('A 记录'),
);
expect(controller.validatePrecheckBlockingIssue(), contains('A 记录'));
});
test('precheck allows debian family systems', () async {
final controller = WorkspaceProvisionController(executor: _FakeSshExecutor());
final controller = WorkspaceProvisionController(
executor: _FakeSshExecutor(),
);
addTearDown(controller.dispose);
controller.updateForm(
serverAddress: '203.0.113.10',
@ -352,18 +385,20 @@ SERVICE_HERMES_GATEWAY=active
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,
);
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('''
controller.importYaml('''
server_address: 167.179.110.129
workspace_domain: onwalk.net
ssh_username: root
@ -382,69 +417,70 @@ extra_configs:
note: "OpenClaw"
''');
expect(controller.serverAddress, '167.179.110.129');
expect(controller.workspaceDomain, 'onwalk.net');
expect(controller.showAdvanced, isTrue);
expect(controller.sshPassword, 'keep-secret');
expect(controller.extraConfigs.first.value, 'deepseek-new');
expect(controller.extraConfigs.last.value, '');
});
expect(controller.serverAddress, '167.179.110.129');
expect(controller.workspaceDomain, 'onwalk.net');
expect(controller.showAdvanced, isTrue);
expect(controller.sshPassword, 'keep-secret');
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: '''
test(
'post deploy verification fails when external probe does not connect',
() async {
final controller = WorkspaceProvisionController(
executor: _FakeSshExecutor(
commandResults: [
const SshResult(exitCode: 0, stdout: 'curl ready', 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,
);
stderr: '',
),
],
streamingChunks: ['Configuring caddy TLS\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();
await controller.createWorkspace();
expect(controller.phase, ProvisionPhase.failed);
expect(controller.errorMessage, contains('probe failed'));
});
expect(controller.phase, ProvisionPhase.failed);
expect(controller.errorMessage, contains('probe failed'));
},
);
});
}

View File

@ -10,7 +10,7 @@ import 'package:xworkmate/runtime/secure_config_store.dart';
import 'package:xworkmate/theme/app_theme.dart';
void main() {
testWidgets('panel renders form controls and keeps upgrade disabled', (
testWidgets('panel renders form controls and enables upgrade', (
tester,
) async {
final appController = _NoopAppController(store: _MemorySecureConfigStore());
@ -44,13 +44,13 @@ void main() {
find.byKey(const Key('workspace-management-upgrade-button')),
)
.onPressed,
isNull,
isNotNull,
);
await tester.tap(find.text('高级选项'));
await tester.pumpAndSettle();
expect(find.text('安装路径'), findsOneWidget);
expect(find.text('执行脚本'), findsOneWidget);
});
testWidgets('panel switches auth method and expands logs', (tester) async {