package api import ( "errors" "log/slog" "net/http" "strings" "github.com/go-chi/chi/v5" "github.com/alexei/tinyforge/internal/store" ) // workloadVolumeRequest is the body shape accepted by the upsert // endpoint. Defaults to scope=absolute when unset. type workloadVolumeRequest struct { Source string `json:"source"` Target string `json:"target"` Scope string `json:"scope"` Name string `json:"name"` } // scopeInfo carries one volume scope plus its operator-facing description. // The UI uses NeedsName to decide whether to show the name input. type scopeInfo struct { Scope string `json:"scope"` Description string `json:"description"` NeedsName bool `json:"needs_name"` PathExample string `json:"path_example"` } // listVolumeScopes handles GET /api/volumes/scopes. Returns the catalogue // of supported volume scopes so the workload-volume editor can render // scope-specific help text without baking the list into the frontend. func (s *Server) listVolumeScopes(w http.ResponseWriter, r *http.Request) { scopes := []scopeInfo{ { Scope: "instance", Description: "Each deploy gets its own isolated directory keyed by image tag.", NeedsName: false, PathExample: "{base}/{workload}/instance-{tag}/{source}", }, { Scope: "stage", Description: "Shared across all instances of this workload (alias of project scope).", NeedsName: false, PathExample: "{base}/{workload}/{source}", }, { Scope: "project", Description: "Shared across all instances of this workload.", NeedsName: false, PathExample: "{base}/{workload}/{source}", }, { Scope: "project_named", Description: "A named volume within the workload — multiple mounts can share the name.", NeedsName: true, PathExample: "{base}/{workload}/_named/{name}/{source}", }, { Scope: "named", Description: "Globally named volume shared across workloads (e.g. shared databases).", NeedsName: true, PathExample: "{base}/_named/{name}/{source}", }, { Scope: "ephemeral", Description: "In-memory tmpfs mount. Fast but data is lost when the container stops.", NeedsName: false, PathExample: "(tmpfs — no host path)", }, { Scope: "absolute", Description: "Direct host path. Must be under an allowed path configured in settings.", NeedsName: false, PathExample: "/mnt/nfs/data (must match allowed paths)", }, } respondJSON(w, http.StatusOK, scopes) } func (s *Server) listWorkloadVolumes(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if _, err := s.store.GetWorkloadByID(id); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "workload") return } respondError(w, http.StatusInternalServerError, "get workload") return } rows, err := s.store.ListWorkloadVolumes(id) if err != nil { respondError(w, http.StatusInternalServerError, "list workload volumes") return } respondJSON(w, http.StatusOK, rows) } func (s *Server) setWorkloadVolume(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if _, err := s.store.GetWorkloadByID(id); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "workload") return } respondError(w, http.StatusInternalServerError, "get workload") return } var req workloadVolumeRequest if !decodeJSONStrict(w, r, &req) { return } req.Target = strings.TrimSpace(req.Target) if req.Target == "" { respondError(w, http.StatusBadRequest, "target is required") return } if !strings.HasPrefix(req.Target, "/") { respondError(w, http.StatusBadRequest, "target must be an absolute container path") return } if strings.Contains(req.Target, "..") { respondError(w, http.StatusBadRequest, "target may not contain path traversal segments") return } scope := req.Scope if scope == "" { scope = string(store.VolumeScopeAbsolute) } if !store.IsValidVolumeScope(scope) { respondError(w, http.StatusBadRequest, "invalid scope") return } // Absolute-scope mounts must reference a real host path; allow-list // enforcement happens at deploy time against settings.AllowedVolumePaths. if scope == string(store.VolumeScopeAbsolute) { if strings.TrimSpace(req.Source) == "" { respondError(w, http.StatusBadRequest, "source is required for absolute scope") return } if strings.Contains(req.Source, "..") { respondError(w, http.StatusBadRequest, "source may not contain path traversal segments") return } } row, err := s.store.SetWorkloadVolume(store.WorkloadVolume{ WorkloadID: id, Source: req.Source, Target: req.Target, Scope: scope, Name: req.Name, }) if err != nil { slog.Error("set workload volume", "workload", id, "target", req.Target, "error", err) respondError(w, http.StatusInternalServerError, "set workload volume") return } respondJSON(w, http.StatusOK, row) } func (s *Server) deleteWorkloadVolume(w http.ResponseWriter, r *http.Request) { volID := chi.URLParam(r, "volID") if err := s.store.DeleteWorkloadVolume(volID); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "workload volume") return } respondError(w, http.StatusInternalServerError, "delete workload volume") return } respondJSON(w, http.StatusOK, map[string]string{"deleted": volID}) }