fix(core): 부트스트랩 배포 경로와 핸들러 테스트를 정리한다

코어 서버의 부트스트랩 공급자 경로와 라우팅을 실운영 흐름에 맞춰 정합성 있게 갱신하고,
핸들러 테스트를 함께 갱신해 동작 회귀를 방지한다.
This commit is contained in:
toki 2026-06-10 19:32:24 +09:00
parent 919fcf37ba
commit 6e8854eadd
11 changed files with 561 additions and 38 deletions

View file

@ -0,0 +1,41 @@
# Code review stub: bootstrap script distribution boundary
## Review target
- Plan: `PLAN-local-G05.md`
- Status: pending implementation
## Reviewer focus
- Confirm the bootstrap endpoint no longer depends on source checkout-relative paths.
- Confirm the chosen script source cannot silently drift without a test or explicit sync command.
- Confirm response headers and error behavior remain suitable for shell bootstrap usage.
- Confirm packaging/runtime assumptions are documented in code or tests, not tribal knowledge.
## Required checks
- `cd services/core && go test -count=1 ./...`
## Notes
This item should stay focused on distribution mechanics, not bootstrap script feature changes.
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Pass
- completeness: Fail
- test coverage: Fail
- API contract: Pass
- code quality: Pass
- plan deviation: Fail
- verification trust: Fail
- 발견된 문제:
- Required: `services/core/internal/httpserver/server_test.go:536`의 `TestEmbeddedBootstrapScriptMatchesRunnerAsset`는 이름과 주석상 runner asset과 embedded copy의 drift check를 주장하지만, 실제로는 embedded content가 비어 있지 않은지만 검사한다. 따라서 `apps/runner/assets/script/shell/oto_agent_bootstrap.sh`와 `services/core/internal/httpserver/oto_agent_bootstrap.sh`가 달라져도 테스트가 통과하며, 계획의 "chosen script source cannot silently drift" 요구를 충족하지 못한다. 테스트에서 runner asset 파일을 읽어 embedded provider bytes 또는 core copy와 동일함을 직접 비교해야 한다.
- Required: `CODE_REVIEW-local-G05.md:1`이 구현 체크리스트, 구현 항목별 완료 여부, 검증 결과, 사용자 리뷰 요청을 채우지 않은 pending stub 상태다. 이번 리뷰는 소스와 로컬 재실행으로 판단했지만, 후속 구현은 active review stub에 실제 완료 항목과 `cd services/core && go test -count=1 ./...` 결과를 기록해야 한다.
- 검증:
- `cd services/core && go test -count=1 ./...` 통과
- `cmp -s apps/runner/assets/script/shell/oto_agent_bootstrap.sh services/core/internal/httpserver/oto_agent_bootstrap.sh` 현재 통과, 하지만 Go 테스트에 동등 비교가 포함되어 있지 않음
- 다음 단계:
- WARN/FAIL 후속: drift 비교 테스트와 구현 증거 기록을 보강하는 후속 `PLAN-local-G06.md` / `CODE_REVIEW-local-G06.md`를 작성한다.

View file

