update roadmap phases and bootstrap scripts across platforms

This commit is contained in:
toki 2026-06-16 13:07:54 +09:00
parent f8863710b1
commit 9628ec36c4
9 changed files with 153 additions and 64 deletions

View file

@ -12,7 +12,7 @@ OTO Core가 발급하는 bootstrap 흐름을 Linux 단일 shell script 기준에
## 상태
[진행중]
[완료]
## 승격 조건
@ -38,22 +38,28 @@ OTO Core가 발급하는 bootstrap 흐름을 Linux 단일 shell script 기준에
OS별 bootstrap command와 release asset 선택 기준을 OTO Core와 runner asset이 같은 의미로 해석하도록 만든다.
- [x] [platform-matrix] Linux, macOS, Windows의 우선 지원 OS/arch와 release asset naming 규칙을 정리한다. 검증: 문서와 테스트 fixture가 동일한 asset 이름을 사용한다.
- [ ] [core-command] OTO Core bootstrap command 요청/응답이 대상 OS 또는 script 종류를 표현하고 올바른 bootstrap URL/command를 반환한다. 검증: `cd services/core && go test ./...`가 OS별 command 케이스를 포함해 통과한다.
- [ ] [unix-bootstrap] Linux/macOS shell bootstrap이 OS/arch를 감지해 올바른 release asset을 내려받고 config/runtime 경로를 생성한다. 검증: `cd apps/runner && dart test test/oto_agent_bootstrap_script_test.dart` 또는 대응 smoke가 통과한다.
- [ ] [windows-bootstrap] Windows PowerShell bootstrap이 release asset 다운로드, 압축 해제, config 생성, agent 실행 안내 또는 시작 흐름을 제공한다. 검증: script parsing/fixture 테스트가 Windows 경로와 quoting을 검증한다.
- [ ] [oto-binary] bootstrap으로 설치된 `oto` binary의 실행 경로와 버전 확인 방법을 OS별로 고정한다. 검증: fixture 또는 smoke가 설치 후 `oto --version` 또는 동등한 binary 확인 절차를 검증한다.
- [ ] [node-connect] bootstrap으로 생성된 agent 설정이 `oto agent run`에서 OTO Core runner registry에 runner/node로 등록 또는 연결되는 최소 흐름을 보장한다. 검증: `cd services/core && go test ./...`와 runner agent 연결 smoke 또는 fixture 테스트가 통과한다.
- [x] [core-command] OTO Core bootstrap command 요청/응답이 대상 OS 또는 script 종류를 표현하고 올바른 bootstrap URL/command를 반환한다. 검증: `cd services/core && go test ./...`가 OS별 command 케이스를 포함해 통과한다.
- [x] [unix-bootstrap] Linux/macOS shell bootstrap이 OS/arch를 감지해 올바른 release asset을 내려받고 config/runtime 경로를 생성한다. 검증: `cd apps/runner && dart test test/oto_agent_bootstrap_script_test.dart` 또는 대응 smoke가 통과한다.
- [x] [windows-bootstrap] Windows PowerShell bootstrap이 release asset 다운로드, 압축 해제, config 생성, agent 실행 안내 또는 시작 흐름을 제공한다. 검증: script parsing/fixture 테스트가 Windows 경로와 quoting을 검증한다.
- [x] [oto-binary] bootstrap으로 설치된 `oto` binary의 실행 경로와 버전 확인 방법을 OS별로 고정한다. 검증: fixture 또는 smoke가 설치 후 `oto --version` 또는 동등한 binary 확인 절차를 검증한다.
- [x] [node-connect] bootstrap으로 생성된 agent 설정이 `oto agent run`에서 OTO Core runner registry에 runner/node로 등록 또는 연결되는 최소 흐름을 보장한다. 검증: `cd services/core && go test ./...`와 runner agent 연결 smoke 또는 fixture 테스트가 통과한다.
- [x] [docs-smoke] README와 `agent-test/local/agent-smoke.md`가 OS별 bootstrap command, secret 기록 금지, release URL 검증 기준을 최신화한다.
## 완료 리뷰
- 상태: 없음
- 요청일: 없음
- 완료 근거: 없음
- 상태: 승인됨
- 요청일: 2026-06-16
- 완료 근거:
- 원격 runner `/Users/toki/agent-work/oto`에서 `cd services/core && go test -count=1 ./...` 통과로 OS별 bootstrap command, embedded bootstrap asset serving, runner registry/httpserver 회귀를 확인했다.
- 원격 runner `/Users/toki/agent-work/oto`에서 `cd apps/runner && dart test test/oto_agent_migration_plan_test.dart test/oto_agent_bootstrap_script_test.dart test/oto_agent_cli_test.dart test/oto_agent_config_test.dart test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart` 통과로 Unix bootstrap, binary verification, generated agent config, OTO Server runner 연결 smoke를 확인했다.
- 원격 runner `/Users/toki/agent-work/oto`에서 `cd apps/runner && dart pub get && dart analyze` 통과로 runner 정적 분석 회귀를 확인했다.
- 코드 레벨 종료 검토 중 bootstrap config YAML 값이 따옴표/백슬래시를 포함할 때 깨질 수 있는 작은 이슈를 확인했고, shell bootstrap은 double-quoted YAML escaping, PowerShell bootstrap은 single-quoted YAML escaping으로 보강했다.
- 로컬 checkout에서 `git diff --check`, `cd services/core && go test -count=1 ./...`, `cd apps/runner && dart analyze`, `cd apps/runner && dart test test/oto_agent_migration_plan_test.dart test/oto_agent_bootstrap_script_test.dart test/oto_agent_cli_test.dart test/oto_agent_config_test.dart test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart test/oto_agent_bootstrap_ps1_test.dart`가 통과했다.
- 원격 runner 임시 tree `/tmp/oto-verify-bootstrap.j89dYx`에서 로컬 HEAD와 이번 변경 파일을 기준으로 `cd services/core && go test -count=1 ./...`, `cd apps/runner && dart pub get && dart analyze && dart test test/oto_agent_migration_plan_test.dart test/oto_agent_bootstrap_script_test.dart test/oto_agent_cli_test.dart test/oto_agent_config_test.dart test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart test/oto_agent_bootstrap_ps1_test.dart`가 통과했다.
- 리뷰 필요:
- [ ] 사용자가 완료 결과를 확인했다
- [ ] archive 이동을 승인했다
- 리뷰 코멘트: 없음
- [x] 사용자가 완료 결과를 확인했다
- [x] archive 이동을 승인했다
- 리뷰 코멘트: 코드 레벨 종료 검토에서 확인된 작은 YAML escaping 이슈를 처리했고, 원격 runner 기준 재검증이 통과해 `bootstrap-platforms` Epic과 Milestone을 완료 처리한다.
## 범위 제외

