From 549f98c8ed913b79f3bbfaeeaddb82ff56aa3519 Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Sun, 12 Jul 2026 11:20:54 -0600 Subject: [PATCH] =?UTF-8?q?Implement=20machines=20signal=20/=20exec=20/=20?= =?UTF-8?q?ps=20=E2=80=94=20clear=20the=20roadmap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last three roadmap endpoints move from 501 to implemented, so the /_mudflaps/health unimplemented list is now empty. - POST .../signal — validate the signal against the Machines API enum (400 on an invalid name), 404 for an unknown machine; like stop, the signal is not really delivered (SignalRequest). - POST .../exec — return a deterministic ExecResponse (exit 0, stdout echoes the command); 400 with no command, 404 for an unknown machine. mudflaps runs no real command (MachineExecRequest / ExecResponse). - GET .../ps — return a deterministic process list (a pid-1 init); 404 for an unknown machine (ProcessStat / ListenSocket). Moves the three paths from unimplementedPaths to implementedPaths (the former is now empty). Updates the health test, adds positive tests for each endpoint, and refreshes the README + api-coverage docs (which also stops overstating volumes/secrets/certs/IP as 501 — those already ship). Closes #54, closes #55, closes #56. --- CHANGELOG.md | 15 +++++++ README.md | 17 +++---- docs/api-coverage.md | 21 +++++---- internal/flaps/types.go | 45 +++++++++++++++++++ internal/server/server.go | 77 +++++++++++++++++++++++++++++--- internal/server/server_test.go | 81 +++++++++++++++++++++++++--------- 6 files changed, 212 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa7035e..690c052 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 0f63172..233c2e1 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/docs/api-coverage.md b/docs/api-coverage.md index 2319f15..c2c268a 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -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 @@ -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. | @@ -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 diff --git a/internal/flaps/types.go b/internal/flaps/types.go index 4cca483..e0736c0 100644 --- a/internal/flaps/types.go +++ b/internal/flaps/types.go @@ -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. diff --git a/internal/server/server.go b/internal/server/server.go index 0174928..88b6933 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 { @@ -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) @@ -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 diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 1565177..7f29aa4 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -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) } } @@ -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") } } @@ -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") } }