Add Forgejo namespace workflow stack
Some checks are pending
Build Rust / Cargo Test (push) Waiting to run
Build Site / Next.js Build (push) Waiting to run

This commit is contained in:
Conrad Kramer 2026-03-18 02:49:55 -07:00
parent 482fd5d085
commit 865b676c99
68 changed files with 9709 additions and 11 deletions

View file

@ -0,0 +1,151 @@
package server
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/burrow/forgejo-nsc/internal/app"
)
type Server struct {
httpServer *http.Server
app *app.Service
log *slog.Logger
}
func New(listen string, svc *app.Service, logger *slog.Logger) *Server {
if logger == nil {
logger = slog.Default()
}
router := chi.NewRouter()
router.Use(middleware.RequestID)
router.Use(middleware.RealIP)
router.Use(middleware.Logger)
router.Use(middleware.Recoverer)
s := &Server{
app: svc,
log: logger,
httpServer: &http.Server{
Addr: listen,
Handler: router,
ReadTimeout: 30 * time.Second,
// Dispatch requests can legitimately run for the duration of a build.
// A short WriteTimeout will kill the request context mid-provisioning.
WriteTimeout: 2 * time.Hour,
IdleTimeout: 60 * time.Second,
},
}
router.Get("/healthz", s.handleHealthz)
router.Post("/api/v1/dispatch", s.handleDispatch)
return s
}
func (s *Server) ListenAndServe() error {
return s.httpServer.ListenAndServe()
}
func (s *Server) Shutdown(ctx context.Context) error {
return s.httpServer.Shutdown(ctx)
}
// Handler exposes the underlying HTTP handler for tests.
func (s *Server) Handler() http.Handler {
return s.httpServer.Handler
}
type dispatchRequest struct {
Count int `json:"count"`
Labels []string `json:"labels"`
Scope *dispatchScope `json:"scope"`
TTL string `json:"ttl"`
Machine string `json:"machine_type"`
Image string `json:"image"`
Env map[string]string `json:"env"`
}
type dispatchScope struct {
Level string `json:"level"`
Owner string `json:"owner"`
Name string `json:"name"`
}
func (s *Server) handleDispatch(w http.ResponseWriter, r *http.Request) {
var payload dispatchRequest
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
duration, err := parseDuration(payload.TTL)
if err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
var scope *app.Scope
if payload.Scope != nil {
scope = &app.Scope{
Level: payload.Scope.Level,
Owner: payload.Scope.Owner,
Name: payload.Scope.Name,
}
}
resp, err := s.app.Dispatch(r.Context(), app.DispatchRequest{
Count: payload.Count,
Labels: payload.Labels,
Scope: scope,
TTL: duration,
Machine: payload.Machine,
Image: payload.Image,
ExtraEnv: payload.Env,
})
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
s.writeJSON(w, http.StatusOK, resp)
}
func parseDuration(value string) (time.Duration, error) {
if value == "" {
return 0, nil
}
dur, err := time.ParseDuration(value)
if err != nil {
return 0, err
}
if dur <= 0 {
return 0, errors.New("ttl must be positive")
}
return dur, nil
}
func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
s.writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) writeError(w http.ResponseWriter, code int, err error) {
s.log.Error("request failed", "err", err, "status", code)
s.writeJSON(w, code, map[string]string{
"error": err.Error(),
})
}
func (s *Server) writeJSON(w http.ResponseWriter, code int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(payload)
}

View file

@ -0,0 +1,111 @@
package server
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/burrow/forgejo-nsc/internal/app"
"github.com/burrow/forgejo-nsc/internal/forgejo"
"github.com/burrow/forgejo-nsc/internal/nsc"
)
type serverForgejoMock struct {
mu sync.Mutex
token string
scopes []forgejo.Scope
}
func (m *serverForgejoMock) RegistrationToken(ctx context.Context, scope forgejo.Scope) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.scopes = append(m.scopes, scope)
return m.token, nil
}
type serverDispatcherMock struct {
mu sync.Mutex
requests []nsc.LaunchRequest
result string
}
func (m *serverDispatcherMock) LaunchRunner(ctx context.Context, req nsc.LaunchRequest) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.requests = append(m.requests, req)
if m.result != "" {
return m.result, nil
}
return "runner", nil
}
func TestDispatchEndpoint(t *testing.T) {
forgejoMock := &serverForgejoMock{token: "token"}
dispatcherMock := &serverDispatcherMock{result: "runner-http"}
cfg := app.Config{
DefaultScope: forgejo.Scope{Level: forgejo.ScopeInstance},
DefaultLabels: []string{"fallback"},
InstanceURL: "https://forgejo.example.com",
DefaultTTL: 30 * time.Minute,
}
service := app.NewService(cfg, forgejoMock, dispatcherMock, nil)
srv := New(":0", service, nil)
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
body := map[string]any{
"count": 1,
"ttl": "45m",
"labels": []string{"nscloud-arm"},
"scope": map[string]string{"level": string(forgejo.ScopeOrganization), "owner": "acme"},
"machine_type": "8x16",
"image": "runner:http",
"env": map[string]string{"FOO": "bar"},
}
payload, _ := json.Marshal(body)
resp, err := http.Post(ts.URL+"/api/v1/dispatch", "application/json", bytes.NewReader(payload))
if err != nil {
t.Fatalf("POST failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 OK, got %d", resp.StatusCode)
}
var decoded app.DispatchResponse
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if len(decoded.Runners) != 1 || decoded.Runners[0].Name != "runner-http" {
t.Fatalf("unexpected response: %+v", decoded)
}
if len(forgejoMock.scopes) != 1 || forgejoMock.scopes[0].Level != forgejo.ScopeOrganization {
t.Fatalf("expected organization scope, got %+v", forgejoMock.scopes)
}
if len(dispatcherMock.requests) != 1 {
t.Fatalf("expected dispatcher call")
}
call := dispatcherMock.requests[0]
if call.Duration != 45*time.Minute {
t.Fatalf("expected ttl override, got %v", call.Duration)
}
if call.Labels[0] != "nscloud-arm" {
t.Fatalf("expected labels passthrough, got %v", call.Labels)
}
if call.ExtraEnv["FOO"] != "bar" {
t.Fatalf("expected env passthrough")
}
}