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
19 changes: 19 additions & 0 deletions conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,25 @@ Some collections grow with an account's whole history — terminated VMs stay in

`exact` rejects members outside `required` as well as missing ones, because sparse bodies and over-full ones are both real divergences — an emulator returning half the members of a summary is the commonest of all. `minItems` guards against a target that returns an empty list and satisfies every member check vacuously.

## Side probes in the middle of a scenario

A failing step halts the rest of its scenario, because whatever the later steps assume about the target's state is no longer trustworthy. An unimplemented one halted it too, which was wrong for a step nothing downstream depends on.

`CreateMicrovmAuthToken` sits between `suspend` and `resume` in `vm-suspend-resume` for a good reason — that is the only place a live recording can observe a token issued against a suspended VM — but while it was unimplemented it took `ResumeMicrovm` off the coverage report with it, and the operation had nothing wrong with it.

`"optional": true` on a step exempts it from halting the scenario **when the target answers 501**:

```json
{
"name": "auth-token-while-suspended",
"operation": "CreateMicrovmAuthToken",
"optional": true,
...
}
```

It exempts nothing else. A step that genuinely fails still halts its scenario however it is marked. Do not mark a step that carries `capture`: the vars it would have set go missing and the failure resurfaces several steps later, a long way from its cause.

## Rejected fixtures