@ -0,0 +1,120 @@
<!-- task=control_plane_phase_exit_followups/03_bootstrap_script_distribution plan=1 tag=REVIEW_BOOTSTRAP_SCRIPT_DISTRIBUTION_BOUNDARY -->
# Code Review Reference - REVIEW_BOOTSTRAP_SCRIPT_DISTRIBUTION_BOUNDARY
> **[IMPLEMENTING AGENT - READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-10
task=control_plane_phase_exit_followups/03_bootstrap_script_distribution, plan=1, tag=REVIEW_BOOTSTRAP_SCRIPT_DISTRIBUTION_BOUNDARY
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G06.md` -> `code_review_local_G06_N.log`, `PLAN-local-G06.md` -> `plan_local_G06_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/control_plane_phase_exit_followups/03_bootstrap_script_distribution/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_BOOTSTRAP_SCRIPT_DISTRIBUTION_BOUNDARY-1] 실제 drift 비교 테스트 추가 | [x] |
## 구현 체크리스트
- [x] `services/core/internal/httpserver/server_test.go`의 `TestEmbeddedBootstrapScriptMatchesRunnerAsset`가 `apps/runner/assets/script/shell/oto_agent_bootstrap.sh`와 `embeddedBootstrapProvider{}.bootstrapScript()` 결과를 직접 비교하도록 보강한다.
- [x] `cd services/core && go test -count=1 ./...`를 실행하고 실제 출력 요약을 `CODE_REVIEW-local-G06.md`에 기록한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 구현 내용
- 변경 파일: `services/core/internal/httpserver/server_test.go`
- 핵심 변경:
- `os` import 추가
- `TestEmbeddedBootstrapScriptMatchesRunnerAsset`에 runner asset 파일 읽기(`os.ReadFile`)와 `bytes.Equal` 비교 추가. 기존 non-empty 단순 확인을 실제 byte-identical 비교로 교체. 실패 시 동기화 명령(`cp ...`)을 메시지에 포함.
- 계획 대비 변경 사항: 없음. 계획 요구 사항 그대로 구현. `TestEmbeddedBootstrapScriptNonEmpty`는 drift 비교 통과 시 중복이지만 독립 보증용으로 유지.
## 검증 결과
- 실행한 명령: `cd services/core && go test -count=1 ./...`
- 결과:
```
? github.com/toki/oto/services/core/cmd/oto-core [no test files]
ok github.com/toki/oto/services/core/internal/cicdstate 0.002s
ok github.com/toki/oto/services/core/internal/httpserver 0.095s
ok github.com/toki/oto/services/core/internal/runnerregistry 0.003s
? github.com/toki/oto/services/core/oto [no test files]
```
- 생략/차단: 없음
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G06_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/control_plane_phase_exit_followups/03_bootstrap_script_distribution/`를 `agent-task/archive/YYYY/MM/control_plane_phase_exit_followups/03_bootstrap_script_distribution/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
## 소유권
| 섹션 | 소유자 |
|------|--------|
| 구현 항목별 완료 여부 | 구현 에이전트 |
| 구현 체크리스트 | 구현 에이전트 |
| 구현 내용 | 구현 에이전트 |
| 검증 결과 | 구현 에이전트 |
| 사용자 리뷰 요청 | 구현 에이전트 |
| 코드리뷰 결과 | 리뷰 에이전트 |
| 코드리뷰 전용 체크리스트 | 리뷰 에이전트 |
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- plan deviation: Pass
- verification trust: Pass
- 발견된 문제: 없음
- 리뷰어 정리:
- `services/core/internal/httpserver/server_test.go`의 drift mismatch 실패 메시지에서 비ASCII 대시를 ASCII 하이픈으로 교체했다. 동작/계약 변경은 없다.
- 검증:
- `cd services/core && go test -count=1 ./...` 통과
- 다음 단계:
- PASS: active plan/review를 로그로 아카이브하고 `complete.log`를 작성한 뒤 task 디렉터리를 archive로 이동한다.

View file

