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

## [Unreleased]

## [0.4.0] - 2026-07-12

### Added

- `POST /v1/apps/{app}/machines/{id}/signal` — validates the signal name against
the Machines API enum (`400` on an invalid name); like stop, mudflaps does not
model real signal delivery.
- `POST /v1/apps/{app}/machines/{id}/exec` — returns a deterministic
`ExecResponse` (exit 0, stdout echoes the command); mudflaps runs no real
command.
- `GET /v1/apps/{app}/machines/{id}/ps` — returns a deterministic process list.

This clears the roadmap: every documented Machines endpoint mudflaps targets is
now implemented, so `/_mudflaps/health` reports an empty `unimplemented` list.

## [0.3.1] - 2026-07-12

### Fixed
Expand Down
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Testing a Machines-API client, such as an infrastructure-as-code applier, requir
- Machine leases with nonce, TTL, owner, and expiry; a mutation by a non-holder returns `409`, and a conflicting acquire returns the holder's lease envelope (without the nonce).
- Synchronous version churn: an update mints a new `instance_id`, marks the prior version `replaced`, and returns the new version in the response.
- `/wait` long-poll that blocks until a target state or returns `408`; the deadline is measured on the injected clock, so timeouts are deterministic in tests.
- A `/_mudflaps/health` endpoint that lists implemented and roadmap paths; unimplemented endpoints return `501`.
- A `/_mudflaps/health` endpoint that reports the implemented paths; the roadmap is currently empty — every documented endpoint mudflaps targets is built.
- Single static binary and distroless container image; no runtime dependencies.

## Quick start
Expand Down Expand Up @@ -88,18 +88,19 @@ curl -s -o /dev/null -w '%{http_code}\n' \

## API coverage

