diff --git a/agent-roadmap/phase/control-plane-product-surface/milestones/cross-os-runner-bootstrap.md b/agent-roadmap/archive/phase/control-plane-product-surface/milestones/cross-os-runner-bootstrap.md similarity index 62% rename from agent-roadmap/phase/control-plane-product-surface/milestones/cross-os-runner-bootstrap.md rename to agent-roadmap/archive/phase/control-plane-product-surface/milestones/cross-os-runner-bootstrap.md index 9d0dc5c..34dba42 100644 --- a/agent-roadmap/phase/control-plane-product-surface/milestones/cross-os-runner-bootstrap.md +++ b/agent-roadmap/archive/phase/control-plane-product-surface/milestones/cross-os-runner-bootstrap.md @@ -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을 완료 처리한다. ## 범위 제외 diff --git a/agent-roadmap/phase/control-plane-product-surface/PHASE.md b/agent-roadmap/phase/control-plane-product-surface/PHASE.md index 12fa9c7..cc18d5f 100644 --- a/agent-roadmap/phase/control-plane-product-surface/PHASE.md +++ b/agent-roadmap/phase/control-plane-product-surface/PHASE.md @@ -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 스켈레톤을 만든다. diff --git a/agent-roadmap/phase/control-plane-product-surface/milestones/flutter-console-skeleton.md b/agent-roadmap/phase/control-plane-product-surface/milestones/flutter-console-skeleton.md index d930ed4..4f1650c 100644 --- a/agent-roadmap/phase/control-plane-product-surface/milestones/flutter-console-skeleton.md +++ b/agent-roadmap/phase/control-plane-product-surface/milestones/flutter-console-skeleton.md @@ -12,7 +12,7 @@ ## 상태 -[계획] +[진행중] ## 승격 조건 diff --git a/apps/runner/assets/script/powershell/oto_agent_bootstrap.ps1 b/apps/runner/assets/script/powershell/oto_agent_bootstrap.ps1 index 7e9735a..939ad3b 100644 --- a/apps/runner/assets/script/powershell/oto_agent_bootstrap.ps1 +++ b/apps/runner/assets/script/powershell/oto_agent_bootstrap.ps1 @@ -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) diff --git a/apps/runner/assets/script/shell/oto_agent_bootstrap.sh b/apps/runner/assets/script/shell/oto_agent_bootstrap.sh index ff8681b..1d8468f 100644 --- a/apps/runner/assets/script/shell/oto_agent_bootstrap.sh +++ b/apps/runner/assets/script/shell/oto_agent_bootstrap.sh @@ -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 < "$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" diff --git a/apps/runner/test/oto_agent_bootstrap_ps1_test.dart b/apps/runner/test/oto_agent_bootstrap_ps1_test.dart index 2654c8e..21f00be 100644 --- a/apps/runner/test/oto_agent_bootstrap_ps1_test.dart +++ b/apps/runner/test/oto_agent_bootstrap_ps1_test.dart @@ -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', ); } } diff --git a/apps/runner/test/oto_agent_bootstrap_script_test.dart b/apps/runner/test/oto_agent_bootstrap_script_test.dart index b2f9828..817a9d0 100644 --- a/apps/runner/test/oto_agent_bootstrap_script_test.dart +++ b/apps/runner/test/oto_agent_bootstrap_script_test.dart @@ -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', () { diff --git a/services/core/internal/httpserver/oto_agent_bootstrap.ps1 b/services/core/internal/httpserver/oto_agent_bootstrap.ps1 index 7e9735a..939ad3b 100644 --- a/services/core/internal/httpserver/oto_agent_bootstrap.ps1 +++ b/services/core/internal/httpserver/oto_agent_bootstrap.ps1 @@ -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) diff --git a/services/core/internal/httpserver/oto_agent_bootstrap.sh b/services/core/internal/httpserver/oto_agent_bootstrap.sh index ff8681b..1d8468f 100644 --- a/services/core/internal/httpserver/oto_agent_bootstrap.sh +++ b/services/core/internal/httpserver/oto_agent_bootstrap.sh @@ -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 < "$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"