refactor(core): httpserver 파일을 핸들러 단위로 분리한다

단일 server.go에 집중되어 있던 HTTP 라우팅 로직을 실행/작업/런너 핸들러 파일로 분리해 책임 경계를 더 선명하게 하며, milestone 기록 파일의 보관 위치도 정리한다.
This commit is contained in:
toki 2026-06-08 20:58:27 +09:00
parent b3100adef9
commit b318da195f
9 changed files with 1155 additions and 1055 deletions

View file

@ -1,4 +1,3 @@
# Milestone: core server 모듈 경계 정리
## 상태

View file

@ -42,43 +42,47 @@ task=m-core-server-boundary-cleanup/03+01,02_handlers, plan=1, tag=HANDLERS
| 항목 | 완료 여부 |
|------|---------|
| [HANDLERS-1] Handler File Boundary | [ ] |
| [HANDLERS-1] Handler File Boundary | [x] |
## 구현 체크리스트
- [ ] predecessor `01_routes`와 `02+01_dto`의 `complete.log` 존재를 확인한다.
- [ ] runner registry/bootstrap handlers를 `runner_handlers.go` 또는 역할이 더 명확한 파일로 이동한다.
- [ ] job/execution/log/artifact handlers를 `job_handlers.go`와 `execution_handlers.go`로 나눈다.
- [ ] runner claim/report/cancel/status/self-update handlers를 `runner_cicd_handlers.go`로 이동한다.
- [ ] `server.go`는 Server type, constructors, Start/Shutdown 같은 server lifecycle만 소유하게 정리한다.
- [ ] `gofmt`를 실행하고 `cd services/core && go test -count=1 ./internal/httpserver`를 통과시킨다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
- [x] predecessor `01_routes`와 `02+01_dto`의 archived `complete.log` 존재를 확인한다.
- [x] runner registry/bootstrap handlers를 `runner_handlers.go`로 이동한다.
- [x] job/execution/log/artifact handlers를 `job_handlers.go`와 `execution_handlers.go`로 나눈다.
- [x] runner claim/report/cancel/status/self-update handlers를 `runner_cicd_handlers.go`로 이동한다.
- [x] `server.go`는 Server type, constructors, Start/Shutdown 같은 server lifecycle만 소유하게 정리한다.
- [x] `gofmt`를 실행하고 `cd services/core && go test -count=1 ./internal/httpserver`를 통과시킨다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G07_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md`를 `plan_local_G07_M.log`로 아카이브한다.
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-core-server-boundary-cleanup/03+01,02_handlers/`를 `agent-task/archive/YYYY/MM/m-core-server-boundary-cleanup/03+01,02_handlers/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-core-server-boundary-cleanup`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-core-server-boundary-cleanup/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G07_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G07_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/m-core-server-boundary-cleanup/03+01,02_handlers/`를 `agent-task/archive/YYYY/MM/m-core-server-boundary-cleanup/03+01,02_handlers/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-core-server-boundary-cleanup`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-core-server-boundary-cleanup/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G07.md`와 `CODE_REVIEW-local-G07.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
- predecessor `complete.log` 확인: `agent-task/archive/2026/06/m-core-server-boundary-cleanup/01_routes/complete.log`와 `agent-task/archive/2026/06/m-core-server-boundary-cleanup/02+01_dto/complete.log` 존재를 확인했다.
- 계획의 파일 분할 구조(`runner_handlers.go`, `job_handlers.go`, `execution_handlers.go`, `runner_cicd_handlers.go`)는 그대로 따랐다.
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
- 같은 package 유지: 모든 신규 파일은 `package httpserver`로 두었다. 새 package를 만들지 않았으므로 import cycle 없음.
- helper 함수 배치: `writeRunnerRegisterResponse`, `writeHeartbeatResponse`, `shellEscape`는 runner handler와 함께 `runner_handlers.go`에 두었다. `writeResponse`는 기존대로 `dto.go`에 유지했다.
- `normalizeRunnerExecutionRequest`, `ensureRunnerKnown`은 runner cicd handler에서만 사용하므로 `runner_cicd_handlers.go`에 배치했다.
- `server.go`는 Server struct, 3개 constructor, Start/StartListener/Shutdown만 남겼다.
## 사용자 리뷰 요청
@ -113,14 +117,18 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후
```bash
$ cd services/core && go test -count=1 ./internal/httpserver
(output)
ok github.com/toki/oto/services/core/internal/httpserver 0.005s
```
### 최종 검증
```bash
$ cd services/core && go test -count=1 ./...
(output)
? 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.005s
ok github.com/toki/oto/services/core/internal/runnerregistry 0.003s
? github.com/toki/oto/services/core/oto [no test files]
```
---
@ -128,3 +136,17 @@ $ cd services/core && go test -count=1 ./...
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- plan deviation: Pass
- verification trust: Pass
- 발견된 문제: 없음
- 다음 단계: PASS이므로 `complete.log` 작성 후 active task directory를 archive로 이동한다.

View file

@ -0,0 +1,43 @@
# Complete - m-core-server-boundary-cleanup/03+01,02_handlers
## 완료 일시
2026-06-08
## 요약
HTTP handler file boundary cleanup completed in 1 review loop; final verdict PASS.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_local_G07_0.log` | `code_review_local_G07_0.log` | PASS | handler body를 기능별 파일로 이동했고 API behavior와 services/core 검증이 유지됨 |
## 구현/정리 내용
- `server.go`를 `Server` type, constructors, Start/Shutdown lifecycle 중심으로 정리했다.
- runner registry/bootstrap handlers를 `runner_handlers.go`로 이동했다.
- job/execution/log/artifact handlers를 `job_handlers.go`와 `execution_handlers.go`로 분리했다.
- runner claim/report/cancel/status/self-update handlers와 runner execution helper를 `runner_cicd_handlers.go`로 이동했다.
## 최종 검증
- `gofmt -l services/core/internal/httpserver/server.go services/core/internal/httpserver/runner_handlers.go services/core/internal/httpserver/job_handlers.go services/core/internal/httpserver/execution_handlers.go services/core/internal/httpserver/runner_cicd_handlers.go services/core/internal/httpserver/routes.go services/core/internal/httpserver/dto.go services/core/internal/httpserver/server_test.go` - PASS; output empty.
- `cd services/core && go test -count=1 ./internal/httpserver` - PASS; `ok github.com/toki/oto/services/core/internal/httpserver 0.006s`.
- `cd services/core && go test -count=1 ./...` - PASS; core service packages passed, no-test packages reported as expected.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/independent-control-plane/milestones/core-server-boundary-cleanup.md`
- Completed task ids:
- `handlers`: PASS; evidence=`plan_local_G07_0.log`, `code_review_local_G07_0.log`; verification=`cd services/core && go test -count=1 ./...`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,140 @@
package httpserver
import (
"encoding/json"
"net/http"
"strings"
"github.com/toki/oto/services/core/internal/cicdstate"
)
func handleGetExecution(store *cicdstate.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
execID := r.PathValue("id")
if execID == "" {
parts := strings.Split(r.URL.Path, "/")
if len(parts) >= 5 {
execID = parts[4]
}
}
if execID == "" {
writeResponse(w, http.StatusBadRequest, map[string]string{"error": "missing execution id"})
return
}
exec, err := store.GetExecution(execID)
if err != nil {
writeResponse(w, http.StatusNotFound, map[string]string{"error": "execution not found"})
return
}
writeResponse(w, http.StatusOK, execToJSON(exec))
}
}
func handleAppendLog(store *cicdstate.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
execID := r.PathValue("id")
if execID == "" {
parts := strings.Split(r.URL.Path, "/")
if len(parts) >= 5 {
execID = parts[4]
}
}
if execID == "" {
writeResponse(w, http.StatusBadRequest, map[string]string{"error": "missing execution id"})
return
}
var req struct {
Line string `json:"line"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Line == "" {
writeResponse(w, http.StatusBadRequest, map[string]string{"error": "line is required"})
return
}
if err := store.AppendLog(execID, req.Line); err != nil {
writeResponse(w, http.StatusNotFound, map[string]string{"error": "execution not found"})
return
}
writeResponse(w, http.StatusCreated, map[string]string{"status": "accepted"})
}
}
func handleGetLogs(store *cicdstate.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
execID := r.PathValue("id")
if execID == "" {
parts := strings.Split(r.URL.Path, "/")
if len(parts) >= 5 {
execID = parts[4]
}
}
if execID == "" {
writeResponse(w, http.StatusBadRequest, map[string]string{"error": "missing execution id"})
return
}
logs, err := store.GetLogs(execID)
if err != nil {
writeResponse(w, http.StatusNotFound, map[string]string{"error": "execution not found"})
return
}
writeResponse(w, http.StatusOK, logsToJSON(logs))
}
}
func handleAppendArtifact(store *cicdstate.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
execID := r.PathValue("id")
if execID == "" {
parts := strings.Split(r.URL.Path, "/")
if len(parts) >= 5 {
execID = parts[4]
}
}
if execID == "" {
writeResponse(w, http.StatusBadRequest, map[string]string{"error": "missing execution id"})
return
}
var req struct {
Name string `json:"name"`
Path string `json:"path"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" || req.Path == "" {
writeResponse(w, http.StatusBadRequest, map[string]string{"error": "name and path are required"})
return
}
if err := store.AppendArtifact(execID, req.Name, req.Path); err != nil {
writeResponse(w, http.StatusNotFound, map[string]string{"error": "execution not found"})
return
}
writeResponse(w, http.StatusCreated, map[string]string{"status": "accepted"})
}
}
func handleGetArtifacts(store *cicdstate.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
execID := r.PathValue("id")
if execID == "" {
parts := strings.Split(r.URL.Path, "/")
if len(parts) >= 5 {
execID = parts[4]
}
}
if execID == "" {
writeResponse(w, http.StatusBadRequest, map[string]string{"error": "missing execution id"})
return
}
artifacts, err := store.GetArtifacts(execID)
if err != nil {
writeResponse(w, http.StatusNotFound, map[string]string{"error": "execution not found"})
return
}
writeResponse(w, http.StatusOK, artifactsToJSON(artifacts))
}
}

View file

@ -0,0 +1,92 @@
package httpserver
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"github.com/toki/oto/services/core/internal/cicdstate"
otopb "github.com/toki/oto/services/core/oto"
)
func handleCreateJob(store *cicdstate.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
ID string `json:"id"`
Name string `json:"name"`
RunRequest *otopb.RunRequest `json:"run_request"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeResponse(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if req.ID == "" || req.Name == "" {
writeResponse(w, http.StatusBadRequest, map[string]string{"error": "id and name are required"})
return
}
job, err := store.CreateJob(req.ID, req.Name, runRequestFromJSON(req.RunRequest))
if err != nil {
writeResponse(w, http.StatusConflict, map[string]string{"error": err.Error()})
return
}
writeResponse(w, http.StatusCreated, jobToJSON(job))
}
}
func handleGetJob(store *cicdstate.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
jobID := r.PathValue("id")
if jobID == "" {
parts := strings.Split(r.URL.Path, "/")
if len(parts) >= 5 {
jobID = parts[4]
}
}
if jobID == "" {
writeResponse(w, http.StatusBadRequest, map[string]string{"error": "missing job id"})
return
}
job, err := store.GetJob(jobID)
if err != nil {
if errors.Is(err, fmt.Errorf("job not found: %s", jobID)) || err.Error() == "job not found: "+jobID {
writeResponse(w, http.StatusNotFound, map[string]string{"error": "job not found"})
return
}
writeResponse(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
writeResponse(w, http.StatusOK, jobToJSON(job))
}
}
func handleCreateExecution(store *cicdstate.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
jobID := r.PathValue("id")
if jobID == "" {
parts := strings.Split(r.URL.Path, "/")
if len(parts) >= 5 {
jobID = parts[4]
}
}
if jobID == "" {
writeResponse(w, http.StatusBadRequest, map[string]string{"error": "missing job id"})
return
}
var req struct {
ID string `json:"id"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if req.ID == "" {
writeResponse(w, http.StatusBadRequest, map[string]string{"error": "execution id is required"})
return
}
exec, err := store.CreateExecution(jobID, req.ID)
if err != nil {
writeResponse(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeResponse(w, http.StatusCreated, execToJSON(exec))
}
}

View file

@ -0,0 +1,494 @@
package httpserver
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/toki/oto/services/core/internal/cicdstate"
"github.com/toki/oto/services/core/internal/runnerregistry"
otopb "github.com/toki/oto/services/core/oto"
)
func handleRunnerClaimJob(store *cicdstate.Store, registry *runnerregistry.Registry, runnerID string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req otopb.JobClaimRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeResponse(w, http.StatusBadRequest, &otopb.JobClaimResponse{
ErrorMessage: "invalid claim request",
})
return
}
if req.RunnerId == "" {
req.RunnerId = runnerID
} else if req.RunnerId != runnerID {
writeResponse(w, http.StatusBadRequest, &otopb.JobClaimResponse{
ErrorMessage: "runner id mismatch between path and body",
})
return
}
if err := ensureRunnerKnown(registry, runnerID); err != nil {
writeResponse(w, http.StatusNotFound, &otopb.JobClaimResponse{
RunnerId: runnerID,
ErrorMessage: err.Error(),
})
return
}
jobID := strings.TrimSpace(req.GetJobId())
execID := strings.TrimSpace(req.GetExecutionId())
if jobID == "" && execID == "" {
var foundJob *cicdstate.Job
for _, j := range store.SnapshotJobs() {
if j.State == cicdstate.StateQueued {
if foundJob == nil || j.CreatedAt.Before(foundJob.CreatedAt) {
copyJ := j
foundJob = &copyJ
}
}
}
if foundJob == nil {
writeResponse(w, http.StatusOK, &otopb.JobClaimResponse{
Accepted: false,
RunnerId: runnerID,
})
return
}
jobID = foundJob.ID
execID = fmt.Sprintf("exec-%d", time.Now().UnixNano())
} else if jobID == "" || execID == "" {
writeResponse(w, http.StatusBadRequest, &otopb.JobClaimResponse{
RunnerId: runnerID,
ErrorMessage: "job id and execution id are required",
})
return
}
job, err := store.GetJob(jobID)
if err != nil {
writeResponse(w, http.StatusNotFound, &otopb.JobClaimResponse{
RunnerId: runnerID,
JobId: jobID,
ErrorMessage: "job not found",
})
return
}
if job.State != cicdstate.StateQueued {
writeResponse(w, http.StatusConflict, &otopb.JobClaimResponse{
RunnerId: runnerID,
JobId: jobID,
ErrorMessage: "job is not queued",
State: job.State,
})
return
}
if job.RunInput == nil {
writeResponse(w, http.StatusConflict, &otopb.JobClaimResponse{
RunnerId: runnerID,
JobId: jobID,
ErrorMessage: "job has no run request",
State: job.State,
})
return
}
if err := validateRunInput(job.RunInput); err != nil {
writeResponse(w, http.StatusBadRequest, &otopb.JobClaimResponse{
RunnerId: runnerID,
JobId: jobID,
ErrorMessage: err.Error(),
})
return
}
if _, err := store.CreateExecution(jobID, execID); err != nil {
writeResponse(w, http.StatusBadRequest, &otopb.JobClaimResponse{
RunnerId: runnerID,
JobId: jobID,
ExecutionId: execID,
ErrorMessage: err.Error(),
})
return
}
_ = store.SetExecutionRunnerID(execID, runnerID)
if err := store.TransitionJob(jobID, cicdstate.StateRunning); err != nil {
writeResponse(w, http.StatusBadRequest, &otopb.JobClaimResponse{
RunnerId: runnerID,
JobId: jobID,
ExecutionId: execID,
ErrorMessage: err.Error(),
})
return
}
if err := store.TransitionExecution(execID, cicdstate.StateRunning); err != nil {
writeResponse(w, http.StatusBadRequest, &otopb.JobClaimResponse{
RunnerId: runnerID,
JobId: jobID,
ExecutionId: execID,
ErrorMessage: err.Error(),
})
return
}
writeResponse(w, http.StatusOK, &otopb.JobClaimResponse{
Accepted: true,
RunnerId: runnerID,
JobId: jobID,
ExecutionId: execID,
State: cicdstate.StateRunning,
RunRequest: runRequestToProto(job.RunInput, runnerID, jobID, execID),
})
}
}
func handleRunnerReportExecution(store *cicdstate.Store, registry *runnerregistry.Registry, runnerID string, execID string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req otopb.ExecutionReportRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeResponse(w, http.StatusBadRequest, &otopb.ExecutionReportResponse{
ErrorMessage: "invalid execution report request",
})
return
}
if req.RunnerId == "" {
req.RunnerId = runnerID
} else if req.RunnerId != runnerID {
writeResponse(w, http.StatusBadRequest, &otopb.ExecutionReportResponse{
ErrorMessage: "runner id mismatch between path and body",
})
return
}
if req.ExecutionId == "" {
req.ExecutionId = execID
} else if req.ExecutionId != execID {
writeResponse(w, http.StatusBadRequest, &otopb.ExecutionReportResponse{
ErrorMessage: "execution id mismatch between path and body",
})
return
}
if err := ensureRunnerKnown(registry, runnerID); err != nil {
writeResponse(w, http.StatusNotFound, &otopb.ExecutionReportResponse{
RunnerId: runnerID,
ExecutionId: execID,
ErrorMessage: err.Error(),
})
return
}
exec, err := store.GetExecution(execID)
if err != nil {
writeResponse(w, http.StatusNotFound, &otopb.ExecutionReportResponse{
RunnerId: runnerID,
ExecutionId: execID,
ErrorMessage: "execution not found",
})
return
}
jobID := strings.TrimSpace(req.GetJobId())
if jobID == "" {
jobID = exec.JobID
} else if jobID != exec.JobID {
writeResponse(w, http.StatusBadRequest, &otopb.ExecutionReportResponse{
RunnerId: runnerID,
JobId: jobID,
ExecutionId: execID,
ErrorMessage: "job id mismatch for execution",
})
return
}
targetState := cicdstate.StateFailed
if req.GetSuccess() {
targetState = cicdstate.StateSucceeded
}
if err := store.TransitionExecution(execID, targetState); err != nil {
writeResponse(w, http.StatusBadRequest, &otopb.ExecutionReportResponse{
RunnerId: runnerID,
JobId: jobID,
ExecutionId: execID,
ErrorMessage: err.Error(),
})
return
}
if err := store.TransitionJob(jobID, targetState); err != nil {
writeResponse(w, http.StatusBadRequest, &otopb.ExecutionReportResponse{
RunnerId: runnerID,
JobId: jobID,
ExecutionId: execID,
ErrorMessage: err.Error(),
})
return
}
if strings.TrimSpace(req.GetMessage()) != "" {
_ = store.AppendLog(execID, req.GetMessage())
}
writeResponse(w, http.StatusOK, &otopb.ExecutionReportResponse{
Accepted: true,
RunnerId: runnerID,
JobId: jobID,
ExecutionId: execID,
State: targetState,
})
}
}
func handleRunnerAppendLog(store *cicdstate.Store, registry *runnerregistry.Registry, runnerID string, execID string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req otopb.LogAppendRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeResponse(w, http.StatusBadRequest, &otopb.LogAppendResponse{
ErrorMessage: "invalid log append request",
})
return
}
if err := normalizeRunnerExecutionRequest(registry, runnerID, execID, req.GetRunnerId(), req.GetExecutionId()); err != nil {
writeResponse(w, http.StatusBadRequest, &otopb.LogAppendResponse{
ErrorMessage: err.Error(),
})
return
}
line := strings.TrimSpace(req.GetLine())
if line == "" {
writeResponse(w, http.StatusBadRequest, &otopb.LogAppendResponse{
ErrorMessage: "line is required",
})
return
}
if err := store.AppendLog(execID, line); err != nil {
writeResponse(w, http.StatusNotFound, &otopb.LogAppendResponse{
ErrorMessage: "execution not found",
})
return
}
writeResponse(w, http.StatusCreated, &otopb.LogAppendResponse{
Accepted: true,
})
}
}
func handleRunnerAppendArtifact(store *cicdstate.Store, registry *runnerregistry.Registry, runnerID string, execID string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req otopb.ArtifactReportRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeResponse(w, http.StatusBadRequest, &otopb.ArtifactReportResponse{
ErrorMessage: "invalid artifact report request",
})
return
}
if err := normalizeRunnerExecutionRequest(registry, runnerID, execID, req.GetRunnerId(), req.GetExecutionId()); err != nil {
writeResponse(w, http.StatusBadRequest, &otopb.ArtifactReportResponse{
ErrorMessage: err.Error(),
})
return
}
name := strings.TrimSpace(req.GetName())
path := strings.TrimSpace(req.GetPath())
if name == "" || path == "" {
writeResponse(w, http.StatusBadRequest, &otopb.ArtifactReportResponse{
ErrorMessage: "name and path are required",
})
return
}
if err := store.AppendArtifact(execID, name, path); err != nil {
writeResponse(w, http.StatusNotFound, &otopb.ArtifactReportResponse{
ErrorMessage: "execution not found",
})
return
}
writeResponse(w, http.StatusCreated, &otopb.ArtifactReportResponse{
Accepted: true,
})
}
}
func normalizeRunnerExecutionRequest(registry *runnerregistry.Registry, runnerID string, execID string, bodyRunnerID string, bodyExecID string) error {
if bodyRunnerID != "" && bodyRunnerID != runnerID {
return fmt.Errorf("runner id mismatch between path and body")
}
if bodyExecID != "" && bodyExecID != execID {
return fmt.Errorf("execution id mismatch between path and body")
}
return ensureRunnerKnown(registry, runnerID)
}
func ensureRunnerKnown(registry *runnerregistry.Registry, runnerID string) error {
if strings.TrimSpace(runnerID) == "" {
return fmt.Errorf("missing runner id")
}
if registry == nil {
return nil
}
if _, ok := registry.Snapshot(runnerID); !ok {
return fmt.Errorf("runner not found")
}
return nil
}
func handleRunnerCancelExecution(store *cicdstate.Store, registry *runnerregistry.Registry, runnerID string, execID string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req otopb.CancelRunRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeResponse(w, http.StatusBadRequest, &otopb.CancelRunResponse{
Success: false,
ErrorMessage: "invalid cancel request",
})
return
}
if err := normalizeRunnerExecutionRequest(registry, runnerID, execID, req.GetRunnerId(), req.GetExecutionId()); err != nil {
writeResponse(w, http.StatusBadRequest, &otopb.CancelRunResponse{
Success: false,
ErrorMessage: err.Error(),
})
return
}
exec, err := store.GetExecution(execID)
if err != nil {
writeResponse(w, http.StatusNotFound, &otopb.CancelRunResponse{
Success: false,
ErrorMessage: "execution not found",
})
return
}
if exec.RunnerID == "" || exec.RunnerID != runnerID {
writeResponse(w, http.StatusBadRequest, &otopb.CancelRunResponse{
Success: false,
ErrorMessage: "execution owner mismatch",
})
return
}
if err := store.CancelJobExecution(exec.JobID, execID); err != nil {
writeResponse(w, http.StatusBadRequest, &otopb.CancelRunResponse{
Success: false,
ErrorMessage: err.Error(),
})
return
}
writeResponse(w, http.StatusOK, &otopb.CancelRunResponse{
Success: true,
})
}
}
func handleRunnerStatus(store *cicdstate.Store, registry *runnerregistry.Registry, runnerID string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
record, ok := registry.Snapshot(runnerID)
if !ok {
writeResponse(w, http.StatusNotFound, map[string]interface{}{
"accepted": false,
"message": "runner not found",
})
return
}
var lastExec *cicdstate.Execution
for _, exec := range store.SnapshotExecutions() {
if exec.RunnerID == runnerID {
if lastExec == nil || exec.CreatedAt.After(lastExec.CreatedAt) {
copyExec := exec
lastExec = &copyExec
}
}
}
currentJobID := ""
currentExecID := ""
if lastExec != nil {
currentJobID = lastExec.JobID
currentExecID = lastExec.ID
}
writeResponse(w, http.StatusOK, map[string]interface{}{
"accepted": true,
"runner_id": runnerID,
"status": record.Status,
"current_job_id": currentJobID,
"current_execution_id": currentExecID,
"message": record.FailureReason,
})
}
}
func handleRunnerSelfUpdate(store *cicdstate.Store, registry *runnerregistry.Registry, runnerID string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req otopb.SelfUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeResponse(w, http.StatusBadRequest, map[string]interface{}{
"success": false,
"accepted": false,
"deferred": false,
"error_message": "invalid self-update request",
"message": "invalid self-update request",
})
return
}
if req.RunnerId == "" {
req.RunnerId = runnerID
} else if req.RunnerId != runnerID {
writeResponse(w, http.StatusBadRequest, map[string]interface{}{
"success": false,
"accepted": false,
"deferred": false,
"error_message": "runner id mismatch between path and body",
"message": "runner id mismatch between path and body",
})
return
}
if err := ensureRunnerKnown(registry, runnerID); err != nil {
writeResponse(w, http.StatusNotFound, map[string]interface{}{
"success": false,
"accepted": false,
"deferred": false,
"error_message": err.Error(),
"message": err.Error(),
})
return
}
if !strings.HasPrefix(req.GetDownloadUrl(), "https://") {
writeResponse(w, http.StatusBadRequest, map[string]interface{}{
"success": false,
"accepted": false,
"deferred": false,
"error_message": "download_url must use HTTPS scheme",
"message": "download_url must use HTTPS scheme",
})
return
}
hasActiveExecution := false
for _, exec := range store.SnapshotExecutions() {
if exec.RunnerID == runnerID && exec.State == cicdstate.StateRunning {
hasActiveExecution = true
break
}
}
if hasActiveExecution {
writeResponse(w, http.StatusOK, map[string]interface{}{
"success": false,
"accepted": false,
"deferred": true,
"target_version": req.GetVersion(),
"message": "deferred: runner is currently running a job",
})
return
}
writeResponse(w, http.StatusOK, map[string]interface{}{
"success": true,
"accepted": true,
"deferred": false,
"target_version": req.GetVersion(),
"message": "self-update request accepted",
})
}
}

View file

@ -0,0 +1,343 @@
package httpserver
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/toki/oto/services/core/internal/runnerregistry"
otopb "github.com/toki/oto/services/core/oto"
)
func handleHealthz(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
func handleReadyz(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
func handleRunnerRegister(registry *runnerregistry.Registry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
var request otopb.RegisterRunnerRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
writeRunnerRegisterResponse(w, http.StatusBadRequest, &otopb.RegisterRunnerResponse{
Accepted: false,
RejectReason: "invalid registration request",
Error: &otopb.ProtocolError{
Code: "invalid_registration_request",
Message: "invalid registration request",
},
})
return
}
response := registry.Register(&request)
writeRunnerRegisterResponse(w, http.StatusOK, response)
}
}
func handleRunnerHeartbeat(registry *runnerregistry.Registry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
runnerID := r.PathValue("id")
if runnerID == "" {
parts := strings.Split(r.URL.Path, "/")
if len(parts) >= 5 {
runnerID = parts[4]
}
}
if runnerID == "" {
writeHeartbeatResponse(w, http.StatusBadRequest, &otopb.HeartbeatResponse{
Success: false,
ErrorMessage: "missing runner id",
})
return
}
var req otopb.HeartbeatRequest
if r.ContentLength > 0 || r.Body != nil {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err.Error() != "EOF" {
writeHeartbeatResponse(w, http.StatusBadRequest, &otopb.HeartbeatResponse{
Success: false,
ErrorMessage: "invalid heartbeat request",
})
return
}
}
if req.RunnerId == "" {
req.RunnerId = runnerID
} else if req.RunnerId != runnerID {
writeHeartbeatResponse(w, http.StatusBadRequest, &otopb.HeartbeatResponse{
Success: false,
ErrorMessage: "runner id mismatch between path and body",
})
return
}
response := registry.Heartbeat(req.RunnerId, req.Status)
if !response.Success {
status := http.StatusNotFound
if response.ErrorMessage != "unknown runner" {
status = http.StatusBadRequest
}
writeHeartbeatResponse(w, status, response)
return
}
writeHeartbeatResponse(w, http.StatusOK, response)
}
}
func handleRunnerDisconnect(registry *runnerregistry.Registry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
runnerID := r.PathValue("id")
if runnerID == "" {
parts := strings.Split(r.URL.Path, "/")
if len(parts) >= 5 {
runnerID = parts[4]
}
}
if runnerID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"error": "missing runner id",
})
return
}
success := registry.Disconnect(runnerID)
w.Header().Set("Content-Type", "application/json")
if !success {
w.WriteHeader(http.StatusNotFound)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"error": "runner not found",
})
return
}
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
})
}
}
func handleGetRunner(registry *runnerregistry.Registry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
runnerID := r.PathValue("id")
if runnerID == "" {
parts := strings.Split(r.URL.Path, "/")
if len(parts) >= 5 {
runnerID = parts[4]
}
}
if runnerID == "" {
http.Error(w, "missing runner id", http.StatusBadRequest)
return
}
record, ok := registry.Snapshot(runnerID)
if !ok {
http.Error(w, "runner not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"runner_id": record.RunnerID,
"alias": record.Alias,
"protocol_version": record.ProtocolVersion,
"status": record.Status,
"accepted_at": record.AcceptedAt.Format(time.RFC3339),
"first_heartbeat_at": record.FirstHeartbeatAt.Format(time.RFC3339),
"last_heartbeat_at": record.LastHeartbeatAt.Format(time.RFC3339),
"failure_reason": record.FailureReason,
})
}
}
func shellEscape(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}
func handleRunnerBootstrapCommand(registry *runnerregistry.Registry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
var request otopb.BootstrapCommandRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"error": "invalid bootstrap command request",
})
return
}
runnerID := strings.TrimSpace(request.GetRunnerId())
token := strings.TrimSpace(request.GetEnrollmentToken())
if runnerID == "" || token == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"error": "missing runner id or enrollment token",
})
return
}
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
serverURL := scheme + "://" + r.Host
u, err := url.Parse(serverURL)
if err != nil || u.Host == "" || u.Scheme == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"error": "invalid server URL",
})
return
}
// Host validation to prevent command injection and host header spoofing
for _, char := range u.Host {
if !((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') ||
char == '.' || char == '-' || char == ':' || char == '[' || char == ']') {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"error": "invalid characters in server Host",
})
return
}
}
releaseBaseURL := os.Getenv("OTO_RUNNER_RELEASE_BASE_URL")
if releaseBaseURL == "" {
if scheme == "https" {
releaseBaseURL = serverURL + "/releases"
} else {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"error": "release base URL must use HTTPS (set OTO_RUNNER_RELEASE_BASE_URL environment variable)",
})
return
}
} else {
if !strings.HasPrefix(releaseBaseURL, "https://") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"error": "OTO_RUNNER_RELEASE_BASE_URL must use https:// scheme",
})
return
}
}
escapedScriptURL := shellEscape(serverURL + "/bootstrap/oto-agent.sh")
escapedServerURL := shellEscape(serverURL)
escapedRunnerID := shellEscape(runnerID)
escapedToken := shellEscape(token)
escapedReleaseURL := shellEscape(releaseBaseURL)
bootstrapCmd := fmt.Sprintf(
"curl -fsSL %s | bash -s -- --server-url %s --agent-id %s --enrollment-token %s --release-base-url %s",
escapedScriptURL, escapedServerURL, escapedRunnerID, escapedToken, escapedReleaseURL,
)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(&otopb.BootstrapCommandResponse{
BootstrapCommand: bootstrapCmd,
})
}
}
func handleServeBootstrapScript() 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
}
}
if err != nil {
http.Error(w, "Bootstrap script not found: "+err.Error(), http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/x-sh")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(content)
}
}
func writeRunnerRegisterResponse(w http.ResponseWriter, status int, response *otopb.RegisterRunnerResponse) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(response)
}
func writeHeartbeatResponse(w http.ResponseWriter, status int, response *otopb.HeartbeatResponse) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(response)
}

File diff suppressed because it is too large Load diff