gito/services/core/internal/controlplane/router.go

364 lines
12 KiB
Go

package controlplane
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"strings"
"time"
"git.toki-labs.com/toki/gito/services/core/internal/config"
"git.toki-labs.com/toki/gito/services/core/internal/protosocket"
"git.toki-labs.com/toki/gito/services/core/internal/provider/forgejo"
"git.toki-labs.com/toki/gito/services/core/internal/storage"
)
func NewRouter(cfg config.Config, logger *slog.Logger) http.Handler {
return newRouterWithStore(cfg, logger, nil)
}
// NewRouterWithStore constructs a router backed by the given durable store.
// Pass nil to fall back to in-memory runtime (same as NewRouter).
func NewRouterWithStore(cfg config.Config, logger *slog.Logger, store storage.Store) http.Handler {
return newRouterWithStore(cfg, logger, store)
}
func newRouterWithStore(cfg config.Config, logger *slog.Logger, store storage.Store) http.Handler {
mux := http.NewServeMux()
protoServer := protosocket.NewServer(protosocket.Config{
HeartbeatIntervalSec: cfg.ProtoSocketHeartbeatSec,
HeartbeatWaitSec: cfg.ProtoSocketHeartbeatWait,
}, logger)
runtime := NewRuntimeWithStore(protoServer, store)
registerProtoSocketHandlers(protoServer.Dispatcher(), runtime, protoServer)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
mux.HandleFunc("/readyz", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{
"status": "ready",
"env": cfg.AppEnv,
})
})
mux.HandleFunc("/api/listeners/branches", handleBranchListeners(runtime))
mux.HandleFunc("/api/events", handleEvents(runtime))
mux.HandleFunc("/callbacks/forgejo/push", handleForgejoPush(cfg, runtime))
mux.HandleFunc(cfg.ProtoSocketPath, func(w http.ResponseWriter, r *http.Request) {
if isWebSocketUpgrade(r) {
protoServer.ServeHTTP(w, r)
return
}
if r.Method != http.MethodGet {
w.Header().Set("Allow", http.MethodGet)
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{
"error": "method not allowed",
})
return
}
logger.Info("proto-socket registry scaffold", "path", r.URL.Path)
writeJSON(w, http.StatusOK, DefaultProtoSocketRegistry())
})
return mux
}
func registerProtoSocketHandlers(dispatcher *protosocket.Dispatcher, runtime *Runtime, subscriber protosocket.EventSubscriber) {
dispatcher.Register("event.subscribe", func(_ context.Context, env protosocket.Envelope) protosocket.Envelope {
connectionID := stringValue(env.Meta["connection_id"])
if connectionID == "" {
return protosocket.ErrorResponse(env, "event.missing_connection", "event.subscribe requires a proto-socket connection id", false)
}
subscription := eventSubscriptionFromPayload(env.Payload)
registered := subscriber.Subscribe(connectionID, subscription)
return protosocket.SuccessResponse(env, map[string]any{
"status": "subscribed",
"subscription": registered.Payload(),
})
})
dispatcher.Register("event.list", func(_ context.Context, env protosocket.Envelope) protosocket.Envelope {
return protosocket.SuccessResponse(env, map[string]any{
"events": eventRecordsPayload(runtime.ListEvents()),
})
})
}
func eventSubscriptionFromPayload(payload map[string]any) protosocket.EventSubscription {
events := stringListValue(payload["events"])
if len(events) == 0 {
events = []string{"branch.updated"}
}
return protosocket.EventSubscription{
Events: events,
RepoID: stringValue(payload["repo_id"]),
Branch: stringValue(payload["branch"]),
}
}
func stringListValue(value any) []string {
items, ok := value.([]any)
if !ok {
if values, ok := value.([]string); ok {
return values
}
return nil
}
result := make([]string, 0, len(items))
for _, item := range items {
if text := stringValue(item); text != "" {
result = append(result, text)
}
}
return result
}
func stringValue(value any) string {
text, ok := value.(string)
if !ok {
return ""
}
return strings.TrimSpace(text)
}
func handleBranchListeners(runtime *Runtime) http.HandlerFunc {
type request struct {
RepoID string `json:"repo_id"`
Branch string `json:"branch"`
Provider string `json:"provider"`
}
return func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]any{"listeners": runtime.ListBranchWatches()})
case http.MethodPost:
var input request
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid listener payload"})
return
}
watch, err := runtime.RegisterBranchWatch(input.RepoID, input.Branch, input.Provider)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusCreated, map[string]any{"listener": watch})
default:
w.Header().Set("Allow", "GET, POST")
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
}
}
}
func handleEvents(runtime *Runtime) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", http.MethodGet)
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"events": eventRecordsPayload(runtime.ListEvents())})
}
}
func handleForgejoPush(cfg config.Config, runtime *Runtime) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read payload"})
return
}
if strings.TrimSpace(cfg.ForgejoWebhookSecret) != "" &&
!forgejo.VerifySignature(cfg.ForgejoWebhookSecret, body, r.Header.Get("X-Forgejo-Signature")) {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid forgejo signature"})
return
}
if eventName := webhookEventName(r); eventName != "" && eventName != "push" {
writeJSON(w, http.StatusAccepted, map[string]any{"accepted": true, "matched": false, "reason": "unsupported event"})
return
}
payload, err := forgejo.ParsePushPayload(body)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid forgejo push payload"})
return
}
revision, err := forgejo.NormalizePush(payload, r.URL.Query().Get("repo_id"), time.Now().UTC())
if err != nil {
writeJSON(w, http.StatusAccepted, map[string]any{"accepted": true, "matched": false, "reason": "non-branch ref"})
return
}
record, matched, err := runtime.HandleRevision(r.Context(), "forgejo", r.Header.Get("X-Forgejo-Delivery"), revision)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to publish event"})
return
}
resp := map[string]any{
"accepted": true,
"matched": matched,
"event": eventRecordPayload(record),
}
if record.Duplicate {
resp["duplicate"] = true
}
writeJSON(w, http.StatusAccepted, resp)
}
}
func webhookEventName(r *http.Request) string {
for _, header := range []string{"X-Forgejo-Event", "X-Gitea-Event", "X-Gogs-Event", "X-GitHub-Event"} {
if value := strings.TrimSpace(r.Header.Get(header)); value != "" {
return strings.ToLower(value)
}
}
return ""
}
func isWebSocketUpgrade(r *http.Request) bool {
return strings.EqualFold(r.Header.Get("Upgrade"), "websocket") &&
strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade")
}
type ProtoSocketRegistry struct {
Transport string `json:"transport"`
Status string `json:"status"`
Channels []ProtoSocketChannel `json:"channels"`
}
type ProtoSocketChannel struct {
Name string `json:"name"`
Purpose string `json:"purpose"`
Status string `json:"status"`
Actions []ProtoSocketAction `json:"actions"`
}
type ProtoSocketAction struct {
Name string `json:"name"`
Status string `json:"status"`
}
func DefaultProtoSocketRegistry() ProtoSocketRegistry {
return ProtoSocketRegistry{
Transport: "proto-socket",
Status: "registry-placeholder",
Channels: []ProtoSocketChannel{
{
Name: "repo",
Purpose: "register, inspect, and list managed repositories",
Status: "placeholder",
Actions: []ProtoSocketAction{
{Name: "register", Status: "placeholder"},
{Name: "get", Status: "placeholder"},
{Name: "list", Status: "placeholder"},
},
},
{
Name: "workspace",
Purpose: "lease, release, and inspect workspace slots",
Status: "placeholder",
Actions: []ProtoSocketAction{
{Name: "lease", Status: "placeholder"},
{Name: "release", Status: "placeholder"},
{Name: "get", Status: "placeholder"},
},
},
{
Name: "operation",
Purpose: "create, cancel, inspect, and stream operations",
Status: "placeholder",
Actions: []ProtoSocketAction{
{Name: "create", Status: "placeholder"},
{Name: "cancel", Status: "placeholder"},
{Name: "get", Status: "placeholder"},
{Name: "stream", Status: "placeholder"},
},
},
{
Name: "git",
Purpose: "request platformless Git operations",
Status: "placeholder",
Actions: []ProtoSocketAction{
{Name: "clone", Status: "placeholder"},
{Name: "fetch", Status: "placeholder"},
{Name: "status", Status: "placeholder"},
{Name: "diff", Status: "placeholder"},
{Name: "commit", Status: "placeholder"},
{Name: "push", Status: "placeholder"},
},
},
{
Name: "change_request",
Purpose: "manage provider-neutral change requests",
Status: "placeholder",
Actions: []ProtoSocketAction{
{Name: "open", Status: "placeholder"},
{Name: "update", Status: "placeholder"},
{Name: "list", Status: "placeholder"},
},
},
{
Name: "agent_shell",
Purpose: "shell heartbeat, command dispatch, and log streaming",
Status: "placeholder",
Actions: []ProtoSocketAction{
{Name: "heartbeat", Status: "placeholder"},
{Name: "dispatch", Status: "placeholder"},
{Name: "stream_logs", Status: "placeholder"},
},
},
{
Name: "event",
Purpose: "subscribe to normalized events",
Status: "mvp",
Actions: []ProtoSocketAction{
{Name: "subscribe", Status: "mvp"},
{Name: "list", Status: "mvp"},
{Name: "ack", Status: "placeholder"},
},
},
},
}
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func eventRecordsPayload(records []EventRecord) []any {
payload := make([]any, 0, len(records))
for _, record := range records {
payload = append(payload, eventRecordPayload(record))
}
return payload
}
func eventRecordPayload(record EventRecord) map[string]any {
changedFiles := make([]any, 0, len(record.Revision.ChangedFiles))
for _, file := range record.Revision.ChangedFiles {
changedFiles = append(changedFiles, map[string]any{
"path": file.Path,
"change_type": file.ChangeType,
})
}
return map[string]any{
"id": record.ID,
"type": record.Type,
"provider": record.Provider,
"delivery_id": record.DeliveryID,
"revision": map[string]any{
"repo_id": record.Revision.RepoID,
"branch": record.Revision.Branch,
"before": record.Revision.Before,
"after": record.Revision.After,
"changed_files": changedFiles,
"observed_at": record.Revision.ObservedAt.UTC().Format(time.RFC3339Nano),
},
"created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano),
}
}