instancectl: POST /cmd and the GET /cmd/{host} long-poll (run spec §5.4, codex 4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+393
−1
internal/instancectl/claim_test.go+1 −1internal/instancectl/client.go+21 −0internal/instancectl/cmd.go+138 −0internal/instancectl/cmd_test.go+205 −0internal/instancectl/names.go+19 −0internal/instancectl/server.go+9 −0
diff --git a/internal/instancectl/claim_test.go b/internal/instancectl/claim_test.go| index d106f53c8..be54b9562 100644 |
| --- a/internal/instancectl/claim_test.go |
| +++ b/internal/instancectl/claim_test.go |
| @@ -11,7 +11,7 @@ import ( |
| func TestClaimRootRefusesLegacyProcessMutations(t *testing.T) { |
| called := false |
| server := &Server{ClaimStop: http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), Systemctl: func(context.Context, ...string) (string, error) { called = true; return "", nil }} |
| - for _, verb := range []string{"start", "seed", "stop", "reset", "sweep"} { |
| + for _, verb := range []string{"start", "seed", "stop", "reset", "sweep", "cmd"} { |
| response := httptest.NewRecorder() |
| server.Handler().ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/"+verb+"?host=app.example&role=run&memory_max=536870912", strings.NewReader(""))) |
| if response.Code != http.StatusServiceUnavailable { |
diff --git a/internal/instancectl/client.go b/internal/instancectl/client.go| index cae0ca4cb..444b9e941 100644 |
| --- a/internal/instancectl/client.go |
| +++ b/internal/instancectl/client.go |
| @@ -25,6 +25,9 @@ const ( |
| DeadlineSeed = 150 * time.Second |
| DeadlineSweep = 60 * time.Second |
| DeadlineReady = 5 * time.Second |
| + |
| + // DeadlineCmdPoll bounds GET /cmd/{host}; the server answers within 30 s. |
| + DeadlineCmdPoll = 35 * time.Second |
| ) |
| // Client is the agent's side of the control channel. It is plain HTTP over |
| @@ -165,6 +168,24 @@ func (c *Client) Stop(ctx context.Context, host string, role Role) error { |
| return err |
| } |
| +// Cmd starts host's cmd unit and returns its invocation (run spec §5.4). |
| +func (c *Client) Cmd(ctx context.Context, host string, memoryMax int64) (string, error) { |
| + q := hostRole(host, "") |
| + q.Set("memory_max", strconv.FormatInt(memoryMax, 10)) |
| + var out StartResponse |
| + if _, _, err := c.do(ctx, DeadlineControl, http.MethodPost, "/cmd", q, &out); err != nil { |
| + return "", err |
| + } |
| + return out.InvocationID, nil |
| +} |
| + |
| +// CmdStatus long-polls the named invocation of host's cmd unit. |
| +func (c *Client) CmdStatus(ctx context.Context, host, invocation string) (CmdStatus, error) { |
| + var out CmdStatus |
| + _, _, err := c.do(ctx, DeadlineCmdPoll, http.MethodGet, "/cmd/"+url.PathEscape(host), url.Values{"invocation": {invocation}}, &out) |
| + return out, err |
| +} |
| + |
| func (c *Client) Reset(ctx context.Context, host string, role Role) error { |
| _, _, err := c.do(ctx, DeadlineControl, http.MethodPost, "/reset", hostRole(host, role), nil) |
| return err |
diff --git a/internal/instancectl/cmd.go b/internal/instancectl/cmd.go| new file mode 100644 |
| index 000000000..5e3294ee2 |
| --- /dev/null |
| +++ b/internal/instancectl/cmd.go |
| @@ -0,0 +1,138 @@ |
| +package instancectl |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "net/http" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +func (s *Server) cmdPoll() time.Duration { |
| + if s.cmdPollEvery > 0 { |
| + return s.cmdPollEvery |
| + } |
| + return 250 * time.Millisecond |
| +} |
| + |
| +func (s *Server) cmdWait() time.Duration { |
| + if s.cmdWaitBound > 0 { |
| + return s.cmdWaitBound |
| + } |
| + return 30 * time.Second |
| +} |
| + |
| +func (s *Server) cmdStartWait() time.Duration { |
| + if s.cmdStartBound > 0 { |
| + return s.cmdStartBound |
| + } |
| + return 10 * time.Second |
| +} |
| + |
| +// handleCmd starts host's cmd unit and answers at once with the new |
| +// invocation (run spec §5.4); /seed blocks, this never does. The agent |
| +// stops a cmd unit after every run, so a unit still loaded (active, |
| +// activating, deactivating) belongs to a run this request must not end: it |
| +// is refused, never restarted. A failed one is reset first, or start would |
| +// keep its old result. |
| +func (s *Server) handleCmd(w http.ResponseWriter, r *http.Request) { |
| + req, err := parseRequest(r, nil, true) |
| + if err != nil { |
| + http.Error(w, err.Error(), http.StatusBadRequest) |
| + return |
| + } |
| + ctx := r.Context() |
| + unit := UnitName(RoleCmd, req.host) |
| + switch state := s.showValue(ctx, "ActiveState", unit); state { |
| + case "active", "activating", "deactivating", "reloading", "refreshing": |
| + http.Error(w, "cmd: "+unit+" is "+state, http.StatusConflict) |
| + return |
| + case "failed": |
| + if out, err := s.Systemctl(ctx, "reset-failed", unit); err != nil { |
| + http.Error(w, "reset-failed: "+strings.TrimSpace(out+" "+err.Error()), http.StatusBadGateway) |
| + return |
| + } |
| + } |
| + if s.refuseStart(w, req.host, unit) { |
| + return |
| + } |
| + if err := s.setTier(ctx, req.host, req.tier); err != nil { |
| + http.Error(w, "set-property: "+err.Error(), http.StatusBadGateway) |
| + return |
| + } |
| + before := s.showValue(ctx, "InvocationID", unit) |
| + if out, err := s.Systemctl(ctx, "start", "--no-block", unit); err != nil { |
| + http.Error(w, "start: "+strings.TrimSpace(out+" "+err.Error()), http.StatusBadGateway) |
| + return |
| + } |
| + // --no-block returns once the job is queued; the invocation exists |
| + // once the job runs. |
| + deadline := time.Now().Add(s.cmdStartWait()) |
| + for { |
| + if inv := s.showValue(ctx, "InvocationID", unit); inv != "" && inv != before { |
| + s.logf("instance-control: %s: started %s memory.max=%d", req.host, unit, req.tier.MemoryMax) |
| + writeJSON(w, http.StatusOK, StartResponse{InvocationID: inv}) |
| + return |
| + } |
| + if time.Now().After(deadline) || ctx.Err() != nil { |
| + dctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) |
| + s.stopUnit(dctx, unit) |
| + cancel() |
| + http.Error(w, "cmd: "+unit+" showed no new invocation", http.StatusBadGateway) |
| + return |
| + } |
| + time.Sleep(s.cmdPoll()) |
| + } |
| +} |
| + |
| +// handleCmdStatus is the bounded long-poll: it answers as soon as the named |
| +// invocation is done or gone, and running at the bound. |
| +func (s *Server) handleCmdStatus(w http.ResponseWriter, r *http.Request) { |
| + host := r.PathValue("host") |
| + if !provision.ValidHostShape(host) { |
| + http.Error(w, fmt.Sprintf("host %q is not a host", host), http.StatusBadRequest) |
| + return |
| + } |
| + want := r.URL.Query().Get("invocation") |
| + if want == "" { |
| + http.Error(w, "invocation is required", http.StatusBadRequest) |
| + return |
| + } |
| + ctx := r.Context() |
| + unit := UnitName(RoleCmd, host) |
| + deadline := time.Now().Add(s.cmdWait()) |
| + for { |
| + out, err := s.Systemctl(ctx, "show", "-p", "InvocationID,ActiveState,SubState,Result,ExecMainStatus,ExecMainCode", unit) |
| + if err != nil { |
| + http.Error(w, "show: "+err.Error(), http.StatusBadGateway) |
| + return |
| + } |
| + st := cmdStatusOf(parseShow(out), want) |
| + if st.State != CmdRunning || !time.Now().Before(deadline) { |
| + writeJSON(w, http.StatusOK, st) |
| + return |
| + } |
| + select { |
| + case <-ctx.Done(): |
| + return |
| + case <-time.After(s.cmdPoll()): |
| + } |
| + } |
| +} |
| + |
| +// cmdStatusOf reads one show for the named invocation. |
| +func cmdStatusOf(m map[string]string, want string) CmdStatus { |
| + st := CmdStatus{InvocationID: m["InvocationID"], Result: m["Result"], Status: m["ExecMainStatus"], Code: m["ExecMainCode"]} |
| + active, sub := m["ActiveState"], m["SubState"] |
| + switch { |
| + case st.InvocationID != want: |
| + st.State = CmdGone |
| + case active == "failed", active == "inactive", active == "active" && sub == "exited": |
| + st.State = CmdDone |
| + default: |
| + st.State = CmdRunning |
| + } |
| + return st |
| +} |
diff --git a/internal/instancectl/cmd_test.go b/internal/instancectl/cmd_test.go| new file mode 100644 |
| index 000000000..790b86c94 |
| --- /dev/null |
| +++ b/internal/instancectl/cmd_test.go |
| @@ -0,0 +1,205 @@ |
| +package instancectl |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// set changes one answer under the fake's lock, for a test whose unit |
| +// moves on while a handler polls. |
| +func (f *fakeCtl) set(prefix, out string) { |
| + f.mu.Lock() |
| + f.answers[prefix] = out |
| + f.mu.Unlock() |
| +} |
| + |
| +const cmdShowAll = "show -p InvocationID,ActiveState,SubState,Result,ExecMainStatus,ExecMainCode" |
| + |
| +func fastCmd(s *Server) { |
| + s.cmdPollEvery = 5 * time.Millisecond |
| + s.cmdWaitBound = 300 * time.Millisecond |
| + s.cmdStartBound = 300 * time.Millisecond |
| +} |
| + |
| +func TestCmdStartsWithoutBlockingAndAnswersTheNewInvocation(t *testing.T) { |
| + ctl := &fakeCtl{answers: map[string]string{ |
| + "show -p ActiveState --value": "inactive\n", |
| + "show -p InvocationID --value": "old\n", |
| + }} |
| + ctl.onStart = func() { ctl.set("show -p InvocationID --value", "new\n") } |
| + _, c := newServer(t, ctl, fastCmd) |
| + resp := post(t, c, "/cmd?host=h.example.com&memory_max=536870912") |
| + if resp.StatusCode != 200 { |
| + t.Fatalf("status %d", resp.StatusCode) |
| + } |
| + var out StartResponse |
| + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.InvocationID != "new" { |
| + t.Fatalf("response %+v, %v", out, err) |
| + } |
| + calls := ctl.joined() |
| + for _, want := range []string{"set-property --runtime carlos-instances-h.example.com.slice", "start --no-block carlos-instance-cmd@h.example.com.service"} { |
| + if !strings.Contains(calls, want) { |
| + t.Errorf("missing %q in\n%s", want, calls) |
| + } |
| + } |
| + if strings.Contains(calls, "stop ") { |
| + t.Errorf("a started command was stopped:\n%s", calls) |
| + } |
| +} |
| + |
| +func TestCmdRefusesAUnitStillLoaded(t *testing.T) { |
| + for _, state := range []string{"active", "activating", "deactivating"} { |
| + ctl := &fakeCtl{answers: map[string]string{"show -p ActiveState --value": state + "\n"}} |
| + _, c := newServer(t, ctl, fastCmd) |
| + resp := post(t, c, "/cmd?host=h.example.com&memory_max=536870912") |
| + if resp.StatusCode != http.StatusConflict { |
| + t.Errorf("%s: status %d, want 409", state, resp.StatusCode) |
| + } |
| + if strings.Contains(ctl.joined(), "start ") { |
| + t.Errorf("%s: a loaded unit was started again:\n%s", state, ctl.joined()) |
| + } |
| + } |
| +} |
| + |
| +func TestCmdResetsAFailedUnitBeforeTheStart(t *testing.T) { |
| + ctl := &fakeCtl{answers: map[string]string{ |
| + "show -p ActiveState --value": "failed\n", |
| + "show -p InvocationID --value": "old\n", |
| + }} |
| + ctl.onStart = func() { ctl.set("show -p InvocationID --value", "new\n") } |
| + _, c := newServer(t, ctl, fastCmd) |
| + if resp := post(t, c, "/cmd?host=h.example.com&memory_max=536870912"); resp.StatusCode != 200 { |
| + t.Fatalf("status %d", resp.StatusCode) |
| + } |
| + calls := ctl.joined() |
| + if r, s := strings.Index(calls, "reset-failed carlos-instance-cmd@"), strings.Index(calls, "start --no-block"); r < 0 || s < 0 || r > s { |
| + t.Errorf("reset-failed must come before the start:\n%s", calls) |
| + } |
| +} |
| + |
| +func TestCmdStopsAUnitThatNeverReportsAnInvocation(t *testing.T) { |
| + ctl := &fakeCtl{answers: map[string]string{ |
| + "show -p ActiveState --value": "inactive\n", |
| + "show -p InvocationID --value": "old\n", |
| + }} |
| + _, c := newServer(t, ctl, fastCmd) |
| + if resp := post(t, c, "/cmd?host=h.example.com&memory_max=536870912"); resp.StatusCode != http.StatusBadGateway { |
| + t.Fatalf("status %d, want 502", resp.StatusCode) |
| + } |
| + if !strings.Contains(ctl.joined(), "stop carlos-instance-cmd@h.example.com.service") { |
| + t.Errorf("the unit was not stopped:\n%s", ctl.joined()) |
| + } |
| +} |
| + |
| +func getStatus(t *testing.T, c *http.Client, path string) CmdStatus { |
| + t.Helper() |
| + resp, err := c.Get("http://unix" + path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != 200 { |
| + t.Fatalf("GET %s: status %d", path, resp.StatusCode) |
| + } |
| + var st CmdStatus |
| + if err := json.NewDecoder(resp.Body).Decode(&st); err != nil { |
| + t.Fatal(err) |
| + } |
| + return st |
| +} |
| + |
| +func TestCmdStatusLongPollsUntilDone(t *testing.T) { |
| + ctl := &fakeCtl{answers: map[string]string{ |
| + cmdShowAll: "InvocationID=inv1\nActiveState=activating\nSubState=start\nResult=success\nExecMainStatus=0\nExecMainCode=0\n", |
| + }} |
| + _, c := newServer(t, ctl, fastCmd) |
| + go func() { |
| + time.Sleep(50 * time.Millisecond) |
| + ctl.set(cmdShowAll, "InvocationID=inv1\nActiveState=active\nSubState=exited\nResult=success\nExecMainStatus=3\nExecMainCode=1\n") |
| + }() |
| + st := getStatus(t, c, "/cmd/h.example.com?invocation=inv1") |
| + if st.State != CmdDone || st.Status != "3" || st.Code != "1" || st.InvocationID != "inv1" { |
| + t.Fatalf("status = %+v", st) |
| + } |
| +} |
| + |
| +func TestCmdStatusAnswersRunningAtTheBound(t *testing.T) { |
| + ctl := &fakeCtl{answers: map[string]string{ |
| + cmdShowAll: "InvocationID=inv1\nActiveState=activating\nSubState=start\nResult=success\nExecMainStatus=0\nExecMainCode=0\n", |
| + }} |
| + _, c := newServer(t, ctl, fastCmd) |
| + start := time.Now() |
| + if st := getStatus(t, c, "/cmd/h.example.com?invocation=inv1"); st.State != CmdRunning { |
| + t.Fatalf("status = %+v", st) |
| + } |
| + if took := time.Since(start); took < 250*time.Millisecond { |
| + t.Errorf("answered running after %s, before the bound", took) |
| + } |
| +} |
| + |
| +func TestCmdStatusDoneCoversFailedAndInactive(t *testing.T) { |
| + for _, show := range []string{ |
| + "InvocationID=inv1\nActiveState=failed\nSubState=failed\nResult=exit-code\nExecMainStatus=1\nExecMainCode=1\n", |
| + "InvocationID=inv1\nActiveState=inactive\nSubState=dead\nResult=success\nExecMainStatus=0\nExecMainCode=1\n", |
| + } { |
| + ctl := &fakeCtl{answers: map[string]string{cmdShowAll: show}} |
| + _, c := newServer(t, ctl, fastCmd) |
| + if st := getStatus(t, c, "/cmd/h.example.com?invocation=inv1"); st.State != CmdDone { |
| + t.Errorf("%q: state %s, want done", show, st.State) |
| + } |
| + } |
| +} |
| + |
| +func TestCmdStatusIsGoneForAnotherInvocation(t *testing.T) { |
| + for _, show := range []string{ |
| + "InvocationID=inv2\nActiveState=active\nSubState=exited\nResult=success\nExecMainStatus=0\nExecMainCode=1\n", |
| + "InvocationID=\nActiveState=inactive\nSubState=dead\nResult=success\nExecMainStatus=0\nExecMainCode=0\n", |
| + } { |
| + ctl := &fakeCtl{answers: map[string]string{cmdShowAll: show}} |
| + _, c := newServer(t, ctl, fastCmd) |
| + if st := getStatus(t, c, "/cmd/h.example.com?invocation=inv1"); st.State != CmdGone { |
| + t.Errorf("%q: state %s, want gone", show, st.State) |
| + } |
| + } |
| +} |
| + |
| +func TestCmdRoutesRefuseWithClaimControlArmed(t *testing.T) { |
| + called := false |
| + s := &Server{ClaimStop: http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), Systemctl: func(context.Context, ...string) (string, error) { called = true; return "", nil }} |
| + for _, req := range []*http.Request{ |
| + httptest.NewRequest(http.MethodPost, "/cmd?host=app.example&memory_max=536870912", nil), |
| + httptest.NewRequest(http.MethodGet, "/cmd/app.example?invocation=x", nil), |
| + } { |
| + w := httptest.NewRecorder() |
| + s.Handler().ServeHTTP(w, req) |
| + if w.Code != http.StatusServiceUnavailable { |
| + t.Errorf("%s %s: status %d", req.Method, req.URL, w.Code) |
| + } |
| + } |
| + if called { |
| + t.Fatal("a cmd route reached systemctl with claim control armed") |
| + } |
| +} |
| + |
| +func TestClientCmdAndCmdStatus(t *testing.T) { |
| + ctl := &fakeCtl{answers: map[string]string{ |
| + "show -p ActiveState --value": "inactive\n", |
| + "show -p InvocationID --value": "old\n", |
| + cmdShowAll: "InvocationID=new\nActiveState=failed\nSubState=failed\nResult=exit-code\nExecMainStatus=2\nExecMainCode=1\n", |
| + }} |
| + ctl.onStart = func() { ctl.set("show -p InvocationID --value", "new\n") } |
| + _, cl := newClient(t, ctl, fastCmd) |
| + inv, err := cl.Cmd(context.Background(), "h.example.com", 536870912) |
| + if err != nil || inv != "new" { |
| + t.Fatalf("Cmd = %q, %v", inv, err) |
| + } |
| + st, err := cl.CmdStatus(context.Background(), "h.example.com", inv) |
| + if err != nil || st.State != CmdDone || st.Status != "2" { |
| + t.Fatalf("CmdStatus = %+v, %v", st, err) |
| + } |
| +} |
diff --git a/internal/instancectl/names.go b/internal/instancectl/names.go| index f69ccc8fa..c2a2717f4 100644 |
| --- a/internal/instancectl/names.go |
| +++ b/internal/instancectl/names.go |
| @@ -194,6 +194,25 @@ type SeedResponse struct { |
| Code string `json:"code"` |
| } |
| +// CmdStatus is GET /cmd/{host}'s answer (run spec §5.4). State is running, |
| +// done once the named invocation's main process has exited (active |
| +// (exited), failed, or inactive after a stop), or gone when the unit's |
| +// invocation is another one or none. Result, Status and Code are systemd's |
| +// Result, ExecMainStatus and ExecMainCode as `systemctl show` prints them. |
| +type CmdStatus struct { |
| + State string `json:"state"` |
| + InvocationID string `json:"invocation_id"` |
| + Result string `json:"result"` |
| + Status string `json:"status"` |
| + Code string `json:"code"` |
| +} |
| + |
| +const ( |
| + CmdRunning = "running" |
| + CmdDone = "done" |
| + CmdGone = "gone" |
| +) |
| + |
| type SweepResponse struct { |
| Units []string `json:"units"` |
| Failed map[string]string `json:"failed,omitempty"` |
diff --git a/internal/instancectl/server.go b/internal/instancectl/server.go| index 474b8f584..cd037ab6d 100644 |
| --- a/internal/instancectl/server.go |
| +++ b/internal/instancectl/server.go |
| @@ -73,6 +73,13 @@ type Server struct { |
| Statfs func(path string) (avail, total uint64, err error) |
| DiskEvery time.Duration |
| + // cmdPollEvery, cmdWaitBound and cmdStartBound pace the cmd routes: |
| + // how often GET /cmd/{host} re-reads the unit (250 ms), how long it |
| + // holds a request (30 s, run spec §5.4), and how long POST /cmd waits |
| + // for the queued start to show a new invocation (10 s). Zero is the |
| + // default; tests shorten them. |
| + cmdPollEvery, cmdWaitBound, cmdStartBound time.Duration |
| + |
| // Run executes one command as root and returns its combined output; |
| // production is exec.CommandContext, tests record. Only |
| // inspectMountpoints uses it, and every argument it passes is a |
| @@ -258,6 +265,8 @@ func (s *Server) Handler() http.Handler { |
| mux.HandleFunc("POST /stop", s.claimAwareLegacy(s.handleStop)) |
| mux.HandleFunc("POST /reset", s.claimAwareLegacy(s.handleReset)) |
| mux.HandleFunc("POST /sweep", s.claimAwareLegacy(s.handleSweep)) |
| + mux.HandleFunc("POST /cmd", s.claimAwareLegacy(s.handleCmd)) |
| + mux.HandleFunc("GET /cmd/{host}", s.claimAwareLegacy(s.handleCmdStatus)) |
| mux.HandleFunc("GET /ready", s.handleReady) |
| return mux |
| } |