carlos / platform Public

instancectl: RoleCmd and the carlos-instance-cmd@ prefix (run spec §5.4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 36c82361c26a60bf6952499dd06fbb939387ed1b parent 9d54ee0
7 files changed, +59 −22
  • approved_run_copy_test.go +1 −1
  • internal/instancectl/names.go +13 −7
  • internal/instancectl/names_test.go +7 −4
  • internal/instancectl/server.go +6 −6
  • internal/instancectl/server_test.go +28 −0
  • internal/instanceexec/exec.go +2 −2
  • internal/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 {