oto/apps/runner/test/oto_agent_bootstrap_script_test.dart

687 lines
22 KiB
Dart

import 'dart:io';
import 'package:test/test.dart';
import 'package:oto/oto/agent/agent_config.dart';
const bootstrapAssetMatrix = <String, List<String>>{
'linux': ['oto-linux-x64.tar.gz', 'oto-linux-arm64.tar.gz'],
'macos': ['oto-macos-x64.tar.gz', 'oto-macos-arm64.tar.gz'],
'windows': ['oto-windows-x64.zip', 'oto-windows-arm64.zip'],
};
Iterable<String> allSupportedBootstrapAssets() sync* {
for (final assets in bootstrapAssetMatrix.values) {
yield* assets;
}
}
class _BootstrapRunResult {
final int exitCode;
final String stdout;
final String stderr;
/// fake curl에 요청된 asset basename. curl이 호출되지 않으면 null.
final String? requestedAsset;
final String? configContent;
const _BootstrapRunResult({
required this.exitCode,
required this.stdout,
required this.stderr,
this.requestedAsset,
this.configContent,
});
}
/// fake uname/curl/oto로 bootstrap script를 실행해 결과를 반환한다.
///
/// [unameS]: `uname -s` 반환값 (e.g. "Linux", "Darwin", "FreeBSD")
/// [unameM]: `uname -m` 반환값 (e.g. "x86_64", "aarch64", "s390x")
/// [provideTarGz]: true이면 curl이 fake tar.gz를 -o 경로에 제공한다.
/// unsupported 케이스처럼 curl 미호출이 예상되면 false로 설정한다.
Future<_BootstrapRunResult> _runBootstrapWithFakeUname(
File scriptFile, {
required String unameS,
required String unameM,
bool provideTarGz = true,
String serverUrl = 'https://server.example.com',
String agentId = 'test-agent',
String token = 'test-token-secret',
int fakeOtoVersionExitCode = 0,
}) async {
final tmpDir = await Directory.systemTemp.createTemp('oto_matrix_test_');
try {
final tempHome = Directory('${tmpDir.path}/home');
await tempHome.create(recursive: true);
final fakeBinDir = Directory('${tmpDir.path}/bin');
await fakeBinDir.create(recursive: true);
// fake uname: -s → unameS, -m → unameM, 그 외는 시스템 uname 위임
final fakeUname = File('${fakeBinDir.path}/uname');
await fakeUname.writeAsString('''#!/bin/sh
case "\$1" in
-s) echo "$unameS" ;;
-m) echo "$unameM" ;;
*) /usr/bin/uname "\$@" ;;
esac
''');
await Process.run('chmod', ['+x', fakeUname.path]);
// curl 호출 시 요청된 asset basename을 기록할 파일
final assetLog = File('${tmpDir.path}/requested_asset.txt');
// supported 조합에서 curl이 제공할 fake tar.gz (oto binary 포함)
String fakeTarGzPath = '';
if (provideTarGz) {
final fakeOtoSrc = Directory('${tmpDir.path}/fake_oto_src');
await fakeOtoSrc.create(recursive: true);
final fakeOtoBin = File('${fakeOtoSrc.path}/oto');
await fakeOtoBin.writeAsString('''#!/bin/sh
if [ "\$1" = "--version" ]; then
echo "fake-oto-version"
exit $fakeOtoVersionExitCode
fi
while true; do sleep 1; done
''');
await Process.run('chmod', ['+x', fakeOtoBin.path]);
final tarGz = File('${tmpDir.path}/fake.tar.gz');
await Process.run('tar', [
'-czf',
tarGz.path,
'-C',
fakeOtoSrc.path,
'oto',
]);
fakeTarGzPath = tarGz.path;
}
// fake curl: URL basename을 assetLog에 기록하고, provideTarGz이면 -o 경로에 복사
final fakeCurl = File('${fakeBinDir.path}/curl');
final copyLine = provideTarGz
? 'if [ -n "\$out_file" ]; then cp "$fakeTarGzPath" "\$out_file"; fi'
: '';
await fakeCurl.writeAsString('''#!/bin/sh
url=""
out_file=""
while [ \$# -gt 0 ]; do
case "\$1" in
-o) out_file="\$2"; shift 2 ;;
-*) shift ;;
*) url="\$1"; shift ;;
esac
done
asset=\$(basename "\$url")
echo "\$asset" >> "${assetLog.path}"
$copyLine
''');
await Process.run('chmod', ['+x', fakeCurl.path]);
final env = Map<String, String>.from(Platform.environment)
..['PATH'] = '${fakeBinDir.path}:${Platform.environment['PATH'] ?? ''}'
..['HOME'] = tempHome.path;
final result = await Process.run('bash', [
scriptFile.absolute.path,
'--server-url',
serverUrl,
'--agent-id',
agentId,
'--enrollment-token',
token,
'--release-base-url',
'https://example.com/release',
], environment: env);
String? requestedAsset;
if (await assetLog.exists()) {
final raw = (await assetLog.readAsString()).trim();
if (raw.isNotEmpty) requestedAsset = raw.split('\n').first.trim();
}
final configFile = File('${tempHome.path}/.oto/agent/config.yaml');
final configContent = await configFile.exists()
? await configFile.readAsString()
: null;
// background로 실행된 fake oto 프로세스 종료
final pidFile = File('${tempHome.path}/.oto/agent/oto-agent.pid');
if (await pidFile.exists()) {
final pidStr = (await pidFile.readAsString()).trim();
final pid = int.tryParse(pidStr);
if (pid != null) Process.killPid(pid);
}
return _BootstrapRunResult(
exitCode: result.exitCode,
stdout: result.stdout.toString(),
stderr: result.stderr.toString(),
requestedAsset: requestedAsset,
configContent: configContent,
);
} finally {
await tmpDir.delete(recursive: true);
}
}
void main() {
final scriptFile = File('assets/script/shell/oto_agent_bootstrap.sh');
final rootReadmeFile = File('../../README.md');
group('Linux Bootstrap Script Contract Tests', () {
late String content;
setUpAll(() async {
expect(
await scriptFile.exists(),
isTrue,
reason: 'Bootstrap script file must exist',
);
content = await scriptFile.readAsString();
});
test('should contain required flags', () {
expect(content, contains('--server-url'));
expect(content, contains('--agent-id'));
expect(content, contains('--enrollment-token'));
expect(content, contains('--release-base-url'));
});
test('should contain optional/default flags and paths', () {
expect(content, contains('--agent-alias'));
expect(content, contains('--install-dir'));
expect(content, contains('--config-path'));
expect(content, contains('--workspace-root'));
expect(content, contains('--log-dir'));
expect(content, contains('--no-background'));
expect(content, contains('.oto/bin'));
expect(content, contains('.oto/agent/config.yaml'));
});
test(
'should support all Unix asset names via dynamic os/arch combination',
() {
expect(content, contains('"linux"'));
expect(content, contains('"macos"'));
expect(content, contains('"x64"'));
expect(content, contains('"arm64"'));
expect(content, contains(r'oto-${os_name}-${arch_name}.tar.gz'));
},
);
test('should detect OS with uname -s and arch with uname -m', () {
expect(content, contains('uname -s'));
expect(content, contains('uname -m'));
expect(content, contains('Linux'));
expect(content, contains('Darwin'));
});
test('should fail with unsupported OS message', () {
expect(content, contains('Unsupported OS:'));
});
test('should fail with unsupported architecture message', () {
expect(content, contains('Unsupported architecture:'));
});
test(
'should keep README bootstrap asset matrix aligned with test fixture',
() async {
expect(
await rootReadmeFile.exists(),
isTrue,
reason: 'Root README must document bootstrap asset names',
);
final readmeContent = await rootReadmeFile.readAsString();
for (final asset in allSupportedBootstrapAssets()) {
expect(readmeContent, contains(asset));
}
},
);
test('should write expected keys to configuration yaml', () {
expect(content, contains('agent:'));
expect(content, contains('id:'));
expect(content, contains('alias:'));
expect(content, contains('enrollment_token:'));
expect(content, contains('server:'));
expect(content, contains('url:'));
expect(content, contains('runtime:'));
expect(content, contains('install_dir:'));
expect(content, contains('workspace_root:'));
expect(content, contains('log_dir:'));
});
test('should contain agent runtime commands and background behavior', () {
expect(content, contains('agent run'));
expect(content, contains('--config'));
expect(content, contains('nohup'));
expect(content, contains('oto-agent.pid'));
});
test('should contain binary verification step', () {
expect(content, contains('Verifying OTO binary...'));
expect(content, contains('--version'));
expect(content, contains('Error: OTO binary verification failed.'));
});
});
group('Bootstrap Script OS/Arch Matrix Tests', () {
const testCases = [
(unameS: 'Linux', unameM: 'x86_64', expected: 'oto-linux-x64.tar.gz'),
(unameS: 'Linux', unameM: 'aarch64', expected: 'oto-linux-arm64.tar.gz'),
(unameS: 'Darwin', unameM: 'x86_64', expected: 'oto-macos-x64.tar.gz'),
(unameS: 'Darwin', unameM: 'arm64', expected: 'oto-macos-arm64.tar.gz'),
];
for (final tc in testCases) {
test('selects ${tc.expected} for ${tc.unameS}/${tc.unameM}', () async {
final r = await _runBootstrapWithFakeUname(
scriptFile,
unameS: tc.unameS,
unameM: tc.unameM,
provideTarGz: true,
);
expect(r.exitCode, 0, reason: 'stderr: ${r.stderr}');
expect(r.requestedAsset, tc.expected);
});
}
test('fails before download for unsupported OS (FreeBSD)', () async {
const token = 'secret-os-token-789';
final r = await _runBootstrapWithFakeUname(
scriptFile,
unameS: 'FreeBSD',
unameM: 'x86_64',
provideTarGz: false,
token: token,
);
expect(r.exitCode, isNot(0));
expect(r.stderr, contains('Unsupported OS:'));
expect(
r.requestedAsset,
isNull,
reason: 'download must not start for unsupported OS',
);
expect(r.stdout, isNot(contains(token)));
expect(r.stderr, isNot(contains(token)));
});
test(
'fails before download for unsupported arch on Linux (s390x)',
() async {
const token = 'secret-arch-token-456';
final r = await _runBootstrapWithFakeUname(
scriptFile,
unameS: 'Linux',
unameM: 's390x',
provideTarGz: false,
token: token,
);
expect(r.exitCode, isNot(0));
expect(r.stderr, contains('Unsupported architecture:'));
expect(
r.requestedAsset,
isNull,
reason: 'download must not start for unsupported arch',
);
expect(r.stdout, isNot(contains(token)));
expect(r.stderr, isNot(contains(token)));
},
);
test(
'fails before download for unsupported arch on macOS (i386)',
() async {
const token = 'secret-arch-token-123';
final r = await _runBootstrapWithFakeUname(
scriptFile,
unameS: 'Darwin',
unameM: 'i386',
provideTarGz: false,
token: token,
);
expect(r.exitCode, isNot(0));
expect(r.stderr, contains('Unsupported architecture:'));
expect(
r.requestedAsset,
isNull,
reason: 'download must not start for unsupported arch',
);
expect(r.stdout, isNot(contains(token)));
expect(r.stderr, isNot(contains(token)));
},
);
test('fails when OTO binary verification fails', () async {
const token = 'secret-verification-token-999';
final r = await _runBootstrapWithFakeUname(
scriptFile,
unameS: 'Linux',
unameM: 'x86_64',
provideTarGz: true,
token: token,
fakeOtoVersionExitCode: 1,
);
expect(r.exitCode, isNot(0));
expect(r.stderr, contains('Error: OTO binary verification failed.'));
expect(r.stdout, isNot(contains(token)));
expect(r.stderr, isNot(contains(token)));
});
test('escapes YAML double-quoted config values', () async {
const agentId = r'agent-"quoted-\id';
const token = r'token-"quoted-\secret';
final r = await _runBootstrapWithFakeUname(
scriptFile,
unameS: 'Linux',
unameM: 'x86_64',
provideTarGz: true,
agentId: agentId,
token: token,
);
expect(r.exitCode, 0, reason: 'stderr: ${r.stderr}');
expect(r.configContent, isNotNull);
expect(r.configContent!, contains(r'id: "agent-\"quoted-\\id"'));
expect(
r.configContent!,
contains(r'enrollment_token: "token-\"quoted-\\secret"'),
);
final config = AgentConfig.fromYamlContent(r.configContent!);
expect(config.agent.id, agentId);
expect(config.agent.enrollmentToken, token);
});
});
group('Bootstrap Config Fixture', () {
// Mirrors the exact YAML template the bootstrap script writes at runtime
// (oto_agent_bootstrap.sh lines 141-152). If the script or AgentConfig
// parser ever diverge on key names, this test will catch it.
const bootstrapScriptGeneratedYaml = '''
agent:
id: "agent-test-001"
alias: "test-agent-alias"
enrollment_token: "test-enrollment-token"
server:
url: "https://oto-core.example.com"
runtime:
install_dir: "/home/user/.oto/bin"
workspace_root: "/home/user/.oto/workspace"
log_dir: "/home/user/.oto/agent/log"
''';
const bootstrapScriptGeneratedYamlEmptyAlias = '''
agent:
id: "agent-test-002"
alias: ""
enrollment_token: "enrollment-token-xyz"
server:
url: "https://oto-core.example.com"
runtime:
install_dir: "/home/user/.oto/bin"
workspace_root: "/home/user/.oto/workspace"
log_dir: "/home/user/.oto/agent/log"
''';
test('AgentConfig parses bootstrap-generated server section config', () {
final config = AgentConfig.fromYamlContent(bootstrapScriptGeneratedYaml);
expect(config.agent.id, 'agent-test-001');
expect(config.agent.alias, 'test-agent-alias');
expect(config.agent.enrollmentToken, 'test-enrollment-token');
expect(config.server.url, 'https://oto-core.example.com');
expect(config.runtime.installDir, '/home/user/.oto/bin');
expect(config.runtime.workspaceRoot, '/home/user/.oto/workspace');
expect(config.runtime.logDir, '/home/user/.oto/agent/log');
});
test('AgentConfig parses bootstrap-generated config with empty alias', () {
final config = AgentConfig.fromYamlContent(
bootstrapScriptGeneratedYamlEmptyAlias,
);
expect(config.agent.id, 'agent-test-002');
expect(config.agent.alias, '');
expect(config.agent.enrollmentToken, 'enrollment-token-xyz');
expect(config.server.url, 'https://oto-core.example.com');
expect(config.runtime.installDir, '/home/user/.oto/bin');
expect(config.runtime.workspaceRoot, '/home/user/.oto/workspace');
expect(config.runtime.logDir, '/home/user/.oto/agent/log');
});
});
group('Linux Bootstrap Script Functional & Regressions Tests', () {
test('should fail when release base URL is not HTTPS', () async {
final result = await Process.run('bash', [
scriptFile.absolute.path,
'--server-url', 'https://server.example.com',
'--agent-id', 'test-agent',
'--enrollment-token', 'secret-token-123',
'--release-base-url', 'http://example.com/release', // HTTP URL
]);
expect(result.exitCode, isNot(0));
expect(
result.stderr.toString(),
contains('--release-base-url must use https://'),
);
expect(
result.stdout.toString(),
isNot(contains('Downloading OTO agent')),
);
// enrollment token이 출력에 노출되지 않는지 확인
expect(result.stdout.toString(), isNot(contains('secret-token-123')));
expect(result.stderr.toString(), isNot(contains('secret-token-123')));
});
test(
'should write PID to standard agent state path when custom config-path is specified',
() async {
final tempDir = await Directory.systemTemp.createTemp(
'oto_bootstrap_test_',
);
try {
final tempHome = Directory('${tempDir.path}/home');
await tempHome.create(recursive: true);
// 1. 가짜 OTO 실행 파일 생성
final fakeOtoSource = Directory('${tempDir.path}/fake_oto_src');
await fakeOtoSource.create(recursive: true);
final fakeOtoFile = File('${fakeOtoSource.path}/oto');
await fakeOtoFile.writeAsString('''#!/bin/sh
if [ "\$1" = "--version" ]; then
echo "fake-oto-version"
exit 0
fi
while true; do sleep 1; done
''');
await Process.run('chmod', ['+x', fakeOtoFile.path]);
// 2. 가짜 tar.gz 아카이브 생성
final fakeTarGz = File('${tempDir.path}/oto-linux-x64.tar.gz');
await Process.run('tar', [
'-czf',
fakeTarGz.path,
'-C',
fakeOtoSource.path,
'oto',
]);
// 3. 가짜 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"
break
fi
shift
done
if [ -n "\$out_file" ]; then
cp "${fakeTarGz.path}" "\$out_file"
fi
''');
await Process.run('chmod', ['+x', fakeCurl.path]);
// 4. custom config path 지정
final customConfigDir = Directory('${tempDir.path}/custom_config');
await customConfigDir.create(recursive: true);
final customConfigPath = '${customConfigDir.path}/my-config.yaml';
// PATH 환경변수에 fakeBinDir 등록, HOME을 tempHome으로 설정
final env = Map<String, String>.from(Platform.environment);
env['PATH'] = '${fakeBinDir.path}:${env['PATH']}';
env['HOME'] = tempHome.path;
final result = await Process.run('bash', [
scriptFile.absolute.path,
'--server-url',
'https://server.example.com',
'--agent-id',
'test-agent',
'--enrollment-token',
'secret-token-123',
'--release-base-url',
'https://example.com/release',
'--config-path',
customConfigPath,
], environment: env);
expect(
result.exitCode,
0,
reason: 'Script failed with: ${result.stderr}',
);
// 5. 검증
final configFile = File(customConfigPath);
expect(await configFile.exists(), isTrue);
final expectedPidFile = File(
'${tempHome.path}/.oto/agent/oto-agent.pid',
);
expect(
await expectedPidFile.exists(),
isTrue,
reason: 'PID file should exist in standard agent state path',
);
final unexpectedPidFile = File(
'${customConfigDir.path}/oto-agent.pid',
);
expect(
await unexpectedPidFile.exists(),
isFalse,
reason: 'PID file should not exist in custom config path directory',
);
final pidStr = await expectedPidFile.readAsString();
final pid = int.tryParse(pidStr.trim());
if (pid != null) {
Process.killPid(pid);
}
} finally {
await tempDir.delete(recursive: true);
}
},
);
test('should support --edge-url as alias compatibility', () async {
final tempDir = await Directory.systemTemp.createTemp(
'oto_bootstrap_alias_test_',
);
try {
final tempHome = Directory('${tempDir.path}/home');
await tempHome.create(recursive: true);
// 1. 가짜 OTO 실행 파일 생성
final fakeOtoSource = Directory('${tempDir.path}/fake_oto_src');
await fakeOtoSource.create(recursive: true);
final fakeOtoFile = File('${fakeOtoSource.path}/oto');
await fakeOtoFile.writeAsString('''#!/bin/sh
if [ "\$1" = "--version" ]; then
echo "fake-oto-version"
exit 0
fi
while true; do sleep 1; done
''');
await Process.run('chmod', ['+x', fakeOtoFile.path]);
// 2. 가짜 tar.gz 아카이브 생성
final fakeTarGz = File('${tempDir.path}/oto-linux-x64.tar.gz');
await Process.run('tar', [
'-czf',
fakeTarGz.path,
'-C',
fakeOtoSource.path,
'oto',
]);
// 3. 가짜 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"
break
fi
shift
done
if [ -n "\$out_file" ]; then
cp "${fakeTarGz.path}" "\$out_file"
fi
''');
await Process.run('chmod', ['+x', fakeCurl.path]);
final customConfigPath = '${tempDir.path}/alias-config.yaml';
final env = Map<String, String>.from(Platform.environment);
env['PATH'] = '${fakeBinDir.path}:${env['PATH']}';
env['HOME'] = tempHome.path;
final result = await Process.run('bash', [
scriptFile.absolute.path,
'--edge-url',
'https://edge.example.com',
'--agent-id',
'test-agent-alias',
'--enrollment-token',
'secret-token-123',
'--release-base-url',
'https://example.com/release',
'--config-path',
customConfigPath,
], environment: env);
expect(
result.exitCode,
0,
reason: 'Script failed with: \${result.stderr}',
);
final configFile = File(customConfigPath);
expect(await configFile.exists(), isTrue);
final configContent = await configFile.readAsString();
expect(configContent, contains('server:'));
expect(configContent, contains('url: "https://edge.example.com"'));
final expectedPidFile = File(
'${tempHome.path}/.oto/agent/oto-agent.pid',
);
expect(await expectedPidFile.exists(), isTrue);
final pidStr = await expectedPidFile.readAsString();
final pid = int.tryParse(pidStr.trim());
if (pid != null) {
Process.killPid(pid);
}
} finally {
await tempDir.delete(recursive: true);
}
});
});
}