zdd plan 2 task 6: generation unit, launch, exit-record and log-socket names
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+231
−0
internal/instancectl/names.go+108 −0internal/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) |
| + } |
| + } |
| +} |