Files
URBM/internal/api/server.go
T
2026-06-15 07:17:37 +02:00

292 lines
8.4 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"git.casaderoll.de/michael/urbm/internal/model"
"git.casaderoll.de/michael/urbm/internal/service"
)
type Server struct {
service *service.Service
log *slog.Logger
http *http.Server
socket string
}
var Version = "dev"
func New(socket string, svc *service.Service, log *slog.Logger) *Server {
s := &Server{service: svc, log: log, socket: socket}
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/health", s.health)
mux.HandleFunc("GET /v1/config", s.getConfig)
mux.HandleFunc("PUT /v1/config", s.putConfig)
mux.HandleFunc("PUT /v1/secrets/{id}", s.putSecret)
mux.HandleFunc("DELETE /v1/secrets/{id}", s.deleteSecret)
mux.HandleFunc("GET /v1/runs", s.runs)
mux.HandleFunc("DELETE /v1/runs", s.clearRuns)
mux.HandleFunc("GET /v1/filesystem/directories", s.directories)
mux.HandleFunc("GET /v1/workloads/{kind}", s.workloads)
mux.HandleFunc("POST /v1/jobs/{id}/run", s.runJob)
mux.HandleFunc("POST /v1/runs/{id}/cancel", s.cancelRun)
mux.HandleFunc("POST /v1/runs/{id}/pause", s.pauseRun)
mux.HandleFunc("POST /v1/runs/{id}/resume", s.resumeRun)
mux.HandleFunc("POST /v1/repositories/{id}/test", s.testRepository)
mux.HandleFunc("POST /v1/repositories/{id}/init", s.initRepository)
mux.HandleFunc("POST /v1/repositories/{id}/unlock", s.unlockRepository)
mux.HandleFunc("POST /v1/repositories/{id}/{action}", s.maintenance)
mux.HandleFunc("GET /v1/repositories/stats", s.repositoryStats)
mux.HandleFunc("GET /v1/repositories/{id}/snapshots", s.snapshots)
mux.HandleFunc("GET /v1/repositories/{id}/snapshots/{snapshot}/files", s.snapshotFiles)
mux.HandleFunc("POST /v1/restores", s.restore)
s.http = &http.Server{Handler: requestLog(log, mux), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 5 * time.Minute, IdleTimeout: 30 * time.Second}
return s
}
func (s *Server) ListenAndServe() error {
if err := os.MkdirAll(filepath.Dir(s.socket), 0750); err != nil {
return err
}
_ = os.Remove(s.socket)
listener, err := net.Listen("unix", s.socket)
if err != nil {
return err
}
if err := os.Chmod(s.socket, 0660); err != nil {
listener.Close()
return err
}
err = s.http.Serve(listener)
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}
func (s *Server) Shutdown(ctx context.Context) error {
defer os.Remove(s.socket)
return s.http.Shutdown(ctx)
}
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, 200, map[string]any{"status": "ok", "version": Version, "schemaVersion": model.SchemaVersion})
}
func (s *Server) getConfig(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, 200, s.service.Config())
}
func (s *Server) putConfig(w http.ResponseWriter, r *http.Request) {
var config model.Config
if err := decode(r, &config); err != nil {
writeError(w, err)
return
}
if err := s.service.SaveConfig(config); err != nil {
writeError(w, err)
return
}
writeJSON(w, 200, config)
}
func (s *Server) putSecret(w http.ResponseWriter, r *http.Request) {
var body struct {
Type string `json:"type"`
Value string `json:"value"`
}
if err := decode(r, &body); err != nil {
writeError(w, err)
return
}
if err := s.service.PutSecret(r.PathValue("id"), body.Type, body.Value); err != nil {
writeError(w, err)
return
}
writeJSON(w, 204, nil)
}
func (s *Server) deleteSecret(w http.ResponseWriter, r *http.Request) {
if err := s.service.DeleteSecret(r.PathValue("id")); err != nil {
writeError(w, err)
return
}
w.WriteHeader(204)
}
func (s *Server) runs(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, s.service.Runs()) }
func (s *Server) clearRuns(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, 200, map[string]int{"cleared": s.service.ClearRunHistory()})
}
func (s *Server) directories(w http.ResponseWriter, r *http.Request) {
items, err := s.service.BrowseDirectories(r.URL.Query().Get("path"))
if err != nil {
writeError(w, err)
return
}
writeJSON(w, 200, items)
}
func (s *Server) workloads(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
items, err := s.service.DiscoverWorkloads(ctx, model.JobType(r.PathValue("kind")))
if err != nil {
writeError(w, err)
return
}
writeJSON(w, 200, items)
}
func (s *Server) runJob(w http.ResponseWriter, r *http.Request) {
run, err := s.service.EnqueueJob(r.PathValue("id"), 10)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, 202, run)
}
func (s *Server) cancelRun(w http.ResponseWriter, r *http.Request) {
if !s.service.Cancel(r.PathValue("id")) {
writeJSON(w, 404, map[string]string{"error": "run not found"})
return
}
w.WriteHeader(204)
}
func (s *Server) pauseRun(w http.ResponseWriter, r *http.Request) {
if err := s.service.Pause(r.PathValue("id")); err != nil {
writeError(w, err)
return
}
w.WriteHeader(204)
}
func (s *Server) resumeRun(w http.ResponseWriter, r *http.Request) {
if err := s.service.Resume(r.PathValue("id")); err != nil {
writeError(w, err)
return
}
w.WriteHeader(204)
}
func (s *Server) testRepository(w http.ResponseWriter, r *http.Request) {
s.repositoryAction(w, r, false)
}
func (s *Server) initRepository(w http.ResponseWriter, r *http.Request) {
s.repositoryAction(w, r, true)
}
func (s *Server) unlockRepository(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
if err := s.service.UnlockRepository(ctx, r.PathValue("id")); err != nil {
writeError(w, err)
return
}
writeJSON(w, 200, map[string]bool{"ok": true})
}
func (s *Server) repositoryAction(w http.ResponseWriter, r *http.Request, initialize bool) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
if err := s.service.TestRepository(ctx, r.PathValue("id"), initialize); err != nil {
writeError(w, err)
return
}
writeJSON(w, 200, map[string]bool{"ok": true})
}
func (s *Server) maintenance(w http.ResponseWriter, r *http.Request) {
run, err := s.service.EnqueueMaintenance(r.PathValue("id"), r.PathValue("action"))
if err != nil {
writeError(w, err)
return
}
writeJSON(w, 202, run)
}
func (s *Server) snapshots(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
items, err := s.service.Snapshots(ctx, r.PathValue("id"))
if err != nil {
writeError(w, err)
return
}
writeJSON(w, 200, items)
}
func (s *Server) repositoryStats(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
writeJSON(w, 200, s.service.RepositoryStats(ctx))
}
func (s *Server) snapshotFiles(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
items, err := s.service.SnapshotFiles(ctx, r.PathValue("id"), r.PathValue("snapshot"), r.URL.Query().Get("path"))
if err != nil {
writeError(w, err)
return
}
writeJSON(w, 200, items)
}
func (s *Server) restore(w http.ResponseWriter, r *http.Request) {
var task model.RestoreTask
if err := decode(r, &task); err != nil {
writeError(w, err)
return
}
run, err := s.service.EnqueueRestore(task)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, 202, run)
}
func decode(r *http.Request, target any) error {
decoder := json.NewDecoder(io.LimitReader(r.Body, 2*1024*1024))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return fmt.Errorf("validation: invalid JSON: %w", err)
}
return nil
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if status != 204 && value != nil {
_ = json.NewEncoder(w).Encode(value)
}
}
func writeError(w http.ResponseWriter, err error) {
status := 500
message := err.Error()
if strings.HasPrefix(message, "validation:") {
status = 400
}
if strings.Contains(message, "unknown") {
status = 404
}
if strings.Contains(message, "already queued") {
status = 409
}
writeJSON(w, status, map[string]string{"error": message})
}
func requestLog(log *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
started := time.Now()
next.ServeHTTP(w, r)
log.Info("api request", "method", r.Method, "path", r.URL.Path, "durationMs", time.Since(started).Milliseconds())
})
}