@ -0,0 +1,35 @@
# Complete - control_plane_phase_exit_followups/03_bootstrap_script_distribution
## 완료 일시
2026-06-10
## 요약
Bootstrap script distribution boundary follow-up completed after 2 review loops; final verdict PASS.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_local_G05_0.log` | `code_review_local_G05_0.log` | FAIL | Embedded bootstrap script drift test was named but did not compare runner asset bytes. |
| `plan_local_G06_1.log` | `code_review_local_G06_1.log` | PASS | Drift comparison test now reads the runner asset and compares it with embedded provider bytes. |
## 구현/정리 내용
- Bootstrap script serving now uses an injected provider boundary instead of source checkout-relative path discovery.
- Production route uses a Go embedded bootstrap script provider.
- Handler tests cover provider-backed success, provider failure, method rejection, embedded script non-empty, and runner asset drift comparison.
- Review cleanup changed one test failure message dash to ASCII without behavior impact.
## 최종 검증
- `cd services/core && go test -count=1 ./...` - PASS; `cmd/oto-core` and `services/core/oto` had no test files, and `internal/cicdstate`, `internal/httpserver`, `internal/runnerregistry` passed.
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,120 @@
<!-- task=control_plane_phase_exit_followups/03_bootstrap_script_distribution plan=1 tag=REVIEW_BOOTSTRAP_SCRIPT_DISTRIBUTION_BOUNDARY -->
# Follow-up Plan - Bootstrap Script Drift Check
## 이 파일을 읽는 구현 에이전트에게
이 계획은 이전 리뷰의 Required 항목만 처리한다. 구현을 마치면 active `CODE_REVIEW-local-G06.md`의 구현 에이전트 소유 섹션을 실제 변경 내용과 검증 출력으로 채운 뒤 active 파일을 그대로 두고 리뷰를 요청한다.
구현 중 사용자만 결정할 수 있는 범위 변경, 사용자 소유 외부 환경/secret/service 준비, 또는 계획 충돌이 없으면 사용자에게 직접 질문하지 않는다. 그런 blocker가 생기면 `CODE_REVIEW-local-G06.md`의 `사용자 리뷰 요청` 섹션에 근거와 재개 조건을 기록하고 중단한다. `USER_REVIEW.md`, log archive, `complete.log` 작성은 코드리뷰 에이전트 전용이다.
## 배경
첫 리뷰에서 provider 주입과 Go embed 방향은 확인되었지만, `TestEmbeddedBootstrapScriptMatchesRunnerAsset`가 실제 runner asset과 embedded script를 비교하지 않아 drift를 막지 못했다. 현재 두 파일은 동일하지만, 테스트가 동등성을 강제하지 않으면 runner asset과 core embedded copy가 조용히 갈라질 수 있다. 이번 후속은 drift 비교 테스트와 구현 증거 기록만 보강한다.
## 사용자 리뷰 요청 흐름
구현 중 blocker가 생기면 active review stub의 `사용자 리뷰 요청` 섹션을 사용한다. 직접 사용자 프롬프트, 채팅 선택지 제시, `request_user_input` 호출은 금지된다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백은 사용자 리뷰 요청이 아니라 `검증 결과`에 기록한다.
## 분석 결과
### 읽은 파일
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/independent-control-plane/PHASE.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/code-review/SKILL.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-test/local/rules.md`
- `agent-test/local/agent-smoke.md`
- `agent-test/local/core-smoke.md`
- `agent-task/control_plane_phase_exit_followups/03_bootstrap_script_distribution/plan_local_G05_0.log`
- `agent-task/control_plane_phase_exit_followups/03_bootstrap_script_distribution/code_review_local_G05_0.log`
- `services/core/internal/httpserver/bootstrap_provider.go`
- `services/core/internal/httpserver/routes.go`
- `services/core/internal/httpserver/runner_handlers.go`
- `services/core/internal/httpserver/server_test.go`
- `services/core/internal/httpserver/oto_agent_bootstrap.sh`
- `apps/runner/assets/script/shell/oto_agent_bootstrap.sh`
### 테스트 환경 규칙
- test_env: `local`
- `agent-test/local/rules.md`를 읽었다.
- `services/core/internal/httpserver/**`와 bootstrap script 경계 변경이므로 `agent-test/local/agent-smoke.md`를 적용한다.
- 적용 명령: `cd services/core && go test ./...`; 이번 계획에서는 캐시 회피를 위해 `cd services/core && go test -count=1 ./...`를 사용한다.
- `core-smoke`는 이전 계획에 기록되어 있어 확인했지만, 이번 변경의 직접 라우팅은 `agent-smoke`다.
### 테스트 커버리지 공백
- Provider-backed 성공 응답, method not allowed, provider error는 기존 구현 테스트가 다룬다.
- Embedded script non-empty는 다루지만, `TestEmbeddedBootstrapScriptMatchesRunnerAsset`가 runner asset과 embedded bytes를 직접 비교하지 않아 drift를 잡지 못한다.
### 심볼 참조
- 이름 변경/삭제 없음.
- `handleServeBootstrapScript` 호출부는 `routes.go`의 embedded provider 주입과 `server_test.go`의 static provider 주입으로 확인했다.
### 분할 판단
- 단일 plan으로 진행한다.
- 변경 대상은 `server_test.go`의 drift check 테스트 한 곳과 필요한 import 보강에 한정된다.
- API/foundation 변경, call-site rollout, 독립 검증 단위 분리, 상이한 위험 프로필이 없어서 split이 오히려 불필요한 coordination을 만든다.
### 범위 결정 근거
- `oto_agent_bootstrap.sh`의 내용, bootstrap command semantics, route path, provider interface, embed source 구조는 변경하지 않는다.
- drift 비교 테스트가 필요로 하는 최소 import와 assertion만 수정한다.
- 새 Makefile target은 만들지 않는다. Go test 안에서 runner asset과 embedded bytes를 직접 비교하는 방식으로 이전 계획의 drift check 요구를 만족시킨다.
### 빌드 등급
- `local-G06`: 실패 원인이 명확하고 deterministic한 테스트 보강이지만, monorepo 상대 경로와 verification trust를 바로잡아야 하므로 이전 `G05`보다 한 단계 올린다.
## 구현 체크리스트
- [ ] `services/core/internal/httpserver/server_test.go`의 `TestEmbeddedBootstrapScriptMatchesRunnerAsset`가 `apps/runner/assets/script/shell/oto_agent_bootstrap.sh`와 `embeddedBootstrapProvider{}.bootstrapScript()` 결과를 직접 비교하도록 보강한다.
- [ ] `cd services/core && go test -count=1 ./...`를 실행하고 실제 출력 요약을 `CODE_REVIEW-local-G06.md`에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REVIEW_BOOTSTRAP_SCRIPT_DISTRIBUTION_BOUNDARY-1] 실제 drift 비교 테스트 추가
#### 문제
`services/core/internal/httpserver/server_test.go:536`의 `TestEmbeddedBootstrapScriptMatchesRunnerAsset`는 runner asset과 embedded copy의 drift check라고 설명하지만, 실제 assertion은 embedded content non-empty뿐이다. 현재 `cmp -s apps/runner/assets/script/shell/oto_agent_bootstrap.sh services/core/internal/httpserver/oto_agent_bootstrap.sh`는 통과하지만, Go test가 이 동등성을 보장하지 않는다.
#### 해결
`TestEmbeddedBootstrapScriptMatchesRunnerAsset`에서 runner asset 파일을 읽고 embedded provider bytes와 직접 비교한다. `go test` 실행 cwd가 `services/core/internal/httpserver` package 디렉터리임을 기준으로 runner asset 상대 경로는 `../../../../apps/runner/assets/script/shell/oto_agent_bootstrap.sh`를 사용한다.
비교 실패 시에는 core embedded copy를 runner asset에서 다시 동기화하라는 메시지를 남긴다. 별도 non-empty 테스트는 유지해도 되지만, 동등 비교 테스트만으로 non-empty가 충분히 검증되면 중복을 줄여도 된다.
#### 체크리스트
- [ ] 필요한 경우 `os` 또는 표준 라이브러리 import를 추가한다.
- [ ] runner asset read 실패를 `t.Fatalf`로 보고한다.
- [ ] `bytes.Equal` 또는 동등한 byte 비교로 embedded bytes와 runner asset bytes를 비교한다.
- [ ] 실패 메시지에 동기화 대상 파일 경로를 포함한다.
#### 테스트 결정
`cd services/core && go test -count=1 ./...`를 필수로 실행한다. 이 명령이 drift check 테스트를 포함해야 한다.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `services/core/internal/httpserver/server_test.go` | `REVIEW_BOOTSTRAP_SCRIPT_DISTRIBUTION_BOUNDARY-1` |
## 최종 검증
```bash
cd services/core && go test -count=1 ./...
```
예상 결과: 모든 services/core package 테스트가 통과하고, `services/core/internal/httpserver` package에 drift 비교 테스트가 포함된다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -1,21 +0,0 @@
# Code review stub: bootstrap script distribution boundary
## Review target
- Plan: `PLAN-local-G05.md`
- Status: pending implementation
## Reviewer focus
- Confirm the bootstrap endpoint no longer depends on source checkout-relative paths.
- Confirm the chosen script source cannot silently drift without a test or explicit sync command.
- Confirm response headers and error behavior remain suitable for shell bootstrap usage.
- Confirm packaging/runtime assumptions are documented in code or tests, not tribal knowledge.
## Required checks
- `cd services/core && go test -count=1 ./...`
## Notes
This item should stay focused on distribution mechanics, not bootstrap script feature changes.

