oto/services/core/internal/httpserver/server.go
toki 5fd116e447 feat(control-plane): CI/CD 상태 API를 추가한다
OTO Core가 job, execution, log, artifact 상태를 서버 내부에서 소유하고 조회할 수 있어야 runner dispatch와 통합 smoke 단계로 이어질 수 있다. in-memory 상태 저장소와 HTTP API, 검증 테스트, 관련 agent-task 산출물을 함께 반영한다.
2026-06-06 06:22:47 +09:00

741 lines
22 KiB
Go

package httpserver
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os"
"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"
)
// Server wraps the HTTP server for the OTO Core service.
type Server struct {
httpServer *http.Server
}
// NewServer creates a new instance of Server.
func NewServer(addr string) *Server {
return NewServerWithRegistryAndStore(addr, runnerregistry.New(), cicdstate.NewStore())
}
// NewServerWithRegistry creates a server using an injected runner registry.
func NewServerWithRegistry(addr string, registry *runnerregistry.Registry) *Server {
return NewServerWithRegistryAndStore(addr, registry, cicdstate.NewStore())
}
// NewServerWithRegistryAndStore creates a server with injected runner registry and CICD store.
func NewServerWithRegistryAndStore(addr string, registry *runnerregistry.Registry, store *cicdstate.Store) *Server {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", handleHealthz)
mux.HandleFunc("/readyz", handleReadyz)
mux.HandleFunc("/api/v1/runners/register", handleRunnerRegister(registry))
mux.HandleFunc("/api/v1/runners/bootstrap-command", handleRunnerBootstrapCommand(registry))
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("/api/v1/", handleRouter(store, nil))
return &Server{
httpServer: &http.Server{
Addr: addr,
Handler: mux,
},
}
}
// Start starts the HTTP server.
func (s *Server) Start() error {
return s.httpServer.ListenAndServe()
}
// StartListener starts the HTTP server using a custom net.Listener.
// Useful for testing with dynamic ports.
func (s *Server) StartListener(ln net.Listener) error {
return s.httpServer.Serve(ln)
}
// Shutdown gracefully shuts down the server.
func (s *Server) Shutdown(ctx context.Context) error {
return s.httpServer.Shutdown(ctx)
}
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",
})
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
// Decode request if body is not empty
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 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)
}
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)
}
}
// Extract path segments after "/api/v1/"
func apiPathSegments(path string) []string {
if !strings.HasPrefix(path, "/api/v1/") {
return nil
}
trimmed := strings.TrimPrefix(path, "/api/v1/")
trimmed = strings.TrimRight(trimmed, "/")
if trimmed == "" {
return nil
}
return strings.Split(trimmed, "/")
}
func handleRouter(store *cicdstate.Store, registry *runnerregistry.Registry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
parts := apiPathSegments(r.URL.Path)
if parts == nil {
http.NotFound(w, r)
return
}
switch parts[0] {
case "jobs":
switch {
case len(parts) == 1 && r.Method == http.MethodPost:
handleCreateJob(store)(w, r)
case len(parts) == 1 && r.Method == http.MethodGet:
writeResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
case len(parts) == 2 && r.Method == http.MethodGet:
handleGetJob(store)(w, r)
case len(parts) == 3 && parts[2] == "executions" && r.Method == http.MethodPost:
handleCreateExecution(store)(w, r)
default:
http.NotFound(w, r)
}
case "executions":
if len(parts) < 2 {
http.NotFound(w, r)
return
}
switch {
case len(parts) == 2 && r.Method == http.MethodGet:
handleGetExecution(store)(w, r)
case len(parts) == 3 && parts[2] == "logs" && r.Method == http.MethodPost:
handleAppendLog(store)(w, r)
case len(parts) == 3 && parts[2] == "logs" && r.Method == http.MethodGet:
handleGetLogs(store)(w, r)
case len(parts) == 3 && parts[2] == "artifacts" && r.Method == http.MethodPost:
handleAppendArtifact(store)(w, r)
case len(parts) == 3 && parts[2] == "artifacts" && r.Method == http.MethodGet:
handleGetArtifacts(store)(w, r)
default:
http.NotFound(w, r)
}
default:
http.NotFound(w, r)
}
}
}
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"`
}
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)
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))
}
}
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))
}
}
// --- JSON helpers ---
type jobResponse struct {
ID string `json:"id"`
Name string `json:"name"`
State string `json:"state"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ExecutionID string `json:"execution_id,omitempty"`
}
func jobToJSON(j *cicdstate.Job) map[string]interface{} {
return map[string]interface{}{
"id": j.ID,
"name": j.Name,
"state": j.State,
"created_at": j.CreatedAt.Format(time.RFC3339),
"updated_at": j.UpdatedAt.Format(time.RFC3339),
"execution_id": j.ExecutionID,
}
}
type execResponse struct {
ID string `json:"id"`
JobID string `json:"job_id"`
State string `json:"state"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Logs []cicdstate.LogEntry `json:"logs,omitempty"`
Artifacts []cicdstate.ArtifactEntry `json:"artifacts,omitempty"`
}
func execToJSON(e *cicdstate.Execution) map[string]interface{} {
return map[string]interface{}{
"id": e.ID,
"job_id": e.JobID,
"state": e.State,
"created_at": e.CreatedAt.Format(time.RFC3339),
"updated_at": e.UpdatedAt.Format(time.RFC3339),
"execution_id": e.ID,
}
}
type logEntryResponse struct {
Timestamp string `json:"timestamp"`
Line string `json:"line"`
}
func logsToJSON(logs []cicdstate.LogEntry) map[string]interface{} {
return map[string]interface{}{
"logs": logs,
}
}
type artifactResponse struct {
Name string `json:"name"`
Path string `json:"path"`
}
func artifactsToJSON(artifacts []cicdstate.ArtifactEntry) map[string]interface{} {
return map[string]interface{}{
"artifacts": artifacts,
}
}
func writeResponse(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(data)
}