Implemented: the apps and machines endpoints (including start, stop, restart,
suspend, cordon/uncordon, and metadata), `/wait`, and the lease endpoints.
Volumes, secrets, certificates, and IP assignments return `501` and are listed
as roadmap items in the `/_mudflaps/health` payload. The full table is in the
Implemented: the apps and machines endpoints (start, stop, restart, suspend,
cordon/uncordon, metadata, signal, exec, ps), `/wait`, the lease endpoints, and
volumes, secrets (apply-only), IP assignments, and certificates. The roadmap is
empty — every documented endpoint mudflaps targets is built, so `/_mudflaps/health`
reports an empty `unimplemented` list. The full table is in the
[API coverage docs](https://intentius.github.io/mudflaps/api-coverage/).

## Roadmap

mudflaps is the local target for the `chant` fly lexicon's `flyApply` applier.
Tracking issues: [chant #736 (epic)](https://github.com/intentius/chant/issues/736)
and [chant #740](https://github.com/intentius/chant/issues/740). Volumes,
secrets, certificate, and IP endpoints are tracked under the Breadth milestone.
Tracking epic: [chant #736](https://github.com/intentius/chant/issues/736). The
Machines surface an IaC applier exercises is complete; new endpoints are staged
here as fly-go / the OpenAPI grows.

## Development

Expand Down
21 changes: 12 additions & 9 deletions docs/api-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
mudflaps implements the subset of flaps that an infrastructure-as-code applier
exercises: apps, machines (full lifecycle), metadata, wait, leases, volumes,
secrets (apply-only), IP assignments, and certificates.
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.
Every documented endpoint mudflaps targets is now implemented, so the roadmap is
empty and `/_mudflaps/health` reports an empty `unimplemented` list. Any endpoint
staged there in future answers `501 Not Implemented` with a clear JSON error
rather than a misleading success.

## Implemented

Expand All @@ -26,6 +27,9 @@ error rather than a misleading success, and they are listed under
| POST | `/v1/apps/{app}/machines/{id}/suspend` | Suspend a machine (resume via start). |
| POST | `/v1/apps/{app}/machines/{id}/cordon` | Cordon a machine. |
| POST | `/v1/apps/{app}/machines/{id}/uncordon` | Uncordon a machine. |
| POST | `/v1/apps/{app}/machines/{id}/signal` | Send a signal (validated; not delivered). |
| POST | `/v1/apps/{app}/machines/{id}/exec` | Exec — deterministic fake result (exit 0). |
| GET | `/v1/apps/{app}/machines/{id}/ps` | List processes (deterministic fake). |
| GET | `/v1/apps/{app}/machines/{id}/wait` | Block until a target state or `408`. |
| GET | `/v1/apps/{app}/machines/{id}/versions` | The machine's instance-ID history. |
| GET | `/v1/apps/{app}/machines/{id}/metadata` | Read machine metadata. |
Expand Down Expand Up @@ -55,13 +59,12 @@ error rather than a misleading success, and they are listed under
| GET | `/v1/platform/regions` | List Fly regions (a static, representative set). |
| GET | `/_mudflaps/health` | Version and coverage report (mudflaps-only). |

## Roadmap (currently `501`)
## Roadmap

| Path | Area |
| --- | --- |
| `/v1/apps/{app}/machines/{id}/signal` | Send a signal |
| `/v1/apps/{app}/machines/{id}/exec` | Exec in a machine |
| `/v1/apps/{app}/machines/{id}/ps` | List processes |
Empty — every documented Machines endpoint mudflaps targets is implemented,
including `signal`, `exec`, and `ps` (each a deterministic stateful-fake result;
mudflaps runs no real processes). New endpoints are staged here as fly-go / the
OpenAPI grows.

## Wire fidelity

Expand Down
45 changes: 45 additions & 0 deletions internal/flaps/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,51 @@ type StopMachineInput struct {
Timeout string `json:"timeout,omitempty"`
}

// SignalRequest is the body of POST .../signal. The signal names mirror the
// Machines API enum (SIGTERM, SIGKILL, …). mudflaps validates the name but does
// not model real signal delivery — like stop, it honors the request shape.
type SignalRequest struct {
Signal string `json:"signal"`
}

// MachineExecRequest is the body of POST .../exec. `cmd` is the deprecated
// single-string form; `command` is the argv form. mudflaps cannot actually run
// a command, so it returns a deterministic ExecResponse.
type MachineExecRequest struct {
Cmd string `json:"cmd,omitempty"`
Command []string `json:"command,omitempty"`
Container string `json:"container,omitempty"`
Stdin string `json:"stdin,omitempty"`
Timeout int `json:"timeout,omitempty"`
}

// ExecResponse is the result of POST .../exec (fly-go flydv1.ExecResponse).
type ExecResponse struct {
ExitCode int `json:"exit_code"`
ExitSignal int `json:"exit_signal"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
}

// ListenSocket is one socket a process listens on (part of ProcessStat).
type ListenSocket struct {
Address string `json:"address"`
Proto string `json:"proto"`
}

// ProcessStat is one row of GET .../ps. mudflaps returns a deterministic set —
// it does not run real processes.
type ProcessStat struct {
Command string `json:"command"`
CPU int `json:"cpu"`
Directory string `json:"directory"`
ListenSockets []ListenSocket `json:"listen_sockets"`
PID int `json:"pid"`
RSS int `json:"rss"`
Rtime int `json:"rtime"`
Stime int `json:"stime"`
}

// ErrorResponse is the JSON body mudflaps returns for any non-2xx status. Fly's
// flaps errors carry an "error" message; mudflaps adds a machine-readable
// status for convenience.
Expand Down
77 changes: 72 additions & 5 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,16 +70,18 @@ var implementedPaths = []string{
"POST /v1/apps/{app}/certificates",
"GET /v1/apps/{app}/certificates/{hostname}",
"DELETE /v1/apps/{app}/certificates/{hostname}",
"POST /v1/apps/{app}/machines/{id}/signal",
"POST /v1/apps/{app}/machines/{id}/exec",
"GET /v1/apps/{app}/machines/{id}/ps",
"GET /v1/platform/regions",
"GET /v1/orgs/{org_slug}/machines",
"GET /v1/orgs/{org_slug}/volumes",
}

var unimplementedPaths = []string{
"/v1/apps/{app}/machines/{id}/signal",
"/v1/apps/{app}/machines/{id}/exec",
"/v1/apps/{app}/machines/{id}/ps",
}
// unimplementedPaths is empty: every documented Machines endpoint mudflaps
// targets is now implemented. Retained so the health endpoint can keep reporting
// a (now empty) roadmap and a future endpoint can be staged here again.
var unimplementedPaths = []string{}

// Options configures a Server.
type Options struct {
Expand Down Expand Up @@ -155,6 +157,9 @@ func (s *Server) routes() {
mux.HandleFunc("POST /v1/apps/{app}/machines/{id}/suspend", s.suspendMachine)
mux.HandleFunc("POST /v1/apps/{app}/machines/{id}/cordon", s.cordonMachine)
mux.HandleFunc("POST /v1/apps/{app}/machines/{id}/uncordon", s.uncordonMachine)
mux.HandleFunc("POST /v1/apps/{app}/machines/{id}/signal", s.signalMachine)
mux.HandleFunc("POST /v1/apps/{app}/machines/{id}/exec", s.execMachine)
mux.HandleFunc("GET /v1/apps/{app}/machines/{id}/ps", s.psMachine)
mux.HandleFunc("GET /v1/apps/{app}/machines/{id}/wait", s.waitMachine)
mux.HandleFunc("GET /v1/apps/{app}/machines/{id}/versions", s.machineVersions)

Expand Down Expand Up @@ -467,6 +472,68 @@ func (s *Server) setCordon(w http.ResponseWriter, r *http.Request, cordoned bool
writeJSON(w, http.StatusOK, struct{}{})
}

// validSignals is the SignalRequest enum from the Machines API OpenAPI.
var validSignals = map[string]bool{
"SIGABRT": true, "SIGALRM": true, "SIGFPE": true, "SIGHUP": true,
"SIGILL": true, "SIGINT": true, "SIGKILL": true, "SIGPIPE": true,
"SIGQUIT": true, "SIGSEGV": true, "SIGTERM": true, "SIGTRAP": true,
"SIGUSR1": true, "SIGUSR2": true,
}

// signalMachine handles POST .../signal. It validates the signal name and that
// the machine exists; like stop, mudflaps does not model real signal delivery.
func (s *Server) signalMachine(w http.ResponseWriter, r *http.Request) {
var in flaps.SignalRequest
if !decodeJSON(w, r, &in) {
return
}
if !validSignals[in.Signal] {
s.writeError(w, http.StatusBadRequest, "invalid or missing signal")
return
}
if _, err := s.store.GetMachine(r.PathValue("app"), r.PathValue("id")); s.handleLookupError(w, err) {
return
}
writeJSON(w, http.StatusOK, struct{}{})
}

// execMachine handles POST .../exec. As a stateful fake it cannot run a command,
// so it returns a deterministic ExecResponse (exit 0) that echoes the requested
// command — enough for a client to exercise the request/response contract.
func (s *Server) execMachine(w http.ResponseWriter, r *http.Request) {
var in flaps.MachineExecRequest
if !decodeJSON(w, r, &in) {
return
}
if _, err := s.store.GetMachine(r.PathValue("app"), r.PathValue("id")); s.handleLookupError(w, err) {
return
}
cmd := in.Cmd
if cmd == "" && len(in.Command) > 0 {
cmd = strings.Join(in.Command, " ")
}
if cmd == "" {
s.writeError(w, http.StatusBadRequest, "exec requires cmd or command")
return
}
writeJSON(w, http.StatusOK, flaps.ExecResponse{
ExitCode: 0,
Stdout: cmd + "\n",
Stderr: "",
})
}

// psMachine handles GET .../ps. It returns a deterministic process list (a pid-1
// init) — mudflaps runs no real processes.
func (s *Server) psMachine(w http.ResponseWriter, r *http.Request) {
if _, err := s.store.GetMachine(r.PathValue("app"), r.PathValue("id")); s.handleLookupError(w, err) {
return
}
writeJSON(w, http.StatusOK, []flaps.ProcessStat{
{Command: "/init", PID: 1, Directory: "/", CPU: 0, RSS: 0, Rtime: 0, Stime: 0, ListenSockets: []flaps.ListenSocket{}},
})
}

// transition sets a transient state and schedules the advance to rest. It
// returns the machine's previous state and whether it succeeded; on failure it
// has already written the error response, and the caller writes the success
Expand Down
81 changes: 59 additions & 22 deletions internal/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,13 @@ func TestHealth(t *testing.T) {
if payload.Status != "ok" || payload.Version != "test" {
t.Fatalf("unexpected health payload: %+v", payload)
}
if len(payload.Implemented) == 0 || len(payload.Unimplemented) == 0 {
t.Fatalf("expected non-empty coverage lists: %+v", payload)
if len(payload.Implemented) == 0 {
t.Fatalf("expected a non-empty implemented list: %+v", payload)
}
// The roadmap is cleared — every documented endpoint mudflaps targets is now
// implemented, so the unimplemented list is empty (but still reported).
if len(payload.Unimplemented) != 0 {
t.Fatalf("expected an empty unimplemented list, got: %+v", payload.Unimplemented)
}
}

Expand Down Expand Up @@ -637,10 +642,22 @@ func TestWaitTimesOut(t *testing.T) {
}
}

func TestUnimplementedReturns501(t *testing.T) {
func TestSignalMachine(t *testing.T) {
h := newHarness(t)
if code, body := h.do(http.MethodPost, "/v1/apps/demo/machines/x/signal", nil, nil); code != http.StatusNotImplemented {
t.Fatalf("signal = %d %s, want 501", code, body)
m := h.createStartedMachine("demo")
base := "/v1/apps/demo/machines/" + m.ID

if code, body := h.do(http.MethodPost, base+"/signal", flaps.SignalRequest{Signal: "SIGTERM"}, nil); code != http.StatusOK {
t.Fatalf("signal SIGTERM = %d %s, want 200", code, body)
}
if code, _ := h.do(http.MethodPost, base+"/signal", flaps.SignalRequest{Signal: "SIGBOGUS"}, nil); code != http.StatusBadRequest {
t.Fatalf("invalid signal, want 400")
}
if code, _ := h.do(http.MethodPost, base+"/signal", flaps.SignalRequest{}, nil); code != http.StatusBadRequest {
t.Fatalf("missing signal, want 400")
}
if code, _ := h.do(http.MethodPost, "/v1/apps/demo/machines/nope/signal", flaps.SignalRequest{Signal: "SIGKILL"}, nil); code != http.StatusNotFound {
t.Fatalf("signal unknown machine, want 404")
}
}

Expand Down Expand Up @@ -669,27 +686,47 @@ func TestCordonSurfacedOnMachine(t *testing.T) {
}
}

// TestSignalExecPsReturn501 is the regression for audit M6: fly-go's
// signal/exec/ps answer an honest 501 and appear in health coverage.
func TestSignalExecPsReturn501(t *testing.T) {
// TestExecMachine covers POST .../exec: mudflaps returns a deterministic
// ExecResponse (it cannot run a real command).
func TestExecMachine(t *testing.T) {
h := newHarness(t)
m := h.createStartedMachine("demo")
base := "/v1/apps/demo/machines/" + m.ID
for _, tc := range []struct{ method, path string }{
{http.MethodPost, base + "/signal"},
{http.MethodPost, base + "/exec"},
{http.MethodGet, base + "/ps"},
} {
if code, body := h.do(tc.method, tc.path, nil, nil); code != http.StatusNotImplemented {
t.Fatalf("%s %s = %d %s, want 501", tc.method, tc.path, code, body)
}

code, body := h.do(http.MethodPost, base+"/exec", flaps.MachineExecRequest{Command: []string{"echo", "hi"}}, nil)
if code != http.StatusOK {
t.Fatalf("exec = %d %s, want 200", code, body)
}
// They must be disclosed in the health coverage list.
_, body := h.do(http.MethodGet, "/_mudflaps/health", nil, nil)
for _, want := range []string{"signal", "exec", "ps"} {
if !strings.Contains(string(body), "/"+want) {
t.Fatalf("health unimplemented missing %q: %s", want, body)
}
var res flaps.ExecResponse
h.mustJSON(body, &res)
if res.ExitCode != 0 || res.Stdout != "echo hi\n" {
t.Fatalf("exec response = %+v", res)
}
if code, _ := h.do(http.MethodPost, base+"/exec", flaps.MachineExecRequest{}, nil); code != http.StatusBadRequest {
t.Fatalf("exec with no command, want 400")
}
if code, _ := h.do(http.MethodPost, "/v1/apps/demo/machines/nope/exec", flaps.MachineExecRequest{Cmd: "ls"}, nil); code != http.StatusNotFound {
t.Fatalf("exec unknown machine, want 404")
}
}

// TestPsMachine covers GET .../ps: a deterministic process list.
func TestPsMachine(t *testing.T) {
h := newHarness(t)
m := h.createStartedMachine("demo")
base := "/v1/apps/demo/machines/" + m.ID

code, body := h.do(http.MethodGet, base+"/ps", nil, nil)
if code != http.StatusOK {
t.Fatalf("ps = %d %s, want 200", code, body)
}
var procs []flaps.ProcessStat
h.mustJSON(body, &procs)
if len(procs) == 0 || procs[0].PID != 1 {
t.Fatalf("ps response = %+v", procs)
}
if code, _ := h.do(http.MethodGet, "/v1/apps/demo/machines/nope/ps", nil, nil); code != http.StatusNotFound {
t.Fatalf("ps unknown machine, want 404")
}
}

Expand Down
Loading