View file

@ -15,10 +15,10 @@
완료, 검토중, 진행중, 계획, 스케치 순서로 두어 아래로 갈수록 미래 작업에 가까워지게 정렬한다.
스케치 Milestone은 아직 구현 가능한 계획이 아니므로 계획 Milestone보다 아래에 둔다.
- [진행중] Runner bootstrap, OTO binary, 노드 연결
- 경로: `agent-roadmap/phase/control-plane-product-surface/milestones/cross-os-runner-bootstrap.md`
- [완료] Runner bootstrap, OTO binary, 노드 연결
- 경로: `agent-roadmap/archive/phase/control-plane-product-surface/milestones/cross-os-runner-bootstrap.md`
- 요약: OTO Core bootstrap command와 bootstrap asset이 Linux, macOS, Windows에서 `oto` binary를 내려받고 runner/node로 Core에 연결할 수 있도록 확장한다.
- [계획] Flutter 콘솔 스켈레톤
- [진행중] Flutter 콘솔 스켈레톤
- 경로: `agent-roadmap/phase/control-plane-product-surface/milestones/flutter-console-skeleton.md`
- 요약: bootstrap과 runner/node 연결 기준을 바탕으로 `apps/client``packages/flutter/oto_console`에 Control Plane 주요 화면, 상태 표시, 빈 상태를 갖춘 UI 스켈레톤을 만든다.

View file

@ -12,7 +12,7 @@
## 상태
[계획]
[진행중]
## 승격 조건

View file

@ -78,17 +78,25 @@ try {
$null = New-Item -ItemType Directory -Path $workspaceRoot -Force
$null = New-Item -ItemType Directory -Path $logDir -Force
$agentIdYaml = $agentId.Replace("'", "''")
$agentAliasYaml = $agentAlias.Replace("'", "''")
$enrollmentTokenYaml = $enrollmentToken.Replace("'", "''")
$serverUrlYaml = $serverUrl.Replace("'", "''")
$installDirYaml = ($installDir -replace '\\', '/').Replace("'", "''")
$workspaceRootYaml = ($workspaceRoot -replace '\\', '/').Replace("'", "''")
$logDirYaml = ($logDir -replace '\\', '/').Replace("'", "''")
$configContent = @"
agent:
id: "$agentId"
alias: "$agentAlias"
enrollment_token: "$enrollmentToken"
id: '$agentIdYaml'
alias: '$agentAliasYaml'
enrollment_token: '$enrollmentTokenYaml'
server:
url: "$serverUrl"
url: '$serverUrlYaml'
runtime:
install_dir: "$($installDir -replace '\\', '/')"
workspace_root: "$($workspaceRoot -replace '\\', '/')"
log_dir: "$($logDir -replace '\\', '/')"
install_dir: '$installDirYaml'
workspace_root: '$workspaceRootYaml'
log_dir: '$logDirYaml'
"@
[System.IO.File]::WriteAllText($configPath, $configContent)

View file

@ -23,6 +23,16 @@ usage() {
echo " --no-background Run in the foreground"
}
yaml_escape() {
local value="$1"
value="${value//\\/\\\\}"
value="${value//\"/\\\"}"
value="${value//$'\t'/\\t}"
value="${value//$'\r'/\\r}"
value="${value//$'\n'/\\n}"
printf '%s' "$value"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--server-url|--edge-url)
@ -140,15 +150,15 @@ mkdir -p "$log_dir"
cat <<EOF > "$config_path"
agent:
id: "$agent_id"
alias: "$agent_alias"
enrollment_token: "$enrollment_token"
id: "$(yaml_escape "$agent_id")"
alias: "$(yaml_escape "$agent_alias")"
enrollment_token: "$(yaml_escape "$enrollment_token")"
server:
url: "$server_url"
url: "$(yaml_escape "$server_url")"
runtime:
install_dir: "$install_dir"
workspace_root: "$workspace_root"
log_dir: "$log_dir"
install_dir: "$(yaml_escape "$install_dir")"
workspace_root: "$(yaml_escape "$workspace_root")"
log_dir: "$(yaml_escape "$log_dir")"
EOF
chmod 600 "$config_path"

View file

@ -2,9 +2,7 @@ import 'dart:io';
import 'package:test/test.dart';
void main() {
final scriptFile = File(
'assets/script/powershell/oto_agent_bootstrap.ps1',
);
final scriptFile = File('assets/script/powershell/oto_agent_bootstrap.ps1');
group('Windows PowerShell Bootstrap Script Contract Tests', () {
late String content;
@ -34,13 +32,16 @@ void main() {
expect(content, contains('--no-background'));
});
test('should support Windows x64 and arm64 zip assets via dynamic arch combination', () {
expect(content, contains("'x64'"));
expect(content, contains("'arm64'"));
expect(content, contains('oto-windows-'));
expect(content, contains('.zip'));
expect(content, contains(r'"oto-windows-$archName.zip"'));
});
test(
'should support Windows x64 and arm64 zip assets via dynamic arch combination',
() {
expect(content, contains("'x64'"));
expect(content, contains("'arm64'"));
expect(content, contains('oto-windows-'));
expect(content, contains('.zip'));
expect(content, contains(r'"oto-windows-$archName.zip"'));
},
);
test('should detect architecture via PROCESSOR_ARCHITECTURE', () {
expect(content, contains('PROCESSOR_ARCHITECTURE'));
@ -72,18 +73,28 @@ void main() {
expect(content, contains('log_dir:'));
});
test('should escape YAML single-quoted config values', () {
expect(content, contains(r'''.Replace("'", "''")'''));
expect(content, contains(r"id: '$agentIdYaml'"));
expect(content, contains(r"enrollment_token: '$enrollmentTokenYaml'"));
expect(content, contains(r"serverUrlYaml = $serverUrl.Replace("));
});
test('should contain agent runtime command and background behavior', () {
expect(content, contains('agent run'));
expect(content, contains('--config'));
expect(content, contains('oto-agent.pid'));
});
test('should use separate stdout and stderr log files for background process', () {
expect(content, contains('RedirectStandardOutput'));
expect(content, contains('RedirectStandardError'));
expect(content, contains('oto-agent.out.log'));
expect(content, contains('oto-agent.err.log'));
});
test(
'should use separate stdout and stderr log files for background process',
() {
expect(content, contains('RedirectStandardOutput'));
expect(content, contains('RedirectStandardError'));
expect(content, contains('oto-agent.out.log'));
expect(content, contains('oto-agent.err.log'));
},
);
test('should contain binary verification before background execution', () {
expect(content, contains('Verifying OTO binary...'));
@ -104,7 +115,8 @@ void main() {
for (final line in lines) {
final trimmed = line.trim();
// Skip the config write block that intentionally contains the token key
if (trimmed.contains('enrollment_token:') && trimmed.contains(r'"$enrollmentToken"')) {
if (trimmed.contains('enrollment_token:') &&
trimmed.contains(r'"$enrollmentToken"')) {
continue;
}
// No Write-Host / Write-Error / Write-Output line should directly print
@ -115,7 +127,8 @@ void main() {
expect(
trimmed,
isNot(contains(r'$enrollmentToken')),
reason: 'Output statement must not expose enrollment token: $trimmed',
reason:
'Output statement must not expose enrollment token: $trimmed',
);
}
}

View file

@ -21,12 +21,14 @@ class _BootstrapRunResult {
/// 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,
});
}
@ -41,6 +43,8 @@ Future<_BootstrapRunResult> _runBootstrapWithFakeUname(
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 {
@ -119,9 +123,9 @@ $copyLine
final result = await Process.run('bash', [
scriptFile.absolute.path,
'--server-url',
'https://server.example.com',
serverUrl,
'--agent-id',
'test-agent',
agentId,
'--enrollment-token',
token,
'--release-base-url',
@ -133,6 +137,10 @@ $copyLine
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');
@ -147,6 +155,7 @@ $copyLine
stdout: result.stdout.toString(),
stderr: result.stderr.toString(),
requestedAsset: requestedAsset,
configContent: configContent,
);
} finally {
await tmpDir.delete(recursive: true);
@ -358,6 +367,31 @@ void main() {
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', () {

View file

@ -78,17 +78,25 @@ try {
$null = New-Item -ItemType Directory -Path $workspaceRoot -Force
$null = New-Item -ItemType Directory -Path $logDir -Force
$agentIdYaml = $agentId.Replace("'", "''")
$agentAliasYaml = $agentAlias.Replace("'", "''")
$enrollmentTokenYaml = $enrollmentToken.Replace("'", "''")
$serverUrlYaml = $serverUrl.Replace("'", "''")
$installDirYaml = ($installDir -replace '\\', '/').Replace("'", "''")
$workspaceRootYaml = ($workspaceRoot -replace '\\', '/').Replace("'", "''")
$logDirYaml = ($logDir -replace '\\', '/').Replace("'", "''")
$configContent = @"
agent:
id: "$agentId"
alias: "$agentAlias"
enrollment_token: "$enrollmentToken"
id: '$agentIdYaml'
alias: '$agentAliasYaml'
enrollment_token: '$enrollmentTokenYaml'
server:
url: "$serverUrl"
url: '$serverUrlYaml'
runtime:
install_dir: "$($installDir -replace '\\', '/')"
workspace_root: "$($workspaceRoot -replace '\\', '/')"
log_dir: "$($logDir -replace '\\', '/')"
install_dir: '$installDirYaml'
workspace_root: '$workspaceRootYaml'
log_dir: '$logDirYaml'
"@
[System.IO.File]::WriteAllText($configPath, $configContent)

View file

@ -23,6 +23,16 @@ usage() {
echo " --no-background Run in the foreground"
}
yaml_escape() {
local value="$1"
value="${value//\\/\\\\}"
value="${value//\"/\\\"}"
value="${value//$'\t'/\\t}"
value="${value//$'\r'/\\r}"
value="${value//$'\n'/\\n}"
printf '%s' "$value"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--server-url|--edge-url)
@ -140,15 +150,15 @@ mkdir -p "$log_dir"
cat <<EOF > "$config_path"
agent:
id: "$agent_id"
alias: "$agent_alias"
enrollment_token: "$enrollment_token"
id: "$(yaml_escape "$agent_id")"
alias: "$(yaml_escape "$agent_alias")"
enrollment_token: "$(yaml_escape "$enrollment_token")"
server:
url: "$server_url"
url: "$(yaml_escape "$server_url")"
runtime:
install_dir: "$install_dir"
workspace_root: "$workspace_root"
log_dir: "$log_dir"
install_dir: "$(yaml_escape "$install_dir")"
workspace_root: "$(yaml_escape "$workspace_root")"
log_dir: "$(yaml_escape "$log_dir")"
EOF
chmod 600 "$config_path"