OTO Server에서 runner 설치 명령과 bootstrap script를 제공해야 하므로 서버 endpoint, runner proto, bootstrap config naming, smoke 검증을 함께 정리한다.
389 lines
11 KiB
Go
389 lines
11 KiB
Go
package httpserver
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"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 NewServerWithRegistry(addr, runnerregistry.New())
|
|
}
|
|
|
|
// NewServerWithRegistry creates a server using an injected runner registry.
|
|
func NewServerWithRegistry(addr string, registry *runnerregistry.Registry) *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())
|
|
|
|
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)
|
|
}
|
|
}
|