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"` } 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}) }