View file

@ -0,0 +1,24 @@
package httpserver
import (
_ "embed"
"fmt"
)
//go:embed oto_agent_bootstrap.sh
var embeddedBootstrapScript []byte
// bootstrapScriptProvider returns the content of the OTO agent bootstrap script.
type bootstrapScriptProvider interface {
bootstrapScript() ([]byte, error)
}
// embeddedBootstrapProvider serves the script compiled into the binary via go:embed.
type embeddedBootstrapProvider struct{}
func (embeddedBootstrapProvider) bootstrapScript() ([]byte, error) {
if len(embeddedBootstrapScript) == 0 {
return nil, fmt.Errorf("embedded bootstrap script is empty")
}
return embeddedBootstrapScript, nil
}

View file

@ -0,0 +1,153 @@
#!/usr/bin/env bash
set -euo pipefail
server_url=""
agent_id=""
enrollment_token=""
release_base_url=""
agent_alias=""
install_dir="${HOME}/.oto/bin"
config_path="${HOME}/.oto/agent/config.yaml"
workspace_root="${HOME}/.oto/workspace"
log_dir="${HOME}/.oto/agent/log"
background="true"
usage() {
echo "Usage: $0 --server-url <url> --agent-id <id> --enrollment-token <token> --release-base-url <url> [options]"
echo "Options:"
echo " --agent-alias <alias> Alias for the agent"
echo " --install-dir <path> Directory to install OTO agent (default: \$HOME/.oto/bin)"
echo " --config-path <path> Path to config file (default: \$HOME/.oto/agent/config.yaml)"
echo " --workspace-root <path> Workspace root directory (default: \$HOME/.oto/workspace)"
echo " --log-dir <path> Log directory (default: \$HOME/.oto/agent/log)"
echo " --no-background Run in the foreground"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--server-url|--edge-url)
server_url="$2"
shift 2
;;
--agent-id)
agent_id="$2"
shift 2
;;
--enrollment-token)
enrollment_token="$2"
shift 2
;;
--release-base-url)
release_base_url="$2"
shift 2
;;
--agent-alias)
agent_alias="$2"
shift 2
;;
--install-dir)
install_dir="$2"
shift 2
;;
--config-path)
config_path="$2"
shift 2
;;
--workspace-root)
workspace_root="$2"
shift 2
;;
--log-dir)
log_dir="$2"
shift 2
;;
--no-background)
background="false"
shift 1
;;
*)
echo "Unknown option: $1" >&2
usage
exit 1
;;
esac
done
if [[ -z "$server_url" || -z "$agent_id" || -z "$enrollment_token" || -z "$release_base_url" ]]; then
echo "Error: Missing required arguments." >&2
usage
exit 1
fi
case "$release_base_url" in
https://*) ;;
*)
echo "Error: --release-base-url must use https://" >&2
exit 1
;;
esac
arch=$(uname -m)
case "$arch" in
x86_64|amd64)
asset_name="oto-linux-x64.tar.gz"
;;
aarch64|arm64)
asset_name="oto-linux-arm64.tar.gz"
;;
*)
echo "Error: Unsupported architecture: $arch" >&2
exit 1
;;
esac
download_url="${release_base_url}/${asset_name}"
tmp_dir=$(mktemp -d)
trap 'rm -rf "$tmp_dir"' EXIT
echo "Downloading OTO agent..."
curl -fsSL "$download_url" -o "$tmp_dir/$asset_name"
echo "Extracting OTO agent..."
tar -xzf "$tmp_dir/$asset_name" -C "$tmp_dir"
mkdir -p "$install_dir"
cp "$tmp_dir/oto" "$install_dir/oto"
chmod +x "$install_dir/oto"
config_dir=$(dirname "$config_path")
agent_state_dir="${HOME}/.oto/agent"
pid_path="${agent_state_dir}/oto-agent.pid"
mkdir -p "$config_dir"
mkdir -p "$agent_state_dir"
mkdir -p "$workspace_root"
mkdir -p "$log_dir"
cat <<EOF > "$config_path"
agent:
id: "$agent_id"
alias: "$agent_alias"
enrollment_token: "$enrollment_token"
server:
url: "$server_url"
runtime:
install_dir: "$install_dir"
workspace_root: "$workspace_root"
log_dir: "$log_dir"
EOF
chmod 600 "$config_path"
if [ "$background" = "true" ]; then
echo "Starting OTO agent in the background..."
nohup "$install_dir/oto" agent run --config "$config_path" >> "$log_dir/oto-agent.log" 2>&1 &
pid=$!
echo "$pid" > "$pid_path"
echo "OTO agent started in the background. PID: $pid"
echo "Log path: $log_dir/oto-agent.log"
else
echo "OTO agent is configured to run in the foreground."
echo "To run it manually, use the following command:"
echo " $install_dir/oto agent run --config $config_path"
fi

