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) }