OTO Server에서 runner 설치 명령과 bootstrap script를 제공해야 하므로 서버 endpoint, runner proto, bootstrap config naming, smoke 검증을 함께 정리한다.
385 lines
13 KiB
Dart
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);
|
|
}
|