582e7e39e3
- Add 'absolute' volume scope for direct host paths (NFS, external mounts) - Allowlist in settings: allowed_volume_paths (JSON array of prefixes) - Validation: absolute source must be under an allowed prefix - Empty allowlist = absolute scope disabled entirely - Settings API exposes/validates allowed_volume_paths - Frontend type updated with absolute scope
328 lines
10 KiB
Go
328 lines
10 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/alexei/docker-watcher/internal/store"
|
|
"github.com/alexei/docker-watcher/internal/volume"
|
|
)
|
|
|
|
// safeNamePattern restricts volume names to alphanumeric, dash, underscore, and dot.
|
|
var safeNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
|
|
|
|
// validateVolumePath checks that the source path does not contain path traversal.
|
|
func validateVolumePath(source string) bool {
|
|
cleaned := filepath.Clean(source)
|
|
return !strings.Contains(cleaned, "..")
|
|
}
|
|
|
|
// volumeRequest is the expected JSON body for creating/updating a volume.
|
|
type volumeRequest struct {
|
|
Source string `json:"source"`
|
|
Target string `json:"target"`
|
|
Mode string `json:"mode,omitempty"` // legacy — ignored if scope is set
|
|
Scope string `json:"scope"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// validScopes lists all accepted scope values.
|
|
var validScopes = map[string]bool{
|
|
"instance": true, "stage": true, "project": true,
|
|
"project_named": true, "named": true, "ephemeral": true,
|
|
"absolute": true,
|
|
}
|
|
|
|
// validateVolumeScope validates the scope, name, and source combination.
|
|
func validateVolumeScope(scope, name, source, allowedPathsJSON string) string {
|
|
if !validScopes[scope] {
|
|
return "scope must be one of: instance, stage, project, project_named, named, ephemeral, absolute"
|
|
}
|
|
if (scope == "project_named" || scope == "named") && strings.TrimSpace(name) == "" {
|
|
return "name is required for " + scope + " scope"
|
|
}
|
|
if name != "" && !safeNamePattern.MatchString(name) {
|
|
return "name must start with a letter or digit and contain only letters, digits, dashes, underscores, or dots"
|
|
}
|
|
if scope == "absolute" {
|
|
if source == "" {
|
|
return "source path is required for absolute scope"
|
|
}
|
|
if !filepath.IsAbs(source) {
|
|
return "absolute scope requires an absolute source path"
|
|
}
|
|
// Validate against allowlist.
|
|
allowed, err := volume.ParseAllowedPaths(allowedPathsJSON)
|
|
if err != nil {
|
|
return "failed to parse allowed volume paths"
|
|
}
|
|
if len(allowed) == 0 {
|
|
return "absolute volume paths are disabled — configure allowed paths in settings first"
|
|
}
|
|
matched := false
|
|
cleanSource := filepath.Clean(source)
|
|
for _, prefix := range allowed {
|
|
cleanPrefix := filepath.Clean(prefix)
|
|
if strings.HasPrefix(cleanSource, cleanPrefix+string(filepath.Separator)) || cleanSource == cleanPrefix {
|
|
matched = true
|
|
break
|
|
}
|
|
}
|
|
if !matched {
|
|
return "source path is not under any allowed volume path"
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// scopeDescriptions returns metadata about each scope for the UI.
|
|
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 all available scopes with descriptions for UI hints.
|
|
func (s *Server) listVolumeScopes(w http.ResponseWriter, r *http.Request) {
|
|
scopes := []scopeInfo{
|
|
{
|
|
Scope: "instance",
|
|
Description: "Each deploy gets its own isolated directory. Data is not shared between deploys.",
|
|
NeedsName: false,
|
|
PathExample: "{base}/{project}/{stage}-{tag}/{source}",
|
|
},
|
|
{
|
|
Scope: "stage",
|
|
Description: "All deploys within the same stage share this volume. Data persists across blue-green deployments.",
|
|
NeedsName: false,
|
|
PathExample: "{base}/{project}/{stage}/{source}",
|
|
},
|
|
{
|
|
Scope: "project",
|
|
Description: "Shared across all stages of the project. Good for common config or shared assets.",
|
|
NeedsName: false,
|
|
PathExample: "{base}/{project}/{source}",
|
|
},
|
|
{
|
|
Scope: "project_named",
|
|
Description: "A named volume within the project. Multiple stages can reference the same name to share data selectively.",
|
|
NeedsName: true,
|
|
PathExample: "{base}/{project}/_named/{name}/{source}",
|
|
},
|
|
{
|
|
Scope: "named",
|
|
Description: "A globally named volume shared across projects. Use for cross-project resources like 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. Good for temp files and caches.",
|
|
NeedsName: false,
|
|
PathExample: "(tmpfs — no host path)",
|
|
},
|
|
{
|
|
Scope: "absolute",
|
|
Description: "Direct host path. Must be under an allowed path configured in settings. Use for external mounts like NFS or pre-existing directories.",
|
|
NeedsName: false,
|
|
PathExample: "/mnt/nfs/data (must match allowed paths)",
|
|
},
|
|
}
|
|
respondJSON(w, http.StatusOK, scopes)
|
|
}
|
|
|
|
// listVolumes handles GET /api/projects/{id}/volumes.
|
|
func (s *Server) listVolumes(w http.ResponseWriter, r *http.Request) {
|
|
projectID := chi.URLParam(r, "id")
|
|
|
|
if _, err := s.store.GetProjectByID(projectID); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "project")
|
|
return
|
|
}
|
|
slog.Error("failed to get project", "project_id", projectID, "error", err)
|
|
respondError(w, http.StatusInternalServerError, "failed to get project")
|
|
return
|
|
}
|
|
|
|
vols, err := s.store.GetVolumesByProjectID(projectID)
|
|
if err != nil {
|
|
slog.Error("failed to list volumes", "project_id", projectID, "error", err)
|
|
respondError(w, http.StatusInternalServerError, "failed to list volumes")
|
|
return
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, vols)
|
|
}
|
|
|
|
// createVolume handles POST /api/projects/{id}/volumes.
|
|
func (s *Server) createVolume(w http.ResponseWriter, r *http.Request) {
|
|
projectID := chi.URLParam(r, "id")
|
|
|
|
if _, err := s.store.GetProjectByID(projectID); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "project")
|
|
return
|
|
}
|
|
slog.Error("failed to get project", "project_id", projectID, "error", err)
|
|
respondError(w, http.StatusInternalServerError, "failed to get project")
|
|
return
|
|
}
|
|
|
|
var req volumeRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
|
|
// Ephemeral volumes don't need a source path.
|
|
if req.Scope != "ephemeral" {
|
|
if req.Source == "" {
|
|
respondError(w, http.StatusBadRequest, "source is required")
|
|
return
|
|
}
|
|
if !validateVolumePath(req.Source) {
|
|
respondError(w, http.StatusBadRequest, "source path must not contain '..'")
|
|
return
|
|
}
|
|
}
|
|
if req.Target == "" {
|
|
respondError(w, http.StatusBadRequest, "target is required")
|
|
return
|
|
}
|
|
if !validateVolumePath(req.Target) {
|
|
respondError(w, http.StatusBadRequest, "target path must not contain '..'")
|
|
return
|
|
}
|
|
|
|
// Resolve scope — support legacy mode field.
|
|
scope := req.Scope
|
|
if scope == "" {
|
|
switch req.Mode {
|
|
case "isolated":
|
|
scope = "instance"
|
|
case "shared":
|
|
scope = "project"
|
|
default:
|
|
scope = "project"
|
|
}
|
|
}
|
|
|
|
// Fetch settings for absolute path allowlist validation.
|
|
settings, err := s.store.GetSettings()
|
|
if err != nil {
|
|
slog.Error("failed to get settings for volume validation", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "failed to validate volume")
|
|
return
|
|
}
|
|
|
|
if errMsg := validateVolumeScope(scope, req.Name, req.Source, settings.AllowedVolumePaths); errMsg != "" {
|
|
respondError(w, http.StatusBadRequest, errMsg)
|
|
return
|
|
}
|
|
|
|
vol, err := s.store.CreateVolume(store.Volume{
|
|
ProjectID: projectID,
|
|
Source: req.Source,
|
|
Target: req.Target,
|
|
Scope: scope,
|
|
Name: strings.TrimSpace(req.Name),
|
|
})
|
|
if err != nil {
|
|
slog.Error("failed to create volume", "project_id", projectID, "error", err)
|
|
respondError(w, http.StatusInternalServerError, "failed to create volume")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusCreated, vol)
|
|
}
|
|
|
|
// updateVolume handles PUT /api/projects/{id}/volumes/{volId}.
|
|
func (s *Server) updateVolume(w http.ResponseWriter, r *http.Request) {
|
|
volID := chi.URLParam(r, "volId")
|
|
|
|
existing, err := s.store.GetVolumeByID(volID)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "volume")
|
|
return
|
|
}
|
|
slog.Error("failed to get volume", "volume_id", volID, "error", err)
|
|
respondError(w, http.StatusInternalServerError, "failed to get volume")
|
|
return
|
|
}
|
|
|
|
var req volumeRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
|
|
updated := existing
|
|
if req.Source != "" {
|
|
if !validateVolumePath(req.Source) {
|
|
respondError(w, http.StatusBadRequest, "source path must not contain '..'")
|
|
return
|
|
}
|
|
updated.Source = req.Source
|
|
}
|
|
if req.Target != "" {
|
|
if !validateVolumePath(req.Target) {
|
|
respondError(w, http.StatusBadRequest, "target path must not contain '..'")
|
|
return
|
|
}
|
|
updated.Target = req.Target
|
|
}
|
|
if req.Scope != "" {
|
|
settings, err := s.store.GetSettings()
|
|
if err != nil {
|
|
slog.Error("failed to get settings for volume validation", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "failed to validate volume")
|
|
return
|
|
}
|
|
source := updated.Source
|
|
if req.Source != "" {
|
|
source = req.Source
|
|
}
|
|
if errMsg := validateVolumeScope(req.Scope, req.Name, source, settings.AllowedVolumePaths); errMsg != "" {
|
|
respondError(w, http.StatusBadRequest, errMsg)
|
|
return
|
|
}
|
|
updated.Scope = req.Scope
|
|
updated.Name = strings.TrimSpace(req.Name)
|
|
}
|
|
|
|
// Non-ephemeral scopes require a source path.
|
|
if updated.Scope != "ephemeral" && updated.Source == "" {
|
|
respondError(w, http.StatusBadRequest, "source is required for non-ephemeral scopes")
|
|
return
|
|
}
|
|
|
|
if err := s.store.UpdateVolume(updated); err != nil {
|
|
slog.Error("failed to update volume", "volume_id", volID, "error", err)
|
|
respondError(w, http.StatusInternalServerError, "failed to update volume")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, updated)
|
|
}
|
|
|
|
// deleteVolume handles DELETE /api/projects/{id}/volumes/{volId}.
|
|
func (s *Server) deleteVolume(w http.ResponseWriter, r *http.Request) {
|
|
volID := chi.URLParam(r, "volId")
|
|
if err := s.store.DeleteVolume(volID); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "volume")
|
|
return
|
|
}
|
|
slog.Error("failed to delete volume", "volume_id", volID, "error", err)
|
|
respondError(w, http.StatusInternalServerError, "failed to delete volume")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, map[string]string{"deleted": volID})
|
|
}
|