From eea0c93a90c010ae512f3e666baa61563717afc3 Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:43:22 -0600 Subject: [PATCH] Implement volume endpoints (breadth #18) Adds a volume resource: store CRUD (per-app, deep-copied), Volume/CreateVolume/ UpdateVolume types mirroring fly-go, and GET/POST /v1/apps/{app}/volumes plus GET/PUT/DELETE /v1/apps/{app}/volumes/{vol}. Moved volumes from the 501 roadmap into health coverage and the api-coverage docs. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 + docs/api-coverage.md | 8 ++- internal/flaps/types.go | 30 +++++++++ internal/id/id.go | 8 +++ internal/server/server.go | 12 +++- internal/server/server_test.go | 57 ++++++++++++++++- internal/server/volumes.go | 109 +++++++++++++++++++++++++++++++++ internal/store/store.go | 98 ++++++++++++++++++++++++++++- 8 files changed, 318 insertions(+), 6 deletions(-) create mode 100644 internal/server/volumes.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 84c3ff0..fe34636 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- Volume endpoints (`GET`/`POST /v1/apps/{app}/volumes`, `GET`/`PUT`/`DELETE + /v1/apps/{app}/volumes/{vol}`). - `GET /v1/platform/regions` returns a static, representative list of Fly regions (unblocks region validation for clients). diff --git a/docs/api-coverage.md b/docs/api-coverage.md index ea0a56e..7edb69c 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -1,7 +1,7 @@ # API coverage mudflaps implements the subset of flaps that an infrastructure-as-code applier -exercises: apps, machines (full lifecycle), metadata, wait, and leases. +exercises: apps, machines (full lifecycle), metadata, wait, leases, and volumes. Endpoints that are not yet built answer `501 Not Implemented` with a clear JSON error rather than a misleading success, and they are listed under `unimplemented` in the `/_mudflaps/health` payload. @@ -32,6 +32,11 @@ error rather than a misleading success, and they are listed under | GET | `/v1/apps/{app}/machines/{id}/lease` | Read the active lease. | | POST | `/v1/apps/{app}/machines/{id}/lease` | Acquire or refresh a lease. | | DELETE | `/v1/apps/{app}/machines/{id}/lease` | Release a lease. | +| GET | `/v1/apps/{app}/volumes` | List volumes. | +| POST | `/v1/apps/{app}/volumes` | Create a volume. | +| GET | `/v1/apps/{app}/volumes/{vol}` | Get a volume. | +| PUT | `/v1/apps/{app}/volumes/{vol}` | Update a volume. | +| DELETE | `/v1/apps/{app}/volumes/{vol}` | Delete a volume. | | GET | `/v1/platform/regions` | List Fly regions (a static, representative set). | | GET | `/_mudflaps/health` | Version and coverage report (mudflaps-only). | @@ -39,7 +44,6 @@ error rather than a misleading success, and they are listed under | Path | Area | | --- | --- | -| `/v1/apps/{app}/volumes` | Volumes | | `/v1/apps/{app}/secrets` | Secrets | | `/v1/apps/{app}/certificates` | Certificates | | `/v1/apps/{app}/ip_assignments` | IP assignments | diff --git a/internal/flaps/types.go b/internal/flaps/types.go index a7a40a9..6e10d4a 100644 --- a/internal/flaps/types.go +++ b/internal/flaps/types.go @@ -185,6 +185,36 @@ type MachineStartResponse struct { PreviousState string `json:"previous_state,omitempty"` } +// Volume is a Fly volume. Field names mirror fly-go's Volume; created_at is a +// string here (marshals to the same RFC3339 a time.Time would). +type Volume struct { + ID string `json:"id"` + Name string `json:"name"` + State string `json:"state"` + SizeGb int `json:"size_gb"` + Region string `json:"region"` + Zone string `json:"zone"` + Encrypted bool `json:"encrypted"` + AttachedMachine *string `json:"attached_machine_id"` + SnapshotRetention int `json:"snapshot_retention,omitempty"` + AutoBackupEnabled bool `json:"auto_backup_enabled,omitempty"` + CreatedAt string `json:"created_at"` +} + +// CreateVolumeRequest is the body of POST .../volumes. +type CreateVolumeRequest struct { + Name string `json:"name"` + Region string `json:"region"` + SizeGb *int `json:"size_gb"` + Encrypted *bool `json:"encrypted"` +} + +// UpdateVolumeRequest is the body of PUT .../volumes/{vol}. +type UpdateVolumeRequest struct { + SnapshotRetention *int `json:"snapshot_retention"` + AutoBackupEnabled *bool `json:"auto_backup_enabled"` +} + // Region is a Fly region. The capitalized JSON tags on RegionData match fly-go's // GetRegions response exactly (they are the wire contract). type Region struct { diff --git a/internal/id/id.go b/internal/id/id.go index 60c1484..c006b82 100644 --- a/internal/id/id.go +++ b/internal/id/id.go @@ -32,6 +32,14 @@ func Instance() string { return string(out) } +// Volume returns a "vol_"-prefixed identifier, matching the shape of Fly volume +// IDs (for example "vol_0abc12de34fg56hi"). +func Volume() string { + b := make([]byte, 8) + mustRead(b) + return "vol_" + hex.EncodeToString(b) +} + // Nonce returns a random hex string suitable for use as a lease nonce. func Nonce() string { b := make([]byte, 16) diff --git a/internal/server/server.go b/internal/server/server.go index 96737bb..f7864c3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -53,11 +53,15 @@ var implementedPaths = []string{ "GET /v1/apps/{app}/machines/{id}/lease", "POST /v1/apps/{app}/machines/{id}/lease", "DELETE /v1/apps/{app}/machines/{id}/lease", + "GET /v1/apps/{app}/volumes", + "POST /v1/apps/{app}/volumes", + "GET /v1/apps/{app}/volumes/{vol}", + "PUT /v1/apps/{app}/volumes/{vol}", + "DELETE /v1/apps/{app}/volumes/{vol}", "GET /v1/platform/regions", } var unimplementedPaths = []string{ - "/v1/apps/{app}/volumes", "/v1/apps/{app}/secrets", "/v1/apps/{app}/certificates", "/v1/apps/{app}/ip_assignments", @@ -150,6 +154,12 @@ func (s *Server) routes() { mux.HandleFunc("POST /v1/apps/{app}/machines/{id}/lease", s.acquireLease) mux.HandleFunc("DELETE /v1/apps/{app}/machines/{id}/lease", s.releaseLease) + mux.HandleFunc("GET /v1/apps/{app}/volumes", s.listVolumes) + mux.HandleFunc("POST /v1/apps/{app}/volumes", s.createVolume) + mux.HandleFunc("GET /v1/apps/{app}/volumes/{vol}", s.getVolume) + mux.HandleFunc("PUT /v1/apps/{app}/volumes/{vol}", s.updateVolume) + mux.HandleFunc("DELETE /v1/apps/{app}/volumes/{vol}", s.deleteVolume) + mux.HandleFunc("GET /v1/platform/regions", s.platformRegions) mux.HandleFunc("GET /_mudflaps/health", s.health) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 103810b..f68fce5 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -626,8 +626,8 @@ func TestWaitTimesOut(t *testing.T) { func TestUnimplementedReturns501(t *testing.T) { h := newHarness(t) - if code, body := h.do(http.MethodGet, "/v1/apps/demo/volumes", nil, nil); code != http.StatusNotImplemented { - t.Fatalf("volumes = %d %s, want 501", code, body) + if code, body := h.do(http.MethodGet, "/v1/apps/demo/secrets", nil, nil); code != http.StatusNotImplemented { + t.Fatalf("secrets = %d %s, want 501", code, body) } } @@ -818,3 +818,56 @@ func TestPlatformRegions(t *testing.T) { t.Fatalf("response must use the capital-R Regions tag: %s", body[:80]) } } + +// TestVolumeCRUD covers the volume endpoints (breadth #18). +func TestVolumeCRUD(t *testing.T) { + h := newHarness(t) + if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: "demo"}, nil); code != http.StatusCreated { + t.Fatalf("create app = %d %s", code, body) + } + // empty list, not 501 + code, body := h.do(http.MethodGet, "/v1/apps/demo/volumes", nil, nil) + if code != http.StatusOK { + t.Fatalf("list volumes = %d %s, want 200", code, body) + } + var vols []flaps.Volume + h.mustJSON(body, &vols) + if len(vols) != 0 { + t.Fatalf("expected empty volume list, got %d", len(vols)) + } + // create + size := 3 + code, body = h.do(http.MethodPost, "/v1/apps/demo/volumes", + flaps.CreateVolumeRequest{Name: "data", Region: "iad", SizeGb: &size}, nil) + if code != http.StatusOK { + t.Fatalf("create volume = %d %s", code, body) + } + var v flaps.Volume + h.mustJSON(body, &v) + if v.ID == "" || v.Name != "data" || v.SizeGb != 3 || v.Region != "iad" || v.State != "created" { + t.Fatalf("unexpected created volume: %+v", v) + } + // get + if code, body := h.do(http.MethodGet, "/v1/apps/demo/volumes/"+v.ID, nil, nil); code != http.StatusOK { + t.Fatalf("get volume = %d %s", code, body) + } + // list shows it + _, body = h.do(http.MethodGet, "/v1/apps/demo/volumes", nil, nil) + h.mustJSON(body, &vols) + if len(vols) != 1 { + t.Fatalf("expected 1 volume, got %d", len(vols)) + } + // update + ab := true + if code, body := h.do(http.MethodPut, "/v1/apps/demo/volumes/"+v.ID, + flaps.UpdateVolumeRequest{AutoBackupEnabled: &ab}, nil); code != http.StatusOK { + t.Fatalf("update volume = %d %s", code, body) + } + // delete + if code, body := h.do(http.MethodDelete, "/v1/apps/demo/volumes/"+v.ID, nil, nil); code != http.StatusOK { + t.Fatalf("delete volume = %d %s", code, body) + } + if code, _ := h.do(http.MethodGet, "/v1/apps/demo/volumes/"+v.ID, nil, nil); code != http.StatusNotFound { + t.Fatalf("get after delete = %d, want 404", code) + } +} diff --git a/internal/server/volumes.go b/internal/server/volumes.go new file mode 100644 index 0000000..0cbfa92 --- /dev/null +++ b/internal/server/volumes.go @@ -0,0 +1,109 @@ +package server + +import ( + "errors" + "net/http" + "time" + + "github.com/intentius/mudflaps/internal/flaps" + "github.com/intentius/mudflaps/internal/id" + "github.com/intentius/mudflaps/internal/store" +) + +func (s *Server) listVolumes(w http.ResponseWriter, r *http.Request) { + vols, err := s.store.ListVolumes(r.PathValue("app")) + if errors.Is(err, store.ErrAppNotFound) { + s.writeError(w, http.StatusNotFound, "app not found") + return + } + writeJSON(w, http.StatusOK, vols) +} + +func (s *Server) createVolume(w http.ResponseWriter, r *http.Request) { + app := r.PathValue("app") + if _, err := s.store.GetApp(app); errors.Is(err, store.ErrAppNotFound) { + s.writeError(w, http.StatusNotFound, "app not found") + return + } + var req flaps.CreateVolumeRequest + if !decodeJSON(w, r, &req) { + return + } + if req.Name == "" { + s.writeError(w, http.StatusBadRequest, "name is required") + return + } + size := 1 + if req.SizeGb != nil { + size = *req.SizeGb + } + v := flaps.Volume{ + ID: id.Volume(), + Name: req.Name, + State: "created", + SizeGb: size, + Region: defaultString(req.Region, "local"), + Zone: "local-1", + Encrypted: req.Encrypted == nil || *req.Encrypted, // encrypted by default, like Fly + CreatedAt: s.clk.Now().UTC().Format(time.RFC3339Nano), + } + created, err := s.store.CreateVolume(app, v) + if err != nil { + s.writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, created) +} + +func (s *Server) getVolume(w http.ResponseWriter, r *http.Request) { + v, err := s.store.GetVolume(r.PathValue("app"), r.PathValue("vol")) + if s.handleVolumeLookup(w, err) { + return + } + writeJSON(w, http.StatusOK, v) +} + +func (s *Server) updateVolume(w http.ResponseWriter, r *http.Request) { + app, vol := r.PathValue("app"), r.PathValue("vol") + var req flaps.UpdateVolumeRequest + if r.ContentLength != 0 && !decodeJSON(w, r, &req) { + return + } + updated, err := s.store.UpdateVolume(app, vol, func(v *flaps.Volume) error { + if req.SnapshotRetention != nil { + v.SnapshotRetention = *req.SnapshotRetention + } + if req.AutoBackupEnabled != nil { + v.AutoBackupEnabled = *req.AutoBackupEnabled + } + return nil + }) + if s.handleVolumeLookup(w, err) { + return + } + writeJSON(w, http.StatusOK, updated) +} + +func (s *Server) deleteVolume(w http.ResponseWriter, r *http.Request) { + deleted, err := s.store.DeleteVolume(r.PathValue("app"), r.PathValue("vol")) + if s.handleVolumeLookup(w, err) { + return + } + writeJSON(w, http.StatusOK, deleted) +} + +// handleVolumeLookup writes a 404 for app/volume not-found and reports whether +// it wrote a response. +func (s *Server) handleVolumeLookup(w http.ResponseWriter, err error) bool { + switch { + case err == nil: + return false + case errors.Is(err, store.ErrAppNotFound): + s.writeError(w, http.StatusNotFound, "app not found") + case errors.Is(err, store.ErrVolumeNotFound): + s.writeError(w, http.StatusNotFound, "volume not found") + default: + s.writeError(w, http.StatusInternalServerError, err.Error()) + } + return true +} diff --git a/internal/store/store.go b/internal/store/store.go index c78f1f3..954c487 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -15,6 +15,7 @@ var ( ErrAppNotFound = errors.New("app not found") ErrAppExists = errors.New("app already exists") ErrMachineNotFound = errors.New("machine not found") + ErrVolumeNotFound = errors.New("volume not found") ) // Store holds apps and their machines. @@ -26,6 +27,7 @@ type Store struct { type appEntry struct { app flaps.App machines map[string]*flaps.Machine + volumes map[string]*flaps.Volume } // New returns an empty store. @@ -43,7 +45,11 @@ func (s *Store) CreateApp(app flaps.App) (flaps.App, error) { if app.Status == "" { app.Status = "deployed" } - s.apps[app.Name] = &appEntry{app: app, machines: make(map[string]*flaps.Machine)} + s.apps[app.Name] = &appEntry{ + app: app, + machines: make(map[string]*flaps.Machine), + volumes: make(map[string]*flaps.Volume), + } return app, nil } @@ -228,3 +234,93 @@ func cloneStringMap(in map[string]string) map[string]string { } return out } + +// ---- volumes ---- + +// CreateVolume stores a volume under an app, keyed by its ID. +func (s *Store) CreateVolume(app string, v flaps.Volume) (flaps.Volume, error) { + s.mu.Lock() + defer s.mu.Unlock() + e, ok := s.apps[app] + if !ok { + return flaps.Volume{}, ErrAppNotFound + } + stored := cloneVolume(&v) + e.volumes[v.ID] = stored + return *cloneVolume(stored), nil +} + +// GetVolume returns a copy of a volume. +func (s *Store) GetVolume(app, id string) (flaps.Volume, error) { + s.mu.RLock() + defer s.mu.RUnlock() + v, err := s.lookupVolume(app, id) + if err != nil { + return flaps.Volume{}, err + } + return *cloneVolume(v), nil +} + +// ListVolumes returns copies of every volume under an app. +func (s *Store) ListVolumes(app string) ([]flaps.Volume, error) { + s.mu.RLock() + defer s.mu.RUnlock() + e, ok := s.apps[app] + if !ok { + return nil, ErrAppNotFound + } + out := make([]flaps.Volume, 0, len(e.volumes)) + for _, v := range e.volumes { + out = append(out, *cloneVolume(v)) + } + return out, nil +} + +// UpdateVolume applies fn to a stored volume and returns a copy. +func (s *Store) UpdateVolume(app, id string, fn func(v *flaps.Volume) error) (flaps.Volume, error) { + s.mu.Lock() + defer s.mu.Unlock() + v, err := s.lookupVolume(app, id) + if err != nil { + return flaps.Volume{}, err + } + if err := fn(v); err != nil { + return flaps.Volume{}, err + } + return *cloneVolume(v), nil +} + +// DeleteVolume removes a volume and returns a copy of what was removed. +func (s *Store) DeleteVolume(app, id string) (flaps.Volume, error) { + s.mu.Lock() + defer s.mu.Unlock() + v, err := s.lookupVolume(app, id) + if err != nil { + return flaps.Volume{}, err + } + out := *cloneVolume(v) + delete(s.apps[app].volumes, id) + return out, nil +} + +// lookupVolume finds a volume without locking; callers must hold s.mu. +func (s *Store) lookupVolume(app, id string) (*flaps.Volume, error) { + e, ok := s.apps[app] + if !ok { + return nil, ErrAppNotFound + } + v, ok := e.volumes[id] + if !ok { + return nil, ErrVolumeNotFound + } + return v, nil +} + +func cloneVolume(v *flaps.Volume) *flaps.Volume { + c := *v + if v.AttachedMachine != nil { + am := *v.AttachedMachine + c.AttachedMachine = &am + } + return &c +}