instancectl: RoleCmd and the carlos-instance-cmd@ prefix (run spec §5.4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+59
−22
approved_run_copy_test.go+1 −1internal/instancectl/names.go+13 −7internal/instancectl/names_test.go+7 −4internal/instancectl/server.go+6 −6internal/instancectl/server_test.go+28 −0internal/instanceexec/exec.go+2 −2internal/instanceexec/exit.go+2 −2
diff --git a/approved_run_copy_test.go b/approved_run_copy_test.go| index 89bc87cd6..a92e2db90 100644 |
| --- a/approved_run_copy_test.go |
| +++ b/approved_run_copy_test.go |
| @@ -33,7 +33,7 @@ var runCopyIDs = func() []string { |
| // runCopyBuilt is every deck id whose text the code or docs now carry. The |
| // task that writes a row's text adds its id here in the same commit. |
| -var runCopyBuilt = []string{} |
| +var runCopyBuilt = []string{"RUN-C9", "RUN-C10", "RUN-C13", "RUN-C14"} |
| func mustParseRunRecord(t *testing.T) []copyRow { |
| t.Helper() |
diff --git a/internal/instancectl/names.go b/internal/instancectl/names.go| index f07a94624..f69ccc8fa 100644 |
| --- a/internal/instancectl/names.go |
| +++ b/internal/instancectl/names.go |
| @@ -20,19 +20,22 @@ import ( |
| "github.com/carlosframework/platform/internal/provision" |
| ) |
| -// Role is which of the three template units a request or a file is about. |
| +// Role is which of the four template units a request or a file is about. |
| type Role string |
| const ( |
| RoleRun Role = "run" |
| RoleSeed Role = "seed" |
| RoleSidecar Role = "sidecar" |
| + // RoleCmd is one `carlos run` command (run spec §5.4). "run" is the |
| + // serving role already, so the command's role is cmd. |
| + RoleCmd Role = "cmd" |
| ) |
| -// ParseRole accepts exactly the three literals. |
| +// ParseRole accepts exactly the four literals. |
| func ParseRole(s string) (Role, bool) { |
| switch Role(s) { |
| - case RoleRun, RoleSeed, RoleSidecar: |
| + case RoleRun, RoleSeed, RoleSidecar, RoleCmd: |
| return Role(s), true |
| } |
| return "", false |
| @@ -63,12 +66,13 @@ func Escape(s string) string { |
| return b.String() |
| } |
| -// The three template prefixes. A unit under any of them is the platform's |
| +// The four template prefixes. A unit under any of them is the platform's |
| // to start, stop and sweep; nothing else is (design §5.3). |
| const ( |
| UnitPrefixRun = "carlos-instance@" |
| UnitPrefixSeed = "carlos-instance-seed@" |
| UnitPrefixSidecar = "carlos-instance-sidecar@" |
| + UnitPrefixCmd = "carlos-instance-cmd@" |
| ) |
| func prefixFor(role Role) string { |
| @@ -77,12 +81,14 @@ func prefixFor(role Role) string { |
| return UnitPrefixSeed |
| case RoleSidecar: |
| return UnitPrefixSidecar |
| + case RoleCmd: |
| + return UnitPrefixCmd |
| } |
| return UnitPrefixRun |
| } |
| // UnitName is the template instance for a role and a host: |
| -// carlos-instance[-seed|-sidecar]@<Escape(InstanceID(host))>.service. |
| +// carlos-instance[-seed|-sidecar|-cmd]@<Escape(InstanceID(host))>.service. |
| func UnitName(role Role, host string) string { |
| return prefixFor(role) + Escape(provision.InstanceID(host)) + ".service" |
| } |
| @@ -93,7 +99,7 @@ func SliceName(host string) string { |
| return "carlos-instances-" + Escape(provision.InstanceID(host)) + ".slice" |
| } |
| -// ParseUnitName recognises the three templates' instances and returns the |
| +// ParseUnitName recognises the four templates' instances and returns the |
| // role and the ESCAPED identifier (systemd reports names escaped). Anything |
| // else, including a template with no instance, is not ours. |
| func ParseUnitName(unit string) (Role, string, bool) { |
| @@ -104,7 +110,7 @@ func ParseUnitName(unit string) (Role, string, bool) { |
| for _, p := range []struct { |
| prefix string |
| role Role |
| - }{{UnitPrefixSeed, RoleSeed}, {UnitPrefixSidecar, RoleSidecar}, {UnitPrefixRun, RoleRun}} { |
| + }{{UnitPrefixSeed, RoleSeed}, {UnitPrefixSidecar, RoleSidecar}, {UnitPrefixCmd, RoleCmd}, {UnitPrefixRun, RoleRun}} { |
| if strings.HasPrefix(body, p.prefix) { |
| esc := strings.TrimPrefix(body, p.prefix) |
| if esc == "" { |
diff --git a/internal/instancectl/names_test.go b/internal/instancectl/names_test.go| index 2534526aa..50b33f0f8 100644 |
| --- a/internal/instancectl/names_test.go |
| +++ b/internal/instancectl/names_test.go |
| @@ -54,14 +54,17 @@ func TestUnitAndSliceNamesAreKeyedByEscapedInstanceID(t *testing.T) { |
| } |
| func TestParseUnitNameRoundTrips(t *testing.T) { |
| - for _, role := range []Role{RoleRun, RoleSeed, RoleSidecar} { |
| + for _, role := range []Role{RoleRun, RoleSeed, RoleSidecar, RoleCmd} { |
| u := UnitName(role, "bill-2.example.com") |
| gotRole, esc, ok := ParseUnitName(u) |
| if !ok || gotRole != role || esc != `bill\x2d2.example.com` { |
| t.Errorf("ParseUnitName(%q) = %q %q %v", u, gotRole, esc, ok) |
| } |
| } |
| - for _, bad := range []string{"carlos-edge.service", "carlos-app@x.service", "carlos-instance@x.socket", "carlos-instance@.service", "carlos-instances-x.slice"} { |
| + if got := UnitName(RoleCmd, "bill-2.example.com"); got != `carlos-instance-cmd@bill\x2d2.example.com.service` { |
| + t.Errorf("cmd unit = %q", got) |
| + } |
| + for _, bad := range []string{"carlos-edge.service", "carlos-app@x.service", "carlos-instance@x.socket", "carlos-instance@.service", "carlos-instance-cmd@.service", "carlos-instances-x.slice"} { |
| if _, _, ok := ParseUnitName(bad); ok { |
| t.Errorf("ParseUnitName(%q) accepted", bad) |
| } |
| @@ -69,12 +72,12 @@ func TestParseUnitNameRoundTrips(t *testing.T) { |
| } |
| func TestParseRole(t *testing.T) { |
| - for _, s := range []string{"run", "seed", "sidecar"} { |
| + for _, s := range []string{"run", "seed", "sidecar", "cmd"} { |
| if r, ok := ParseRole(s); !ok || string(r) != s { |
| t.Errorf("ParseRole(%q) = %q %v", s, r, ok) |
| } |
| } |
| - for _, s := range []string{"", "Run", "exec", "run "} { |
| + for _, s := range []string{"", "Run", "exec", "run ", "Cmd", "cmd-err"} { |
| if _, ok := ParseRole(s); ok { |
| t.Errorf("ParseRole(%q) accepted", s) |
| } |
diff --git a/internal/instancectl/server.go b/internal/instancectl/server.go| index 64b96d55c..474b8f584 100644 |
| --- a/internal/instancectl/server.go |
| +++ b/internal/instancectl/server.go |
| @@ -27,7 +27,7 @@ import ( |
| ) |
| // Server is the root instance-control service (design §5). It does four |
| -// things and nothing else: starts, stops and sweeps the three templates' |
| +// things and nothing else: starts, stops and sweeps the four templates' |
| // instances, sets a host slice's memory tier before a start, runs the |
| // readiness probe, and answers /ready. Every systemctl argument it passes |
| // is built from a validated host by UnitName/SliceName; nothing from a |
| @@ -280,7 +280,7 @@ func parseRequest(r *http.Request, roles []Role, wantTier bool) (request, error) |
| if len(roles) > 0 { |
| role, ok := ParseRole(q.Get("role")) |
| if !ok { |
| - return request{}, fmt.Errorf("role %q is not run, seed or sidecar", q.Get("role")) |
| + return request{}, fmt.Errorf("role %q is not run, seed, sidecar or cmd", q.Get("role")) |
| } |
| allowed := false |
| for _, r := range roles { |
| @@ -471,7 +471,7 @@ func (s *Server) stopUnit(ctx context.Context, unit string) { |
| } |
| func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) { |
| - req, err := parseRequest(r, []Role{RoleRun, RoleSeed, RoleSidecar}, false) |
| + req, err := parseRequest(r, []Role{RoleRun, RoleSeed, RoleSidecar, RoleCmd}, false) |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusBadRequest) |
| return |
| @@ -484,7 +484,7 @@ func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) { |
| } |
| func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) { |
| - req, err := parseRequest(r, []Role{RoleRun, RoleSeed, RoleSidecar}, false) |
| + req, err := parseRequest(r, []Role{RoleRun, RoleSeed, RoleSidecar, RoleCmd}, false) |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusBadRequest) |
| return |
| @@ -496,7 +496,7 @@ func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) { |
| w.WriteHeader(http.StatusNoContent) |
| } |
| -// handleSweep stops every instance of the three templates that is not |
| +// handleSweep stops every instance of the four templates that is not |
| // inactive or has a job, keyed on the names systemd reports, so a unit |
| // whose identifier is hashed and whose row is gone is still stopped |
| // (design §6.7, codex R7-1). |
| @@ -504,7 +504,7 @@ func (s *Server) handleSweep(w http.ResponseWriter, r *http.Request) { |
| ctx := r.Context() |
| targets := map[string]bool{} |
| out, err := s.Systemctl(ctx, "list-units", "--all", "--plain", "--no-legend", "--no-pager", |
| - UnitPrefixRun+"*.service", UnitPrefixSeed+"*.service", UnitPrefixSidecar+"*.service") |
| + UnitPrefixRun+"*.service", UnitPrefixSeed+"*.service", UnitPrefixSidecar+"*.service", UnitPrefixCmd+"*.service") |
| if err != nil { |
| http.Error(w, "list-units: "+err.Error(), http.StatusBadGateway) |
| return |
diff --git a/internal/instancectl/server_test.go b/internal/instancectl/server_test.go| index 5ef76bc3c..2482137f8 100644 |
| --- a/internal/instancectl/server_test.go |
| +++ b/internal/instancectl/server_test.go |
| @@ -689,3 +689,31 @@ func TestReadyRefusesWhenTheRunningBinaryIsNotTheInstalledOne(t *testing.T) { |
| t.Fatalf("after the restart: %v %v", resp, err) |
| } |
| } |
| + |
| +func TestStopResetAndSweepCoverTheCmdRole(t *testing.T) { |
| + ctl := &fakeCtl{answers: map[string]string{ |
| + "list-units": "carlos-instance-cmd@a.example.com.service loaded active exited carlos exec-backed instance a.example.com (cmd)\n", |
| + "show -p ActiveState --value": "inactive\n", |
| + }} |
| + s, c := newServer(t, ctl) |
| + s.cgroupWaitBudget = time.Second |
| + if resp := post(t, c, "/stop?host=a.example.com&role=cmd"); resp.StatusCode != http.StatusNoContent { |
| + t.Fatalf("stop status %d", resp.StatusCode) |
| + } |
| + if resp := post(t, c, "/reset?host=a.example.com&role=cmd"); resp.StatusCode/100 != 2 { |
| + t.Fatalf("reset status %d", resp.StatusCode) |
| + } |
| + if resp := post(t, c, "/sweep"); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("sweep status %d", resp.StatusCode) |
| + } |
| + calls := ctl.joined() |
| + for _, want := range []string{ |
| + "stop carlos-instance-cmd@a.example.com.service", |
| + "reset-failed carlos-instance-cmd@a.example.com.service", |
| + "carlos-instance-cmd@*.service", |
| + } { |
| + if !strings.Contains(calls, want) { |
| + t.Errorf("missing %q in\n%s", want, calls) |
| + } |
| + } |
| +} |
diff --git a/internal/instanceexec/exec.go b/internal/instanceexec/exec.go| index 9b2adfb93..02de863a7 100644 |
| --- a/internal/instanceexec/exec.go |
| +++ b/internal/instanceexec/exec.go |
| @@ -41,7 +41,7 @@ func dataDir() string { |
| // RunExec is `carlos instance-exec <role> <iid>`. |
| func RunExec(args []string) { |
| fs := flag.NewFlagSet("instance-exec", flag.ExitOnError) |
| - fs.Usage = cliflag.Usage(fs, "usage: carlos instance-exec <run|seed|sidecar> <instance-id>") |
| + fs.Usage = cliflag.Usage(fs, "usage: carlos instance-exec <run|seed|sidecar|cmd> <instance-id>") |
| _ = fs.Parse(args) |
| if fs.NArg() != 2 { |
| fs.Usage() |
| @@ -49,7 +49,7 @@ func RunExec(args []string) { |
| } |
| role, ok := instancectl.ParseRole(fs.Arg(0)) |
| if !ok { |
| - log.Fatalf("instance-exec: role %q is not run, seed or sidecar", fs.Arg(0)) |
| + log.Fatalf("instance-exec: role %q is not run, seed, sidecar or cmd", fs.Arg(0)) |
| } |
| // The tenant is scoped (#178, see ScopeAbstractUnix) and runs under |
| // the trampoline as its init (#126, see init_linux.go). |
diff --git a/internal/instanceexec/exit.go b/internal/instanceexec/exit.go| index cf32a4a04..a41d452a7 100644 |
| --- a/internal/instanceexec/exit.go |
| +++ b/internal/instanceexec/exit.go |
| @@ -32,7 +32,7 @@ var invocationShape = regexp.MustCompile(`^[0-9a-f]{32}$`) |
| func RunExit(args []string) { |
| fs := flag.NewFlagSet("instance-exit", flag.ExitOnError) |
| runDir := fs.String("run-dir", provision.SocketDir, "run directory holding instances/<id>") |
| - fs.Usage = cliflag.Usage(fs, "usage: carlos instance-exit <run|seed|sidecar> <instance-id>") |
| + fs.Usage = cliflag.Usage(fs, "usage: carlos instance-exit <run|seed|sidecar|cmd> <instance-id>") |
| _ = fs.Parse(args) |
| if fs.NArg() != 2 { |
| fs.Usage() |
| @@ -40,7 +40,7 @@ func RunExit(args []string) { |
| } |
| role, ok := instancectl.ParseRole(fs.Arg(0)) |
| if !ok { |
| - log.Printf("instance-exit: role %q is not run, seed or sidecar", fs.Arg(0)) |
| + log.Printf("instance-exit: role %q is not run, seed, sidecar or cmd", fs.Arg(0)) |
| return |
| } |
| if err := writeExitRecord(*runDir, role, fs.Arg(1), os.Getenv, time.Now()); err != nil { |