View file

@ -19,7 +19,7 @@ func registerRoutes(mux *http.ServeMux, registry *runnerregistry.Registry, store
mux.HandleFunc("/api/v1/runners/{id}/heartbeat", handleRunnerHeartbeat(registry))
mux.HandleFunc("/api/v1/runners/{id}/disconnect", handleRunnerDisconnect(registry))
mux.HandleFunc("/api/v1/runners/{id}", handleGetRunner(registry))
mux.HandleFunc("/bootstrap/oto-agent.sh", handleServeBootstrapScript())
mux.HandleFunc("/bootstrap/oto-agent.sh", handleServeBootstrapScript(embeddedBootstrapProvider{}))
mux.HandleFunc("/api/v1/", handleRouter(store, registry))
}

View file

@ -252,27 +252,15 @@ func handleRunnerBootstrapCommand(registry *runnerregistry.Registry) http.Handle
}
}
func handleServeBootstrapScript() http.HandlerFunc {
func handleServeBootstrapScript(provider bootstrapScriptProvider) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
paths := []string{
"../../apps/runner/assets/script/shell/oto_agent_bootstrap.sh",
"../../../../apps/runner/assets/script/shell/oto_agent_bootstrap.sh",
"apps/runner/assets/script/shell/oto_agent_bootstrap.sh",
}
var content []byte
var err error
for _, p := range paths {
content, err = os.ReadFile(p)
if err == nil {
break
}
}
content, err := provider.bootstrapScript()
if err != nil {
http.Error(w, "Bootstrap script not found: "+err.Error(), http.StatusNotFound)
http.Error(w, "Bootstrap script not available: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/x-sh")

View file

@ -4,9 +4,11 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
@ -486,11 +488,19 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) {
}
}
// staticProvider is a test-only bootstrapScriptProvider with fixed content.
type staticProvider struct {
content []byte
err error
}
func (p staticProvider) bootstrapScript() ([]byte, error) { return p.content, p.err }
func TestHandleServeBootstrapScript(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/bootstrap/oto-agent.sh", nil)
rr := httptest.NewRecorder()
handleServeBootstrapScript()(rr, req)
handleServeBootstrapScript(staticProvider{content: []byte("#!/usr/bin/env bash\n")})(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %v, want %v; body = %s", rr.Code, http.StatusOK, rr.Body.String())
}
@ -504,6 +514,59 @@ func TestHandleServeBootstrapScript(t *testing.T) {
}
}
func TestHandleServeBootstrapScript_MethodNotAllowed(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/bootstrap/oto-agent.sh", nil)
rr := httptest.NewRecorder()
handleServeBootstrapScript(staticProvider{content: []byte("#!/usr/bin/env bash\n")})(rr, req)
if rr.Code != http.StatusMethodNotAllowed {
t.Fatalf("status = %v, want %v", rr.Code, http.StatusMethodNotAllowed)
}
}
func TestHandleServeBootstrapScript_ProviderError(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/bootstrap/oto-agent.sh", nil)
rr := httptest.NewRecorder()
handleServeBootstrapScript(staticProvider{err: fmt.Errorf("script unavailable")})(rr, req)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("status = %v, want %v", rr.Code, http.StatusInternalServerError)
}
}
func TestEmbeddedBootstrapScriptMatchesRunnerAsset(t *testing.T) {
// Drift check: embedded copy must be byte-identical to the runner asset.
// On failure: cp apps/runner/assets/script/shell/oto_agent_bootstrap.sh \
// services/core/internal/httpserver/oto_agent_bootstrap.sh
embedded, err := embeddedBootstrapProvider{}.bootstrapScript()
if err != nil {
t.Fatalf("embeddedBootstrapProvider.bootstrapScript() failed: %v", err)
}
const runnerAsset = "../../../../apps/runner/assets/script/shell/oto_agent_bootstrap.sh"
asset, err := os.ReadFile(runnerAsset)
if err != nil {
t.Fatalf("failed to read runner asset %s: %v", runnerAsset, err)
}
if !bytes.Equal(embedded, asset) {
t.Fatalf("embedded bootstrap script differs from runner asset %s - "+
"run: cp %s services/core/internal/httpserver/oto_agent_bootstrap.sh",
runnerAsset, runnerAsset)
}
}
func TestEmbeddedBootstrapScriptNonEmpty(t *testing.T) {
provider := embeddedBootstrapProvider{}
content, err := provider.bootstrapScript()
if err != nil {
t.Fatalf("bootstrapScript() error: %v", err)
}
if len(content) == 0 {
t.Fatal("embedded bootstrap script content is empty")
}
}
func TestHandleCreateJob(t *testing.T) {
store := cicdstate.NewStore()