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
14 changes: 12 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.3.1] - 2026-07-12

### Fixed

- `POST /v1/apps` now requires `org_slug`, returning `400` to match real Fly,
which rejects app creation without it; the emulator was previously too lenient
and accepted the request, letting a plan that would fail against Fly pass
offline.

## [0.3.0] - 2026-07-11

Breadth: the full app-resource surface (volumes, secrets, IPs, certificates) plus
Expand Down Expand Up @@ -48,7 +57,7 @@ Fidelity and correctness pass from an adversarial audit against `superfly/fly-go
- `/wait` honors fly-go's `version` filter and a repeatable `state` param (any
requested state satisfies the wait); `instance_id` still accepted.
- `cordoned` is surfaced on the machine object (`json:"cordoned"`).
- `signal`, `exec`, and `ps` answer an honest `501` and appear in
- `signal`, `exec`, and `ps` answer a plain `501` and appear in
`/_mudflaps/health` instead of falling through to a bare `404`.
- Response shapes match flaps: create-machine returns `200`, `start` returns a
`MachineStartResponse`, and stop/restart/suspend/cordon/delete return an empty
Expand Down Expand Up @@ -87,7 +96,8 @@ Fidelity and correctness pass from an adversarial audit against `superfly/fly-go
- Distroless container image, GoReleaser configuration, mkdocs-material doc site,
and CI.

[Unreleased]: https://github.com/intentius/mudflaps/compare/v0.3.0...HEAD
[Unreleased]: https://github.com/intentius/mudflaps/compare/v0.3.1...HEAD
[0.3.1]: https://github.com/intentius/mudflaps/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/intentius/mudflaps/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/intentius/mudflaps/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/intentius/mudflaps/releases/tag/v0.1.0
4 changes: 4 additions & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,10 @@ func (s *Server) createApp(w http.ResponseWriter, r *http.Request) {
s.writeError(w, http.StatusBadRequest, "app_name is required")
return
}
if req.OrgSlug == "" {
s.writeError(w, http.StatusBadRequest, "authorization: request doesn't contain org_slug")
return
}
app, err := s.store.CreateApp(flaps.App{Name: req.AppName, Organization: req.OrgSlug})
if errors.Is(err, store.ErrAppExists) {
s.writeError(w, http.StatusConflict, "app already exists")
Expand Down
29 changes: 21 additions & 8 deletions internal/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ func (h *harness) mustJSON(raw []byte, dst any) {
// createStartedMachine creates an app+machine and drives it to started.
func (h *harness) createStartedMachine(app string) flaps.Machine {
h.t.Helper()
if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: app}, nil); code != http.StatusCreated {
if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: app, OrgSlug: "personal"}, nil); code != http.StatusCreated {
h.t.Fatalf("create app: %d %s", code, body)
}
code, body := h.do(http.MethodPost, "/v1/apps/"+app+"/machines", flaps.CreateMachineRequest{
Expand Down Expand Up @@ -132,6 +132,19 @@ func TestCreateAndWaitStarted(t *testing.T) {
}
}

// TestCreateAppRequiresOrgSlug mirrors real Fly, which rejects app creation
// without an org_slug (400) rather than silently creating the app.
func TestCreateAppRequiresOrgSlug(t *testing.T) {
h := newHarness(t)
code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: "x"}, nil)
if code != http.StatusBadRequest {
t.Fatalf("create app without org_slug = %d %s, want 400", code, body)
}
if !strings.Contains(string(body), "org_slug") {
t.Fatalf("error body = %s, want it to mention org_slug", body)
}
}

func TestLeaseBlocksMutationWithoutNonce(t *testing.T) {
h := newHarness(t)
m := h.createStartedMachine("demo")
Expand Down Expand Up @@ -460,7 +473,7 @@ func TestStopAcceptsDurationStringTimeout(t *testing.T) {
// TestSkipLaunchRestsInCreated is the regression for audit M1.
func TestSkipLaunchRestsInCreated(t *testing.T) {
h := newHarness(t)
if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: "demo"}, nil); code != http.StatusCreated {
if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: "demo", OrgSlug: "personal"}, nil); code != http.StatusCreated {
t.Fatalf("create app = %d %s", code, body)
}
code, body := h.do(http.MethodPost, "/v1/apps/demo/machines",
Expand Down Expand Up @@ -589,7 +602,7 @@ func TestDestroyAndWaitDestroyed(t *testing.T) {
// multi-second real sleep.
func TestWaitTimesOut(t *testing.T) {
h := newHarness(t)
if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: "demo"}, nil); code != http.StatusCreated {
if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: "demo", OrgSlug: "personal"}, nil); code != http.StatusCreated {
t.Fatalf("create app = %d %s", code, body)
}
// skip_launch leaves the machine in 'created', so it never reaches 'started'.
Expand Down Expand Up @@ -752,7 +765,7 @@ func TestDeleteAppWithLeasedMachine(t *testing.T) {
// data has no description field.
func TestResponseShapesMatchFlaps(t *testing.T) {
h := newHarness(t)
if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: "demo"}, nil); code != http.StatusCreated {
if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: "demo", OrgSlug: "personal"}, nil); code != http.StatusCreated {
t.Fatalf("create app = %d %s", code, body)
}
// create machine -> 200 (not 201)
Expand Down Expand Up @@ -822,7 +835,7 @@ func TestPlatformRegions(t *testing.T) {
// 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 {
if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: "demo", OrgSlug: "personal"}, nil); code != http.StatusCreated {
t.Fatalf("create app = %d %s", code, body)
}
// empty list, not 501
Expand Down Expand Up @@ -876,7 +889,7 @@ func TestVolumeCRUD(t *testing.T) {
// delete, and the invariant that a value is never returned.
func TestSecretsApplyOnly(t *testing.T) {
h := newHarness(t)
if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: "demo"}, nil); code != http.StatusCreated {
if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: "demo", OrgSlug: "personal"}, nil); code != http.StatusCreated {
t.Fatalf("create app = %d %s", code, body)
}
// set
Expand Down Expand Up @@ -919,7 +932,7 @@ func TestSecretsApplyOnly(t *testing.T) {
// TestIPAssignments covers the ip_assignments endpoints (breadth #21).
func TestIPAssignments(t *testing.T) {
h := newHarness(t)
if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: "demo"}, nil); code != http.StatusCreated {
if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: "demo", OrgSlug: "personal"}, nil); code != http.StatusCreated {
t.Fatalf("create app = %d %s", code, body)
}
// shared v4
Expand Down Expand Up @@ -957,7 +970,7 @@ func TestIPAssignments(t *testing.T) {
// TestCertificates covers the certificate endpoints (breadth #20).
func TestCertificates(t *testing.T) {
h := newHarness(t)
if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: "demo"}, nil); code != http.StatusCreated {
if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: "demo", OrgSlug: "personal"}, nil); code != http.StatusCreated {
t.Fatalf("create app = %d %s", code, body)
}
// create
Expand Down
Loading