carlos / platform Public

instancectl: unit names, escaping, launch and exit shapes, protocol types (plan 2, task 1)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ1ZvWj7naf73GjTX8MBv5
Paul Campbell pushed by paul@keymail.dev cdc34b83502e93901954aea980e1f8b43b54a33a parent 6087978
2 files changed, +279 −0
  • internal/instancectl/names.go +185 −0
  • internal/instancectl/names_test.go +94 −0
diff --git a/internal/instancectl/names.go b/internal/instancectl/names.go
new file mode 100644
index 000000000..73810a815
--- /dev/null
+++ b/internal/instancectl/names.go
@@ -0,0 +1,185 @@
+// Package instancectl is the root instance-control channel of the
+// isolated-exec design (§5) and the names everything on both sides of it
+// agrees on: unit and slice names keyed by the bounded instance identifier,
+// the launch file the trampoline reads (§6.2), the advisory exit record
+// (§6.5) and the probe's verdict (§8.5).
+//
+// The names live in one package because three programs and two services
+// build them: the agent (plan 3) composes launch files and requests, the
+// control service builds unit names from validated hosts, and the
+// in-namespace programs read files keyed by the same identifier. A
+// disagreement between any two of them would be a unit that never starts
+// or a record nobody finds.
+package instancectl
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+// Role is which of the three template units a request or a file is about.
+type Role string
+
+const (
+ RoleRun Role = "run"
+ RoleSeed Role = "seed"
+ RoleSidecar Role = "sidecar"
+)
+
+// ParseRole accepts exactly the three literals.
+func ParseRole(s string) (Role, bool) {
+ switch Role(s) {
+ case RoleRun, RoleSeed, RoleSidecar:
+ return Role(s), true
+ }
+ return "", false
+}
+
+// Escape is systemd-escape(1)'s rule for a unit instance name: "/" becomes
+// "-", a leading "." becomes "\x2e", and every byte outside [a-zA-Z0-9:_.]
+// becomes "\x" plus two lowercase hex digits. For the identifiers this
+// platform produces (hosts are [a-z0-9.-], a hashed identifier adds a
+// leading underscore) that means "-" becomes "\x2d" and nothing else
+// changes; the general rule is kept so an unexpected byte can never yield
+// a name systemd refuses to load.
+func Escape(s string) string {
+ var b strings.Builder
+ for i := 0; i < len(s); i++ {
+ c := s[i]
+ switch {
+ case c == '/':
+ b.WriteByte('-')
+ case i == 0 && c == '.':
+ b.WriteString(`\x2e`)
+ case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == ':', c == '_', c == '.':
+ b.WriteByte(c)
+ default:
+ fmt.Fprintf(&b, `\x%02x`, c)
+ }
+ }
+ return b.String()
+}
+
+// The three 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@"
+)
+
+func prefixFor(role Role) string {
+ switch role {
+ case RoleSeed:
+ return UnitPrefixSeed
+ case RoleSidecar:
+ return UnitPrefixSidecar
+ }
+ return UnitPrefixRun
+}
+
+// UnitName is the template instance for a role and a host:
+// carlos-instance[-seed|-sidecar]@<Escape(InstanceID(host))>.service.
+func UnitName(role Role, host string) string {
+ return prefixFor(role) + Escape(provision.InstanceID(host)) + ".service"
+}
+
+// SliceName is the per-host slice every role of a host runs in (design
+// §4.3): carlos-instances-<Escape(InstanceID(host))>.slice.
+func SliceName(host string) string {
+ return "carlos-instances-" + Escape(provision.InstanceID(host)) + ".slice"
+}
+
+// ParseUnitName recognises the three 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) {
+ if !strings.HasSuffix(unit, ".service") {
+ return "", "", false
+ }
+ body := strings.TrimSuffix(unit, ".service")
+ for _, p := range []struct {
+ prefix string
+ role Role
+ }{{UnitPrefixSeed, RoleSeed}, {UnitPrefixSidecar, RoleSidecar}, {UnitPrefixRun, RoleRun}} {
+ if strings.HasPrefix(body, p.prefix) {
+ esc := strings.TrimPrefix(body, p.prefix)
+ if esc == "" {
+ return "", "", false
+ }
+ return p.role, esc, true
+ }
+ }
+ return "", "", false
+}
+
+// LaunchSchema is the only launch-file schema this release understands.
+const LaunchSchema = 1
+
+// Launch is what the agent writes into the instance directory before a
+// start and what the trampoline execs (design §6.2). Env is the complete
+// environment, one entry per key, exactly as the agent composed it; the
+// trampoline parses nothing.
+type Launch struct {
+ Schema int `json:"schema"`
+ Host string `json:"host"`
+ Argv []string `json:"argv"`
+ Env []string `json:"env"`
+}
+
+// LaunchFileName is "launch.<role>.json" inside the instance directory.
+func LaunchFileName(role Role) string { return "launch." + string(role) + ".json" }
+
+// ExitRecord is the advisory record the unit's ExecStopPost leaves in the
+// instance's run directory (design §6.5). It decides the wording of the
+// member-facing event line and nothing else.
+type ExitRecord struct {
+ Invocation string `json:"invocation"`
+ Result string `json:"result"`
+ Code string `json:"code"`
+ Status string `json:"status"`
+ At time.Time `json:"at"`
+}
+
+// ExitRecordName is "exit.<role>.<invocation>.json".
+func ExitRecordName(role Role, invocation string) string {
+ return "exit." + string(role) + "." + invocation + ".json"
+}
+
+// ProbeDirName is the instance directory the readiness probe runs in. It
+// starts with an underscore, which no valid host does, so it can never
+// collide with a real instance's directory.
+const ProbeDirName = "_probe"
+
+// VerdictLine is one check of the readiness probe (design §8.5). Verdict is
+// "ok", "unverified" or "fail"; only "fail" blocks readiness.
+type VerdictLine struct {
+ Check string `json:"check"`
+ Verdict string `json:"verdict"`
+ Detail string `json:"detail,omitempty"`
+}
+
+// ProbeReport is what the probe prints and what /ready answers with.
+type ProbeReport struct {
+ Ready bool `json:"ready"`
+ Lines []VerdictLine `json:"lines"`
+}
+
+// Responses of the control verbs (design §5.3).
+type StartResponse struct {
+ InvocationID string `json:"invocation_id"`
+}
+
+type SeedResponse struct {
+ InvocationID string `json:"invocation_id"`
+ Result string `json:"result"`
+ Status string `json:"status"`
+ Code string `json:"code"`
+}
+
+type SweepResponse struct {
+ Units []string `json:"units"`
+}
diff --git a/internal/instancectl/names_test.go b/internal/instancectl/names_test.go
new file mode 100644
index 000000000..2534526aa
--- /dev/null
+++ b/internal/instancectl/names_test.go
@@ -0,0 +1,94 @@
+package instancectl
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+// Escape follows systemd-escape(1) for unit instance names. The cases are
+// the ones our identifiers can produce (hosts are [a-z0-9.-]; a hashed
+// identifier adds a leading underscore), plus the general rules so a
+// surprise character can never yield an unloadable unit name.
+func TestEscapeFollowsSystemd(t *testing.T) {
+ cases := map[string]string{
+ "bill.go.tito.io": "bill.go.tito.io",
+ "bill-2.example.com": `bill\x2d2.example.com`,
+ "_aaaa-1234abcd": `_aaaa\x2d1234abcd`,
+ "a/b": "a-b",
+ ".hidden": `\x2ehidden`,
+ "x y": `x\x20y`,
+ "": "",
+ "UPPER_ok:1": "UPPER_ok:1",
+ }
+ for in, want := range cases {
+ if got := Escape(in); got != want {
+ t.Errorf("Escape(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+func TestUnitAndSliceNamesAreKeyedByEscapedInstanceID(t *testing.T) {
+ host := "bill-2.example.com"
+ esc := `bill\x2d2.example.com`
+ if got := UnitName(RoleRun, host); got != "carlos-instance@"+esc+".service" {
+ t.Errorf("run unit = %q", got)
+ }
+ if got := UnitName(RoleSeed, host); got != "carlos-instance-seed@"+esc+".service" {
+ t.Errorf("seed unit = %q", got)
+ }
+ if got := UnitName(RoleSidecar, host); got != "carlos-instance-sidecar@"+esc+".service" {
+ t.Errorf("sidecar unit = %q", got)
+ }
+ if got := SliceName(host); got != "carlos-instances-"+esc+".slice" {
+ t.Errorf("slice = %q", got)
+ }
+ long := strings.Repeat("a", 60) + ".example.com"
+ if got := UnitName(RoleRun, long); !strings.HasPrefix(got, "carlos-instance@_") || !strings.HasSuffix(got, ".service") {
+ t.Errorf("long host unit = %q, want the hashed identifier", got)
+ }
+ if got, want := UnitName(RoleRun, long), "carlos-instance@"+Escape(provision.InstanceID(long))+".service"; got != want {
+ t.Errorf("long host unit = %q, want %q", got, want)
+ }
+}
+
+func TestParseUnitNameRoundTrips(t *testing.T) {
+ for _, role := range []Role{RoleRun, RoleSeed, RoleSidecar} {
+ 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 _, _, ok := ParseUnitName(bad); ok {
+ t.Errorf("ParseUnitName(%q) accepted", bad)
+ }
+ }
+}
+
+func TestParseRole(t *testing.T) {
+ for _, s := range []string{"run", "seed", "sidecar"} {
+ 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 "} {
+ if _, ok := ParseRole(s); ok {
+ t.Errorf("ParseRole(%q) accepted", s)
+ }
+ }
+}
+
+func TestFileNames(t *testing.T) {
+ if got := LaunchFileName(RoleSidecar); got != "launch.sidecar.json" {
+ t.Errorf("LaunchFileName = %q", got)
+ }
+ if got := ExitRecordName(RoleRun, "abc123"); got != "exit.run.abc123.json" {
+ t.Errorf("ExitRecordName = %q", got)
+ }
+ if provision.ValidHostShape(ProbeDirName) {
+ t.Errorf("ProbeDirName %q is a valid host; it must never collide with one", ProbeDirName)
+ }
+}