oto/apps/runner/test/oto_server_connection_smoke_test.dart
toki 4f26df99c3 feat(agent): 부트스트랩 명령 발급을 추가한다
OTO Server에서 runner 설치 명령과 bootstrap script를 제공해야 하므로 서버 endpoint, runner proto, bootstrap config naming, smoke 검증을 함께 정리한다.
2026-06-05 20:53:41 +09:00

385 lines
13 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:test/test.dart';
import 'package:oto/oto/agent/agent_config.dart';
import 'package:oto/oto/agent/edge_registration_client.dart';
const _host = '127.0.0.1';
const _token = 'oto-smoke-token';
const _runnerId = 'oto-smoke-runner';
const _runnerAlias = 'oto-smoke-alias';
void main() {
test(
'OTO Dart runner registers with Go OTO Server, goes online, and disconnects on close',
() async {
final port = await _freePort();
final serverAddr = '$_host:$port';
// Start Go OTO Server in background
final serverProcess = await Process.start(
'go',
['run', './cmd/oto-core'],
workingDirectory: '../../services/core',
environment: {'OTO_CORE_ADDR': serverAddr},
);
final output = StringBuffer();
final stdoutSub = serverProcess.stdout
.transform(systemEncoding.decoder)
.listen(output.write, onError: output.write);
final stderrSub = serverProcess.stderr
.transform(systemEncoding.decoder)
.listen(output.write, onError: output.write);
try {
// Wait for server port to listen
await _waitForPort(_host, port, serverProcess, output);
final agentConfig = AgentConfig(
agent: const AgentIdentityConfig(
id: _runnerId,
alias: _runnerAlias,
enrollmentToken: _token,
),
server: ServerConnectionConfig(url: 'http://$serverAddr'),
runtime: const AgentRuntimeConfig(
installDir: '/tmp/install',
workspaceRoot: '/tmp/workspace',
logDir: '/tmp/log',
),
);
// Open OTO Server session (with short heartbeat interval for testing)
final client = OtoServerRegistrationClient(
commandTypes: ['Shell', 'Git'],
heartbeatInterval: const Duration(milliseconds: 200),
);
final session = await client.openSession(agentConfig);
final result = session.result;
expect(result.accepted, isTrue);
expect(result.runnerId, _runnerId);
expect(result.alias, _runnerAlias);
// Poll the server's GET endpoint until status becomes 'online'
final httpClient = http.Client();
try {
final statusUrl = Uri.parse(
'http://$serverAddr/api/v1/runners/$_runnerId',
);
var isOnline = false;
final deadline = DateTime.now().add(const Duration(seconds: 10));
while (DateTime.now().isBefore(deadline)) {
final response = await httpClient.get(statusUrl);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['status'] == 'online') {
isOnline = true;
break;
}
}
await Future<void>.delayed(const Duration(milliseconds: 200));
}
expect(
isOnline,
isTrue,
reason: 'Runner did not transition to online state in registry',
);
// Close the session to trigger disconnect
await session.close();
// Verify status transitions to disconnected
var isDisconnected = false;
final disconnectDeadline = DateTime.now().add(
const Duration(seconds: 5),
);
while (DateTime.now().isBefore(disconnectDeadline)) {
final response = await httpClient.get(statusUrl);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['status'] == 'disconnected') {
isDisconnected = true;
break;
}
}
await Future<void>.delayed(const Duration(milliseconds: 200));
}
expect(
isDisconnected,
isTrue,
reason:
'Runner did not transition to disconnected state in registry',
);
} finally {
httpClient.close();
}
} finally {
serverProcess.kill(ProcessSignal.sigterm);
await serverProcess.exitCode.timeout(
const Duration(seconds: 5),
onTimeout: () {
serverProcess.kill(ProcessSignal.sigkill);
return serverProcess.exitCode;
},
);
await stdoutSub.cancel();
await stderrSub.cancel();
}
},
timeout: const Timeout(Duration(seconds: 30)),
);
test(
'OTO Server issues runner bootstrap command and serves bootstrap script',
() async {
final port = await _freePort();
final serverAddr = '$_host:$port';
// Start Go OTO Server in background
final serverProcess = await Process.start(
'go',
['run', './cmd/oto-core'],
workingDirectory: '../../services/core',
environment: {
'OTO_CORE_ADDR': serverAddr,
'OTO_RUNNER_RELEASE_BASE_URL': 'https://example.com/releases',
},
);
final output = StringBuffer();
final stdoutSub = serverProcess.stdout
.transform(systemEncoding.decoder)
.listen(output.write, onError: output.write);
final stderrSub = serverProcess.stderr
.transform(systemEncoding.decoder)
.listen(output.write, onError: output.write);
try {
// Wait for server port to listen
await _waitForPort(_host, port, serverProcess, output);
final httpClient = http.Client();
try {
// 1. Verify bootstrap command endpoint
final bootstrapCmdUrl = Uri.parse(
'http://$serverAddr/api/v1/runners/bootstrap-command',
);
final response = await httpClient.post(
bootstrapCmdUrl,
headers: {'content-type': 'application/json'},
body: jsonEncode({
'runner_id': 'test-runner-id',
'enrollment_token': 'test-token-123',
}),
);
expect(response.statusCode, equals(200));
final data = jsonDecode(response.body);
expect(data['bootstrap_command'], isNotNull);
final bootstrapCommand = data['bootstrap_command'] as String;
// Verify command contains server URL, runner ID, enrollment token
expect(bootstrapCommand, contains('http://$serverAddr'));
expect(bootstrapCommand, contains('test-runner-id'));
expect(bootstrapCommand, contains('test-token-123'));
expect(bootstrapCommand, contains('--server-url'));
expect(bootstrapCommand, contains('--agent-id'));
expect(bootstrapCommand, contains('--enrollment-token'));
// 2. Full script execution test using fake curl/tar
final tempDir = await Directory.systemTemp.createTemp(
'oto_smoke_script_run_',
);
final tempHome = Directory('${tempDir.path}/home');
await tempHome.create(recursive: true);
final scriptFile = File('assets/script/shell/oto_agent_bootstrap.sh');
// Create fake OTO executable source
final fakeOtoSource = Directory('${tempDir.path}/fake_oto_src');
await fakeOtoSource.create(recursive: true);
final fakeOtoFile = File('${fakeOtoSource.path}/oto');
await fakeOtoFile.writeAsString(
'#!/bin/sh\necho "fake-oto-started"\n',
);
await Process.run('chmod', ['+x', fakeOtoFile.path]);
// Create fake tar.gz archive
final fakeTarGz = File('${tempDir.path}/oto-linux-x64.tar.gz');
await Process.run('tar', [
'-czf',
fakeTarGz.path,
'-C',
fakeOtoSource.path,
'oto',
]);
// Create fake bin dir and fake curl
final fakeBinDir = Directory('${tempDir.path}/bin');
await fakeBinDir.create(recursive: true);
final fakeCurl = File('${fakeBinDir.path}/curl');
await fakeCurl.writeAsString('''#!/bin/sh
out_file=""
while [ \$# -gt 0 ]; do
if [ "\$1" = "-o" ]; then
out_file="\$2"
shift 2
elif [ "\$1" = "-fsSL" ]; then
shift 1
else
shift 1
fi
done
if [ -n "\$out_file" ]; then
cp "${fakeTarGz.path}" "\$out_file"
else
cat "${scriptFile.absolute.path}"
fi
''');
await Process.run('chmod', ['+x', fakeCurl.path]);
// Append custom paths and options to the command string
final customConfigPath = '${tempDir.path}/smoke-config.yaml';
final customInstallDir = '${tempDir.path}/install_dir';
final customWorkspaceRoot = '${tempDir.path}/workspace';
final customLogDir = '${tempDir.path}/log';
final fullCommand = [
bootstrapCommand,
"--config-path '$customConfigPath'",
"--install-dir '$customInstallDir'",
"--workspace-root '$customWorkspaceRoot'",
"--log-dir '$customLogDir'",
'--no-background',
].join(' ');
// Set up environment with fake curl path and custom HOME
final env = Map<String, String>.from(Platform.environment);
env['PATH'] = '${fakeBinDir.path}:${env['PATH']}';
env['HOME'] = tempHome.path;
// Run the full pipeline via bash
final scriptResult = await Process.run('bash', [
'-c',
fullCommand,
], environment: env);
expect(
scriptResult.exitCode,
equals(0),
reason:
'Script failed: ${scriptResult.stderr}\nStdout: ${scriptResult.stdout}',
);
// Assertions:
// - Generated config exists and has correct values
final configFile = File(customConfigPath);
expect(
await configFile.exists(),
isTrue,
reason: 'Config file not generated',
);
final configContent = await configFile.readAsString();
expect(configContent, contains('server:'));
expect(configContent, contains('url: "http://$serverAddr"'));
expect(configContent, contains('id: "test-runner-id"'));
expect(configContent, contains('enrollment_token: "test-token-123"'));
// - Installed binary exists and has execution permissions
final installedOto = File('$customInstallDir/oto');
expect(
await installedOto.exists(),
isTrue,
reason: 'OTO binary not installed',
);
final stat = await installedOto.stat();
expect(
stat.mode & 0x49,
isNot(0),
reason: 'Installed binary not executable',
);
await tempDir.delete(recursive: true);
// 3. Verify bootstrap script hosting endpoint
final scriptUrl = Uri.parse(
'http://$serverAddr/bootstrap/oto-agent.sh',
);
final scriptResponse = await httpClient.get(scriptUrl);
expect(scriptResponse.statusCode, equals(200));
expect(
scriptResponse.headers['content-type'],
contains('application/x-sh'),
);
expect(scriptResponse.body, isNotEmpty);
expect(scriptResponse.body, contains('--server-url'));
} finally {
httpClient.close();
}
} finally {
serverProcess.kill(ProcessSignal.sigterm);
await serverProcess.exitCode.timeout(
const Duration(seconds: 5),
onTimeout: () {
serverProcess.kill(ProcessSignal.sigkill);
return serverProcess.exitCode;
},
);
await stdoutSub.cancel();
await stderrSub.cancel();
}
},
timeout: const Timeout(Duration(seconds: 30)),
);
}
Future<int> _freePort() async {
final socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
final port = socket.port;
await socket.close();
return port;
}
Future<void> _waitForPort(
String host,
int port,
Process process,
StringBuffer output,
) async {
final deadline = DateTime.now().add(const Duration(seconds: 15));
while (DateTime.now().isBefore(deadline)) {
final exit = await _tryExitCode(process);
if (exit != null) {
fail('OTO Core exited before listening (code $exit):\n$output');
}
try {
final socket = await Socket.connect(
host,
port,
timeout: const Duration(milliseconds: 200),
);
await socket.close();
return;
} catch (_) {
await Future<void>.delayed(const Duration(milliseconds: 100));
}
}
fail('timed out waiting for OTO Core on $host:$port:\n$output');
}
Future<int?> _tryExitCode(Process process) {
return process.exitCode
.timeout(Duration.zero, onTimeout: () => -1)
.then((code) => code == -1 ? null : code);
}