Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
8 changes: 6 additions & 2 deletions docs/api-coverage.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -32,14 +32,18 @@ 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). |

## Roadmap (currently `501`)

| Path | Area |
| --- | --- |
| `/v1/apps/{app}/volumes` | Volumes |
| `/v1/apps/{app}/secrets` | Secrets |
| `/v1/apps/{app}/certificates` | Certificates |
| `/v1/apps/{app}/ip_assignments` | IP assignments |
Expand Down
30 changes: 30 additions & 0 deletions internal/flaps/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions internal/id/id.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 11 additions & 1 deletion internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down
57 changes: 55 additions & 2 deletions internal/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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)
}
}
109 changes: 109 additions & 0 deletions internal/server/volumes.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading