carlos / platform Public

zdd plan 2 task 6: generation unit, launch, exit-record and log-socket names

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 6992ac0fb5def696cc90a88dcbe0310930777ea4 parent 26a6ef7
2 files changed, +231 −0
  • internal/instancectl/names.go +108 −0
  • internal/instancectl/names_gen_test.go +123 −0
diff --git a/internal/instancectl/names.go b/internal/instancectl/names.go
index f07a94624..323407d88 100644
--- a/internal/instancectl/names.go
+++ b/internal/instancectl/names.go
@@ -14,6 +14,8 @@ package instancectl
import (
"fmt"
+ "path/filepath"
+ "strconv"
"strings"
"time"
@@ -93,6 +95,95 @@ func SliceName(host string) string {
return "carlos-instances-" + Escape(provision.InstanceID(host)) + ".slice"
}
+// UnitPrefixGen is the generation template (zero-downtime deploys spec
+// §7.6): one instance per spawn of a host's run role. The three templates
+// above stay for every box and binary still running them.
+const UnitPrefixGen = "carlos-instance-gen@"
+
+// GenInstance is the generation template's unescaped instance string,
+// "<iid>:<n>" (%I inside the unit). No instance ID holds ':'.
+func GenInstance(host string, n uint64) string {
+ return provision.InstanceID(host) + ":" + strconv.FormatUint(n, 10)
+}
+
+// ParseGenInstance splits "<iid>:<n>", the trampoline's and the exit
+// writer's half of §7.6. n is canonical decimal and never 0. iid is
+// non-empty and holds no ':', '/' or NUL, and is neither "." nor "..".
+func ParseGenInstance(s string) (iid string, n uint64, ok bool) {
+ i := strings.LastIndexByte(s, ':')
+ if i <= 0 {
+ return "", 0, false
+ }
+ iid, num := s[:i], s[i+1:]
+ n, err := strconv.ParseUint(num, 10, 64)
+ if err != nil || n == 0 || strconv.FormatUint(n, 10) != num {
+ return "", 0, false
+ }
+ if strings.ContainsAny(iid, ":/\x00") || iid == "." || iid == ".." {
+ return "", 0, false
+ }
+ return iid, n, true
+}
+
+// GenUnitName is carlos-instance-gen@<Escape(GenInstance(host, n))>.service.
+func GenUnitName(host string, n uint64) string {
+ return UnitPrefixGen + Escape(GenInstance(host, n)) + ".service"
+}
+
+// ParseGenUnitName recognises a generation unit and returns its ESCAPED
+// instance ID (systemd reports names escaped) and its generation.
+func ParseGenUnitName(unit string) (esc string, n uint64, ok bool) {
+ body, found := strings.CutSuffix(unit, ".service")
+ if !found {
+ return "", 0, false
+ }
+ inst, found := strings.CutPrefix(body, UnitPrefixGen)
+ if !found {
+ return "", 0, false
+ }
+ return ParseGenInstance(inst)
+}
+
+// GenKeyOf and GenKey key a generation's files: its executable mount
+// directory and its log socket (spec §7.6). It is "<iid>@<n>", not the
+// unit's "<iid>:<n>", because ':' is BindPaths='s field separator. No host
+// and no instance ID holds '@', so a key never meets a legacy name.
+func GenKeyOf(iid string, n uint64) string { return iid + "@" + strconv.FormatUint(n, 10) }
+
+func GenKey(host string, n uint64) string { return GenKeyOf(provision.InstanceID(host), n) }
+
+// GenLogSocket is the generation's run log socket:
+// /run/carlos/logs/<GenKey>.run.sock.
+func GenLogSocket(host string, n uint64) string {
+ return filepath.Join(provision.SocketDir, "logs", GenKey(host, n)+".run.sock")
+}
+
+// Unescape is Escape's inverse for the names this platform produces:
+// "\xHH" becomes the byte and "-" becomes "/". It reports false on a
+// malformed escape.
+func Unescape(s string) (string, bool) {
+ var b strings.Builder
+ for i := 0; i < len(s); i++ {
+ switch c := s[i]; {
+ case c == '-':
+ b.WriteByte('/')
+ case c == '\\':
+ if i+3 >= len(s) || s[i+1] != 'x' {
+ return "", false
+ }
+ v, err := strconv.ParseUint(s[i+2:i+4], 16, 8)
+ if err != nil {
+ return "", false
+ }
+ b.WriteByte(byte(v))
+ i += 3
+ default:
+ b.WriteByte(c)
+ }
+ }
+ return b.String(), true
+}
+
// 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.
@@ -100,6 +191,10 @@ func ParseUnitName(unit string) (Role, string, bool) {
if !strings.HasSuffix(unit, ".service") {
return "", "", false
}
+ if esc, _, ok := ParseGenUnitName(unit); ok {
+ // A generation unit runs in its host's slice, as the run role.
+ return RoleRun, esc, true
+ }
body := strings.TrimSuffix(unit, ".service")
for _, p := range []struct {
prefix string
@@ -133,6 +228,10 @@ type Launch struct {
// LaunchFileName is "launch.<role>.json" inside the instance directory.
func LaunchFileName(role Role) string { return "launch." + string(role) + ".json" }
+// GenLaunchFileName is generation n's launch file in the instance
+// directory: "launch.gen.<n>.json".
+func GenLaunchFileName(n uint64) string { return "launch.gen." + strconv.FormatUint(n, 10) + ".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.
@@ -149,6 +248,15 @@ func ExitRecordName(role Role, invocation string) string {
return "exit." + string(role) + "." + invocation + ".json"
}
+// GenExitRecordName is generation n's exit record: "exit.gen.<n>.<inv>.json".
+// It is never under "exit.run.", which prepare sweeps for the run role.
+func GenExitRecordName(n uint64, invocation string) string {
+ return GenExitPrefix(n) + invocation + ".json"
+}
+
+// GenExitPrefix is every exit record of generation n.
+func GenExitPrefix(n uint64) string { return "exit.gen." + strconv.FormatUint(n, 10) + "." }
+
// 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.
diff --git a/internal/instancectl/names_gen_test.go b/internal/instancectl/names_gen_test.go
new file mode 100644
index 000000000..e968a1f36
--- /dev/null
+++ b/internal/instancectl/names_gen_test.go
@@ -0,0 +1,123 @@
+package instancectl
+
+import (
+ "math"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+func TestGenerationUnitNames(t *testing.T) {
+ const host = "bill-2.example.com"
+ if got := GenInstance(host, 7); got != "bill-2.example.com:7" {
+ t.Errorf("GenInstance = %q", got)
+ }
+ if got := GenUnitName(host, 7); got != `carlos-instance-gen@bill\x2d2.example.com:7.service` {
+ t.Errorf("GenUnitName = %q", got)
+ }
+ esc, n, ok := ParseGenUnitName(GenUnitName(host, 7))
+ if !ok || esc != `bill\x2d2.example.com` || n != 7 {
+ t.Errorf("ParseGenUnitName = %q %d %v", esc, n, ok)
+ }
+ // The sweep's parser knows the generation template, as a run unit in
+ // the host's slice.
+ role, esc, ok := ParseUnitName(GenUnitName(host, 7))
+ if !ok || role != RoleRun || esc != `bill\x2d2.example.com` {
+ t.Errorf("ParseUnitName(gen) = %q %q %v", role, esc, ok)
+ }
+ // Flag off: the original names are unchanged.
+ if got := UnitName(RoleRun, host); got != `carlos-instance@bill\x2d2.example.com.service` {
+ t.Errorf("UnitName(run) = %q", got)
+ }
+ if role, esc, ok := ParseUnitName(`carlos-instance@bill\x2d2.example.com.service`); !ok || role != RoleRun || esc != `bill\x2d2.example.com` {
+ t.Errorf("ParseUnitName(run) = %q %q %v", role, esc, ok)
+ }
+ for _, bad := range []string{"carlos-instance-gen@.service", `carlos-instance-gen@bill.example.com.service`, "carlos-instance-gen@bill.example.com:0.service"} {
+ if _, _, ok := ParseUnitName(bad); ok {
+ t.Errorf("ParseUnitName(%q) accepted", bad)
+ }
+ }
+}
+
+func TestParseGenInstanceIsStrict(t *testing.T) {
+ if iid, n, ok := ParseGenInstance("_abc-123:42"); !ok || iid != "_abc-123" || n != 42 {
+ t.Errorf("ParseGenInstance = %q %d %v", iid, n, ok)
+ }
+ for _, bad := range []string{"x", "x:", ":7", "x:0", "x:07", "x:7a", "a/b:7", "..:7", "x:18446744073709551616", "a:b:7"} {
+ if _, _, ok := ParseGenInstance(bad); ok {
+ t.Errorf("ParseGenInstance(%q) accepted", bad)
+ }
+ }
+}
+
+func TestGenerationFileNames(t *testing.T) {
+ const host = "bill-2.example.com"
+ for got, want := range map[string]string{
+ GenKey(host, 7): "bill-2.example.com@7",
+ GenLogSocket(host, 7): "/run/carlos/logs/bill-2.example.com@7.run.sock",
+ GenLaunchFileName(7): "launch.gen.7.json",
+ GenExitRecordName(7, "abc"): "exit.gen.7.abc.json",
+ GenExitPrefix(7): "exit.gen.7.",
+ } {
+ if got != want {
+ t.Errorf("got %q, want %q", got, want)
+ }
+ }
+ // Never under a legacy prefix: prepare's stale sweep for the run role
+ // removes "exit.run.*" and must not touch a live generation's record.
+ if strings.HasPrefix(GenExitRecordName(7, "abc"), "exit.run.") || GenLaunchFileName(7) == LaunchFileName(RoleRun) {
+ t.Error("a generation file shares a legacy name")
+ }
+}
+
+// D2: no instance ID holds '@', so a generation key never equals a legacy
+// instance ID, whatever the host.
+func TestGenKeyNeverMeetsALegacyName(t *testing.T) {
+ for _, host := range []string{"shop.example.com", "shop.example.com.g7", strings.Repeat("x", 60) + ".example.com"} {
+ if iid := provision.InstanceID(host); strings.Contains(iid, "@") {
+ t.Fatalf("InstanceID(%q) = %q holds '@'", host, iid)
+ }
+ if strings.Count(GenKey(host, 7), "@") != 1 {
+ t.Errorf("GenKey(%q) = %q", host, GenKey(host, 7))
+ }
+ }
+ if GenLogSocket("shop.example.com", 7) == provision.LogSocket("shop.example.com.g7", "run") {
+ t.Error("a generation log socket met a legacy one")
+ }
+}
+
+// Review focus: the longest host and the largest generation stay inside
+// sockaddr_un and systemd's unit-name limit (UNIT_NAME_MAX 256 with NUL).
+func TestGenerationNamesStayBoundedForTheLongestHost(t *testing.T) {
+ host := strings.Repeat("a-b.", 60) + "example.com"
+ n := uint64(math.MaxUint64)
+ if got := GenLogSocket(host, n); len(got) > provision.MaxUnixPath {
+ t.Errorf("GenLogSocket is %d bytes: %q", len(got), got)
+ }
+ unit := GenUnitName(host, n)
+ if len(unit) > 255 {
+ t.Errorf("GenUnitName is %d bytes: %q", len(unit), unit)
+ }
+ if !strings.HasPrefix(GenInstance(host, n), "_") {
+ t.Errorf("GenInstance(long) = %q, want the hashed instance ID", GenInstance(host, n))
+ }
+ esc, m, ok := ParseGenUnitName(unit)
+ iid, uok := Unescape(esc)
+ if !ok || m != n || !uok || iid != provision.InstanceID(host) {
+ t.Errorf("round trip: %q %d %v -> %q %v", esc, m, ok, iid, uok)
+ }
+}
+
+func TestUnescapeInvertsEscape(t *testing.T) {
+ for _, s := range []string{"bill-2.example.com", "_abcdefghijklmnopqrstuv-0123456789abcdef", "a.b"} {
+ if got, ok := Unescape(Escape(s)); !ok || got != s {
+ t.Errorf("Unescape(Escape(%q)) = %q %v", s, got, ok)
+ }
+ }
+ for _, bad := range []string{`a\x2`, `a\y2d`, `a\xzz`} {
+ if _, ok := Unescape(bad); ok {
+ t.Errorf("Unescape(%q) accepted", bad)
+ }
+ }
+}