A fixture renamed to `<step>.json.rejected-<reason>` is a recording the suite refuses to treat as truth, because it captured the recording *account* rather than the service. They are kept rather than deleted: each one is evidence, and each cost a live session.
Expand Down
1 change: 1 addition & 0 deletions conformance/cases/40-vm-suspend-resume.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
{
"name": "auth-token-while-suspended",
"operation": "CreateMicrovmAuthToken",
"optional": true,
"method": "POST",
"path": "/2025-09-09/microvms/${microvmId}/auth-token",
"body": {
Expand Down
16 changes: 15 additions & 1 deletion conformance/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@ type Step struct {
Expect Expect `json:"expect"`
Capture map[string]string `json:"capture,omitempty"`
Until *Until `json:"until,omitempty"`

// Optional marks a step whose operation is incidental to the scenario, so
// a 501 from it does not halt the rest. Without it a side probe wedged
// mid-scenario hides every step behind it: CreateMicrovmAuthToken sits
// between suspend and resume in vm-suspend-resume purely because that is
// where a live recording could observe it, and while it was unimplemented
// it took ResumeMicrovm's coverage down with it.
//
// It exempts Unimplemented only. A step that genuinely fails still halts
// the scenario however it is marked, because a wrong answer means the
// state the later steps assume is no longer trustworthy. A step carrying
// Capture should not be optional: the vars it would have set go missing
// and the failure resurfaces later, further from its cause.
Optional bool `json:"optional,omitempty"`
}

// Until turns a step into a poll: the request repeats until the dot-path in
Expand Down Expand Up @@ -241,7 +255,7 @@ func (r *Runner) runScenario(s Scenario) []StepResult {
}
outcome, detail, fixture := r.runStep(s, st, vars)
res.Outcome, res.Detail, res.Fixture = outcome, detail, fixture
if outcome != Pass {
if outcome != Pass && !(st.Optional && outcome == Unimplemented) {
failed = true
}
results = append(results, res)
Expand Down
91 changes: 91 additions & 0 deletions conformance/runner/runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,97 @@ func TestRunAgainstStub(t *testing.T) {
}
}

// An optional step's 501 must not take the rest of the scenario with it —
// the case vm-suspend-resume is in, where CreateMicrovmAuthToken sat between
// suspend and resume and hid ResumeMicrovm's coverage behind it.
func TestOptionalUnimplementedStepDoesNotHaltScenario(t *testing.T) {
srv := stub()
defer srv.Close()
dir := t.TempDir()
writeScenario(t, dir, "30-optional.json", Scenario{
ID: "optional-probe",
Tags: []string{"documented-only"},
Steps: []Step{
{
Name: "create", Operation: "CreateMicrovmImage",
Method: "POST", Path: "/2025-09-09/microvm-images",
Body: json.RawMessage(`{"name":"img1"}`),
Expect: Expect{Status: 201},
Capture: map[string]string{"imageName": "name"},
},
{
Name: "side-probe", Operation: "CreateMicrovmAuthToken",
Method: "POST", Path: "/2025-09-09/microvms/x/auth-token",
Optional: true,
Expect: Expect{},
},
{
Name: "after-probe", Operation: "GetMicrovmImage",
Method: "GET", Path: "/2025-09-09/microvm-images/${imageName}",
Expect: Expect{Status: 200},
},
},
})

r := newTestRunner(t, srv.URL, dir, t.TempDir(), false, nil)
scenarios, err := r.LoadScenarios()
if err != nil {
t.Fatal(err)
}
got := outcomes(r.Run(scenarios))

want := map[string]Outcome{
"optional-probe/create": Pass,
"optional-probe/side-probe": Unimplemented,
"optional-probe/after-probe": Pass,
}
for k, w := range want {
if got[k] != w {
t.Errorf("%s: got %s, want %s", k, got[k], w)
}
}
}

// Optional exempts 501 and nothing else. A wrong answer means the state the
// later steps assume is no longer trustworthy, so the scenario still halts.
func TestOptionalStepStillHaltsOnFailure(t *testing.T) {
srv := stub()
defer srv.Close()
dir := t.TempDir()
writeScenario(t, dir, "31-optional-fail.json", Scenario{
ID: "optional-fail",
Tags: []string{"documented-only"},
Steps: []Step{
{
Name: "wrong-answer", Operation: "GetMicrovmImage",
Method: "GET", Path: "/2025-09-09/microvm-images/img1",
Optional: true,
// The stub answers 200 CREATED; demanding 404 is a real fail.
Expect: Expect{Status: 404},
},
{
Name: "after", Operation: "GetMicrovmImage",
Method: "GET", Path: "/2025-09-09/microvm-images/img1",
Expect: Expect{Status: 200},
},
},
})

r := newTestRunner(t, srv.URL, dir, t.TempDir(), false, nil)
scenarios, err := r.LoadScenarios()
if err != nil {
t.Fatal(err)
}
got := outcomes(r.Run(scenarios))

if got["optional-fail/wrong-answer"] != Fail {
t.Errorf("wrong-answer: got %s, want fail", got["optional-fail/wrong-answer"])
}
if got["optional-fail/after"] != Skipped {
t.Errorf("after: got %s, want skipped — an optional step that fails still halts", got["optional-fail/after"])
}
}

func TestTagFilter(t *testing.T) {
srv := stub()
defer srv.Close()
Expand Down
66 changes: 53 additions & 13 deletions internal/vms/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ func Register(srv *api.Server, svc *Service, images ImageResolver) {
srv.Register("RunMicrovm", h.run)
srv.Register("GetMicrovm", h.get)
srv.Register("ListMicrovms", h.list)
srv.Register("SuspendMicrovm", h.suspend)
srv.Register("ResumeMicrovm", h.resume)
srv.Register("TerminateMicrovm", h.terminate)
}

Expand Down Expand Up @@ -95,7 +97,7 @@ func (h *handlers) run(w http.ResponseWriter, r *http.Request) {
}

vm := h.svc.Run(region, arn, version, idle)
api.WriteJSON(w, http.StatusOK, detail(vm))
api.WriteJSON(w, http.StatusOK, detail(h.svc.Snapshot(vm)))
}

func (h *handlers) lookup(w http.ResponseWriter, r *http.Request) (*VM, bool) {
Expand All @@ -118,38 +120,76 @@ func (h *handlers) get(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
api.WriteJSON(w, http.StatusOK, detail(vm))
api.WriteJSON(w, http.StatusOK, detail(h.svc.Snapshot(vm)))
}

func (h *handlers) list(w http.ResponseWriter, r *http.Request) {
region := api.RegionFromRequest(r)
items := make([]any, 0)
for _, vm := range h.svc.List(region) {
for _, vm := range h.svc.Snapshots(region) {
items = append(items, listItem(vm))
}
api.WriteJSON(w, http.StatusOK, map[string]any{"items": items, "nextToken": nil})
}

func (h *handlers) terminate(w http.ResponseWriter, r *http.Request) {
// mutable resolves the VM and rejects the one case the recording pinned down:
// any state change on a terminated VM is a plain 400 ValidationException, not
// either modeled conflict type. Suspend, resume and terminate share it.
func (h *handlers) mutable(w http.ResponseWriter, r *http.Request) (*VM, bool) {
vm, ok := h.lookup(w, r)
if !ok {
return
return nil, false
}
if vm.Terminal() {
// The recorded shape for mutating a terminated VM: a plain 400
// ValidationException, not either modeled conflict type.
if snap := h.svc.Snapshot(vm); snap.Terminal() {
api.WriteError(w, http.StatusBadRequest, "ValidationException", map[string]any{
"message": "The MicroVM " + vm.ID + " has been terminated and its state cannot be changed.",
})
return nil, false
}
return vm, true
}

// Recorded: 200 with an empty object, not the VM. All three mutations answer
// the same way.
func writeAccepted(w http.ResponseWriter) {
api.WriteJSON(w, http.StatusOK, map[string]any{})
}

func (h *handlers) suspend(w http.ResponseWriter, r *http.Request) {
vm, ok := h.mutable(w, r)
if !ok {
return
}
h.svc.Suspend(vm)
writeAccepted(w)
}

func (h *handlers) resume(w http.ResponseWriter, r *http.Request) {
vm, ok := h.mutable(w, r)
if !ok {
return
}
h.svc.Resume(vm)
writeAccepted(w)
}

func (h *handlers) terminate(w http.ResponseWriter, r *http.Request) {
vm, ok := h.mutable(w, r)
if !ok {
return
}
h.svc.Terminate(vm)
// Recorded: 200 with an empty object, not the VM.
api.WriteJSON(w, http.StatusOK, map[string]any{})
writeAccepted(w)
}

// detail is the full VM shape, returned identically by Run and Get.
func detail(vm *VM) map[string]any {
// detail is the full VM shape, returned identically by Run and Get. It takes
// a snapshot rather than the live VM so a transition cannot change state
// halfway through building the body.
//
// The state marker is deliberately absent: it is m80's own instrumentation,
// read through the per-VM endpoint stub (#12), and putting it on a modeled
// response would be an invented member on the wire.
func detail(vm VM) map[string]any {
body := map[string]any{
"egressNetworkConnectors": []any{managedConnector(vm.Region, "INTERNET_EGRESS")},
"endpoint": vm.Endpoint,
Expand All @@ -173,7 +213,7 @@ func detail(vm *VM) map[string]any {
}

// listItem is a summary: five members, not the full detail.
func listItem(vm *VM) map[string]any {
func listItem(vm VM) map[string]any {
return map[string]any{
"imageArn": vm.ImageArn,
"imageVersion": vm.ImageVersion,
Expand Down
Loading