diff --git a/README.md b/README.md index d3c641c..9e537ef 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # ironmount -mutagen sync create ~/Developer/ironmount nicolas@192.168.2.220:/home/nicolas/ironmount +mutagen sync create ~/Developer/dir/ironmount nicolas@192.168.2.42:/home/nicolas/ironmount docker run --rm -it -v nicolas:/data alpine sh -lc 'echo hello > /data/hi && cat /data/hi' diff --git a/docker-compose.yml b/docker-compose.yml index fb266d3..2567269 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,7 @@ services: - SYS_MODULE ports: - "8080:8080" + privileged: true # security_opt: # - apparmor:unconfined volumes: @@ -18,7 +19,7 @@ services: - ./:/app/ - - ./tmp:/mounts #:rshared + - /home/nicolas/ironmount/tmp:/mounts:rshared environment: - GO_ENV=development - - VOLUME_ROOT=/Users/nicolas/Developer/dir/ironmount/tmp + - VOLUME_ROOT=/home/nicolas/ironmount/tmp diff --git a/go.mod b/go.mod index 2ffbc33..eef4595 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,8 @@ require ( github.com/gin-contrib/cors v1.7.6 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/go-jet/jet/v2 v2.13.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect @@ -33,6 +35,7 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/moby/sys/mountinfo v0.7.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect @@ -56,6 +59,9 @@ require ( golang.org/x/text v0.28.0 // indirect google.golang.org/protobuf v1.36.7 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/mount-utils v0.33.4 // indirect + k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect modernc.org/libc v1.66.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index ca37692..7acec4d 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,10 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-jet/jet/v2 v2.13.0 h1:DcD2IJRGos+4X40IQRV6S6q9onoOfZY/GPdvU6ImZcQ= +github.com/go-jet/jet/v2 v2.13.0/go.mod h1:YhT75U1FoYAxFOObbQliHmXVYQeffkBKWT7ZilZ3zPc= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -67,6 +71,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= +github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -83,6 +89,7 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= @@ -149,6 +156,12 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4= gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/mount-utils v0.33.4 h1:o83Qx0AgY5JDYhFV6gAYfSy+GAPIuPqSKdEqac5lxqo= +k8s.io/mount-utils v0.33.4/go.mod h1:1JR4rKymg8B8bCPo618hpSAdrpO6XLh0Acqok/xVwPE= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= diff --git a/internal/core/utils.go b/internal/core/utils.go new file mode 100644 index 0000000..e1f0844 --- /dev/null +++ b/internal/core/utils.go @@ -0,0 +1,129 @@ +package core + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "reflect" + "strings" + + "github.com/gin-gonic/gin/binding" + "github.com/go-playground/validator/v10" +) + +type FieldError struct { + Field string `json:"field"` + Tag string `json:"tag"` + Param string `json:"param,omitempty"` + Message string `json:"message"` +} + +type ValidationError struct { + Errors []FieldError `json:"errors"` +} + +func (e *ValidationError) Error() string { + return fmt.Sprintf("validation failed (%d errors)", len(e.Errors)) +} + +// DecodeStrict decodes a JSON object from raw, rejects unknown fields, and +// validates struct tags (binding:"..."). Returns a ValidationError for +// semantic issues so callers can map it to HTTP 422. +func DecodeStrict[T any](raw json.RawMessage) (T, error) { + var out T + + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return out, &ValidationError{ + Errors: []FieldError{{ + Field: "", + Tag: "required", + Message: "config is required", + }}, + } + } + if trimmed[0] != '{' { + return out, fmt.Errorf("config must be a JSON object") + } + + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&out); err != nil { + return out, fmt.Errorf("invalid JSON: %w", err) + } + // Ensure no trailing junk after the object + if err := dec.Decode(&struct{}{}); err != io.EOF { + if err == nil { + return out, errors.New("unexpected trailing data after JSON object") + } + return out, fmt.Errorf("invalid JSON: %w", err) + } + + if binding.Validator == nil { + return out, errors.New("validator not initialized") + } + if err := binding.Validator.ValidateStruct(out); err != nil { + if verrs, ok := err.(validator.ValidationErrors); ok { + return out, toValidationError[T](verrs) + } + return out, err + } + + return out, nil +} + +func toValidationError[T any](verrs validator.ValidationErrors) *ValidationError { + errs := make([]FieldError, 0, len(verrs)) + t := reflect.TypeOf((*T)(nil)).Elem() + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + + for _, fe := range verrs { + name := fe.Field() + if t.Kind() == reflect.Struct { + if sf, ok := t.FieldByName(fe.StructField()); ok { + if tag := sf.Tag.Get("json"); tag != "" && tag != "-" { + name = strings.Split(tag, ",")[0] + } else { + // fallback to lower-camel for nicer output + name = lowerCamel(name) + } + } + } + errs = append(errs, FieldError{ + Field: name, + Tag: fe.Tag(), + Param: fe.Param(), + Message: defaultMsg(fe), + }) + } + return &ValidationError{Errors: errs} +} + +func defaultMsg(fe validator.FieldError) string { + switch fe.Tag() { + case "required": + return "" + case "min": + return fmt.Sprintf("must be at least %s", fe.Param()) + case "max": + return fmt.Sprintf("must be at most %s", fe.Param()) + case "oneof": + return "must be one of: " + fe.Param() + case "hostname", "ip": + return "must be a valid " + fe.Tag() + } + return fe.Error() +} + +func lowerCamel(s string) string { + if s == "" { + return s + } + r := []rune(s) + r[0] = []rune(strings.ToLower(string(r[0])))[0] + return string(r) +} diff --git a/internal/db/schema.go b/internal/db/schema.go index 22f52d3..e598799 100644 --- a/internal/db/schema.go +++ b/internal/db/schema.go @@ -1,12 +1,13 @@ package db import ( - "gorm.io/gorm" ) type Volume struct { gorm.Model - Name string `json:"name"` - Path string `json:"path"` + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` + Config string `json:"config"` } diff --git a/internal/modules/driver/handlers.go b/internal/modules/driver/handlers.go index cfbfde6..acfd6af 100644 --- a/internal/modules/driver/handlers.go +++ b/internal/modules/driver/handlers.go @@ -37,7 +37,10 @@ func SetupHandlers(router *gin.Engine) { return } - volume, status, err := volumeService.CreateVolume(req.Name) + volume, status, err := volumeService.CreateVolume(volumes.VolumeCreateRequest{ + Name: req.Name, + Type: volumes.VolumeBackendTypeLocal, + }) if err != nil { log.Error().Err(err).Msg("Failed to create volume") diff --git a/internal/modules/volumes/handlers.go b/internal/modules/volumes/handlers.go index a36f730..b6b8185 100644 --- a/internal/modules/volumes/handlers.go +++ b/internal/modules/volumes/handlers.go @@ -2,8 +2,6 @@ package volumes import ( - "ironmount/internal/core" - "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" ) @@ -19,22 +17,15 @@ func SetupHandlers(router *gin.Engine) { }) router.POST("/api/volumes", func(c *gin.Context) { - var req struct { - Name string `json:"name" binding:"required"` - } + var body VolumeCreateRequest - if err := c.ShouldBindJSON(&req); err != nil { + if err := c.ShouldBindJSON(&body); err != nil { + log.Error().Err(err).Msg("Failed to bind JSON for volume creation") c.JSON(400, gin.H{"error": "Invalid request body"}) return } - clean := core.Slugify(req.Name) - if clean == "" || clean != req.Name { - c.JSON(400, gin.H{"error": "invalid volume name"}) - return - } - - volume, status, err := volumeService.CreateVolume(clean) + volume, status, err := volumeService.CreateVolume(body) if err != nil { c.JSON(status, gin.H{"error": err.Error()}) return diff --git a/internal/modules/volumes/queries.go b/internal/modules/volumes/queries.go index 8b0e37d..5f97473 100644 --- a/internal/modules/volumes/queries.go +++ b/internal/modules/volumes/queries.go @@ -2,6 +2,7 @@ package volumes import ( "context" + "github.com/go-playground/validator/v10" "ironmount/internal/db" "github.com/rs/zerolog/log" @@ -27,9 +28,18 @@ func (q *VolumeQueries) QueryVolumeByName(n string) (*db.Volume, error) { return volume, nil } -func (q *VolumeQueries) InsertVolume(name, path string) error { +func (q *VolumeQueries) InsertVolume(name string, path string, volType VolumeBackendType, config string) error { ctx := context.Background() - err := gorm.G[db.Volume](db.DB).Create(ctx, &db.Volume{Name: name, Path: path}) + + validate := validator.New(validator.WithRequiredStructEnabled()) + + data := &db.Volume{} + if err := validate.Struct(data); err != nil { + log.Error().Err(err).Str("name", name).Msg("Validation error while inserting volume") + return err + } + + err := gorm.G[db.Volume](db.DB).Create(ctx, &db.Volume{}) if err != nil { return err diff --git a/internal/modules/volumes/service.go b/internal/modules/volumes/service.go index 3e2ef6a..d46756e 100644 --- a/internal/modules/volumes/service.go +++ b/internal/modules/volumes/service.go @@ -9,6 +9,9 @@ import ( "os" "path/filepath" "strings" + + "github.com/rs/zerolog/log" + "k8s.io/utils/mount" ) type VolumeService struct{} @@ -16,7 +19,12 @@ type VolumeService struct{} var volumeQueries = VolumeQueries{} // CreateVolume handles the creation of a new volume. -func (v *VolumeService) CreateVolume(name string) (*db.Volume, int, error) { +func (v *VolumeService) CreateVolume(body VolumeCreateRequest) (*db.Volume, int, error) { + name := core.Slugify(body.Name) + if name == "" || name != body.Name { + return nil, http.StatusBadRequest, fmt.Errorf("invalid volume name: %s", body.Name) + } + existingVol, _ := volumeQueries.QueryVolumeByName(name) if existingVol != nil { @@ -25,14 +33,48 @@ func (v *VolumeService) CreateVolume(name string) (*db.Volume, int, error) { cfg := core.LoadConfig() - volPathHost := filepath.Join(cfg.VolumeRootHost, name) - volPathLocal := filepath.Join(constants.VolumeRootLocal, name) + volPathHost := filepath.Join(cfg.VolumeRootHost, name, "_data") + volPathLocal := filepath.Join(constants.VolumeRootLocal, name, "_data") if err := os.MkdirAll(volPathLocal, 0755); err != nil { return nil, http.StatusInternalServerError, fmt.Errorf("failed to create volume directory: %w", err) } - if err := volumeQueries.InsertVolume(name, volPathHost); err != nil { + switch body.Type { + case VolumeBackendTypeNFS: + var cfg NFSConfig + cfg, err := core.DecodeStrict[NFSConfig](body.Config) + if err != nil { + return nil, http.StatusBadRequest, fmt.Errorf("invalid NFS configuration: %w", err) + } + + mounter := mount.New("") + source := fmt.Sprintf("%s:%s", cfg.Server, cfg.ExportPath) + options := []string{"vers=" + cfg.Version, "port=" + fmt.Sprintf("%d", cfg.Port)} + + if err := UnmountVolume(volPathLocal); err != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("failed to unmount existing volume: %w", err) + } + + if err := mounter.Mount(source, volPathLocal, "nfs", options); err != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("failed to mount NFS volume: %w", err) + } + + case VolumeBackendTypeSMB: + var _ SMBConfig + + case VolumeBackendTypeLocal: + var cfg DirectoryConfig + log.Debug().Str("directory_path", cfg.Path).Msg("Using local directory for volume") + } + + bytesConfig, err := body.Config.MarshalJSON() + if err != nil { + return nil, http.StatusBadRequest, fmt.Errorf("failed to marshal volume configuration: %w", err) + } + stringConfig := string(bytesConfig) + + if err := volumeQueries.InsertVolume(name, volPathHost, stringConfig); err != nil { if strings.Contains(err.Error(), "UNIQUE") { return nil, http.StatusConflict, fmt.Errorf("volume %s already exists", name) } @@ -79,7 +121,13 @@ func (v *VolumeService) DeleteVolume(name string) (int, error) { return http.StatusInternalServerError, fmt.Errorf("failed to remove volume from database: %w", err) } - // os.RemoveAll(vol.Path) ?? depends on whether we want to delete the actual directory + volPathLocal := filepath.Join(constants.VolumeRootLocal, name) + log.Debug().Str("volume_path", volPathLocal).Msg("Deleting volume directory") + if err := UnmountVolume(volPathLocal); err != nil { + return http.StatusInternalServerError, fmt.Errorf("failed to unmount volume: %w", err) + } + + os.RemoveAll(volPathLocal) return http.StatusOK, nil } diff --git a/internal/modules/volumes/types.go b/internal/modules/volumes/types.go index 58f07b7..17af9c7 100644 --- a/internal/modules/volumes/types.go +++ b/internal/modules/volumes/types.go @@ -1,10 +1,12 @@ package volumes -var DateFormat = "2006-01-02T15:04:05Z" +import ( + "encoding/json" + "fmt" + "strings" +) -type CreateVolumeBody struct { - Name string `json:"name" binding:"required"` -} +var DateFormat = "2006-01-02T15:04:05Z" type CreateVolumeResponse struct { Name string `json:"name"` @@ -29,3 +31,57 @@ type ListVolumesResponse struct { Volumes []VolumeInfo `json:"volumes"` Err string `json:"err,omitempty"` } + +type VolumeBackendType string + +const ( + VolumeBackendTypeSMB VolumeBackendType = "smb" + VolumeBackendTypeNFS VolumeBackendType = "nfs" + VolumeBackendTypeLocal VolumeBackendType = "local" +) + +func (vbt VolumeBackendType) String() string { + return string(vbt) +} + +func (vbt *VolumeBackendType) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return fmt.Errorf("volume backend type should be a string: %w", err) + } + + lower := strings.ToLower(s) + + switch VolumeBackendType(lower) { + case VolumeBackendTypeSMB, VolumeBackendTypeNFS, VolumeBackendTypeLocal: + *vbt = VolumeBackendType(lower) + return nil + default: + return fmt.Errorf("invalid volume backend type: '%s'. Allowed types are: smb, nfs, local", lower) + } +} + +type VolumeCreateRequest struct { + Name string `json:"name" binding:"required"` + Type VolumeBackendType `json:"type" binding:"required,oneof=nfs smb directory"` + Config json.RawMessage `json:"config" binding:"required"` +} + +type NFSConfig struct { + Server string `json:"server" binding:"required,hostname|ip"` + ExportPath string `json:"exportPath" binding:"required"` + Port int `json:"port" binding:"required,min=1,max=65535"` + Version string `json:"version" binding:"required,oneof=3 4"` +} + +type SMBConfig struct { + Server string `json:"server" binding:"required"` + Share string `json:"share" binding:"required"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + Domain string `json:"domain,omitempty"` +} + +type DirectoryConfig struct { + Path string `json:"path" binding:"required"` +} diff --git a/internal/modules/volumes/utils.go b/internal/modules/volumes/utils.go new file mode 100644 index 0000000..8c29fca --- /dev/null +++ b/internal/modules/volumes/utils.go @@ -0,0 +1,21 @@ +package volumes + +import ( + "fmt" + "strings" + + "k8s.io/utils/mount" +) + +func UnmountVolume(path string) error { + mounter := mount.New("") + if err := mounter.Unmount(path); err != nil { + if strings.Contains(err.Error(), "not mounted") || strings.Contains(err.Error(), "No such file or directory") || strings.Contains(err.Error(), "Invalid argument") { + // Volume is not mounted + return nil + } + return fmt.Errorf("failed to unmount volume at %s: %w", path, err) + } + return nil + +}