provision: instance identifier and nested path helpers (isolated exec plan 1, task 1)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YZ1ZvWj7naf73GjTX8MBv5
2 files changed,
+203
−0
internal/provision/provision.go+100 −0internal/provision/provision_test.go+103 −0
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index c5da151c6..f8cb63063 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -8,6 +8,8 @@ package provision |
| import ( |
| "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| "encoding/json" |
| "errors" |
| "fmt" |
| @@ -1010,3 +1012,101 @@ const SocketDir = "/run/carlos" |
| // used before this helper existed were Join'd; this keeps that.) |
| func DefaultSocket(host string) string { return filepath.Join(SocketDir, host+".sock") } |
| func DefaultDBPath(dataDir, host string) string { return filepath.Join(dataDir, host+".db") } |
| + |
| +// MaxInstanceID bounds InstanceID. A unix socket address holds MaxUnixPath |
| +// bytes of path and a host may be 253 bytes, so the directories a socket |
| +// lives under are keyed by a bounded identifier, never by the host itself |
| +// (isolated-exec design §3.1, codex R5-3). |
| +const MaxInstanceID = 40 |
| + |
| +// MaxUnixPath is sockaddr_un's sun_path (108 bytes) minus the NUL Go |
| +// insists on room for. |
| +const MaxUnixPath = 107 |
| + |
| +// ControlSocketDir and ControlSocketPath are the root instance-control |
| +// channel (design §5). Deliberately NOT under SocketDir: an instance's |
| +// namespace replaces /run/carlos with an empty tmpfs, and nothing an |
| +// instance can reach must resolve to this path. |
| +const ( |
| + ControlSocketDir = "/run/carlos-control" |
| + ControlSocketPath = ControlSocketDir + "/control.sock" |
| +) |
| + |
| +// InstanceID is the bounded per-host identifier every isolated-exec |
| +// directory, socket, unit instance and slice is keyed by. A host of at |
| +// most MaxInstanceID bytes is its own identifier. A longer host becomes |
| +// "_" + its first 22 bytes + "-" + 16 hex digits of its sha256, also |
| +// MaxInstanceID bytes. |
| +// |
| +// The underscore is the whole reason the two forms can share one |
| +// namespace: no valid host contains one (controlname labels are |
| +// [a-z0-9-]), so a hashed identifier can never equal any short host, and |
| +// two hashed identifiers collide only on a 64-bit hash collision between |
| +// two long hosts with the same 22-byte prefix (codex R6-1). Anything that |
| +// finds two rows with one identifier treats them as a conflict, never as |
| +// the same host. |
| +func InstanceID(host string) string { |
| + if len(host) <= MaxInstanceID { |
| + return host |
| + } |
| + sum := sha256.Sum256([]byte(host)) |
| + return "_" + host[:22] + "-" + hex.EncodeToString(sum[:8]) |
| +} |
| + |
| +// InstanceDir is the per-host data directory of the nested layout: |
| +// <dataDir>/instances/<InstanceID(host)>. File names INSIDE it keep the |
| +// full host (a file name may be 255 bytes; a socket path may not). |
| +func InstanceDir(dataDir, host string) string { |
| + return filepath.Join(dataDir, "instances", InstanceID(host)) |
| +} |
| + |
| +// NestedDBPath is the database under InstanceDir. It is what the layout |
| +// pass writes into a migrated row's DBPath; DefaultDBPath stays flat in |
| +// this release (design §8.1). |
| +func NestedDBPath(dataDir, host string) string { |
| + return filepath.Join(InstanceDir(dataDir, host), host+".db") |
| +} |
| + |
| +// InstanceRunDir is the per-host socket directory of the nested layout. |
| +func InstanceRunDir(host string) string { |
| + return filepath.Join(SocketDir, "instances", InstanceID(host)) |
| +} |
| + |
| +// NestedSocket is the instance's listener under InstanceRunDir; its |
| +// control socket is NestedSocket + ".control", as for every exec route. |
| +func NestedSocket(host string) string { |
| + return filepath.Join(InstanceRunDir(host), "app.sock") |
| +} |
| + |
| +// LogSocket is the agent's per-role log listener for a host (design |
| +// §6.4): outside every tenant mount, keyed by the identifier. |
| +func LogSocket(host, role string) string { |
| + return filepath.Join(SocketDir, "logs", InstanceID(host)+"."+role+".sock") |
| +} |
| + |
| +// IsControlEndpoint reports whether path is the control channel or |
| +// anything else under its directory. The edge and the agent refuse to |
| +// dial such a path as an upstream (design §5.4): a registry row pointing |
| +// at it is refused before connect(). |
| +func IsControlEndpoint(path string) bool { |
| + clean := filepath.Clean(path) |
| + return clean == ControlSocketDir || strings.HasPrefix(clean, ControlSocketDir+"/") |
| +} |
| + |
| +// LongestSocketAddress is the longest unix socket path any helper here |
| +// can produce, over the longest valid host, so a test can pin it under |
| +// MaxUnixPath rather than trusting arithmetic in a comment. |
| +func LongestSocketAddress() int { |
| + host := strings.Repeat("x", 253) |
| + longest := 0 |
| + for _, p := range []string{ |
| + NestedSocket(host), |
| + NestedSocket(host) + ".control", |
| + LogSocket(host, "sidecar"), // the longest role name |
| + } { |
| + if len(p) > longest { |
| + longest = len(p) |
| + } |
| + } |
| + return longest |
| +} |
diff --git a/internal/provision/provision_test.go b/internal/provision/provision_test.go| index 407dbb631..595b52611 100644 |
| --- a/internal/provision/provision_test.go |
| +++ b/internal/provision/provision_test.go |
| @@ -1006,3 +1006,106 @@ func TestValidateShapeGate(t *testing.T) { |
| t.Fatalf("plaintext gate accepted: %v", err) |
| } |
| } |
| + |
| +func TestInstanceIDShortHostsAreThemselves(t *testing.T) { |
| + for _, h := range []string{"h", "bill.go.tito.io", strings.Repeat("a", 40)} { |
| + if got := InstanceID(h); got != h { |
| + t.Errorf("InstanceID(%q) = %q, want the host itself", h, got) |
| + } |
| + } |
| +} |
| + |
| +func TestInstanceIDLongHostsAreBoundedAndPrefixed(t *testing.T) { |
| + long := strings.Repeat("a", 60) + ".example.com" |
| + got := InstanceID(long) |
| + if len(got) != MaxInstanceID { |
| + t.Fatalf("InstanceID(long) = %q (%d bytes), want %d", got, len(got), MaxInstanceID) |
| + } |
| + if got[0] != '_' { |
| + t.Errorf("InstanceID(long) = %q, want a leading underscore: no valid host has one", got) |
| + } |
| + if got[1:23] != long[:22] { |
| + t.Errorf("InstanceID(long) = %q, want the host's first 22 bytes after the underscore", got) |
| + } |
| + if got[23] != '-' { |
| + t.Errorf("InstanceID(long) = %q, want a dash before the hash", got) |
| + } |
| + if got != InstanceID(long) { |
| + t.Error("InstanceID is not stable") |
| + } |
| + // Two long hosts sharing a 22-byte prefix get different identifiers. |
| + other := strings.Repeat("a", 60) + ".example.org" |
| + if InstanceID(other) == got { |
| + t.Errorf("InstanceID(%q) == InstanceID(%q); the hash must separate them", long, other) |
| + } |
| +} |
| + |
| +func TestInstanceIDDomainsNeverMeet(t *testing.T) { |
| + // codex R6-1: the hashed form of one host must never equal any valid |
| + // short host. A leading underscore is not a valid host label. |
| + long := strings.Repeat("a", 31) + ".example.com" |
| + if ValidHostShape(InstanceID(long)) { |
| + t.Errorf("InstanceID(%q) = %q is a valid host shape; the domains overlap", long, InstanceID(long)) |
| + } |
| +} |
| + |
| +func TestNestedHelpersAreKeyedByInstanceID(t *testing.T) { |
| + long := strings.Repeat("b", 50) + ".example.com" |
| + iid := InstanceID(long) |
| + if got, want := InstanceDir("/data", long), "/data/instances/"+iid; got != want { |
| + t.Errorf("InstanceDir = %q, want %q", got, want) |
| + } |
| + if got, want := NestedDBPath("/data/", long), "/data/instances/"+iid+"/"+long+".db"; got != want { |
| + t.Errorf("NestedDBPath = %q, want %q (file names keep the full host)", got, want) |
| + } |
| + if got, want := InstanceRunDir(long), "/run/carlos/instances/"+iid; got != want { |
| + t.Errorf("InstanceRunDir = %q, want %q", got, want) |
| + } |
| + if got, want := NestedSocket(long), "/run/carlos/instances/"+iid+"/app.sock"; got != want { |
| + t.Errorf("NestedSocket = %q, want %q", got, want) |
| + } |
| + if got, want := LogSocket(long, "sidecar"), "/run/carlos/logs/"+iid+".sidecar.sock"; got != want { |
| + t.Errorf("LogSocket = %q, want %q", got, want) |
| + } |
| +} |
| + |
| +func TestFlatDefaultsUnchangedThisRelease(t *testing.T) { |
| + // Spec §8.1: nothing mints nested in this release. The flat helpers |
| + // are what routeFor and `carlos add` still use. |
| + if got := DefaultSocket("h"); got != "/run/carlos/h.sock" { |
| + t.Errorf("DefaultSocket = %q", got) |
| + } |
| + if got := DefaultDBPath("/data", "h"); got != "/data/h.db" { |
| + t.Errorf("DefaultDBPath = %q", got) |
| + } |
| +} |
| + |
| +func TestLongestSocketAddressFitsSockaddrUn(t *testing.T) { |
| + // codex R5-3: an 80-byte host overflowed the first draft's paths. The |
| + // bound is computed, not assumed, over a 253-byte host. |
| + if got := LongestSocketAddress(); got > MaxUnixPath { |
| + t.Fatalf("longest socket address is %d bytes, over the %d-byte limit", got, MaxUnixPath) |
| + } |
| + host := strings.Repeat("c", 253) |
| + for _, p := range []string{NestedSocket(host), NestedSocket(host) + ".control", LogSocket(host, "sidecar")} { |
| + if len(p) > MaxUnixPath { |
| + t.Errorf("%q is %d bytes", p, len(p)) |
| + } |
| + } |
| +} |
| + |
| +func TestIsControlEndpoint(t *testing.T) { |
| + for path, want := range map[string]bool{ |
| + ControlSocketPath: true, |
| + "/run/carlos-control/anything": true, |
| + "/run/carlos-control": true, |
| + "/run/carlos/h.sock": false, |
| + "/run/carlos/instances/h/app.sock": false, |
| + "/run/carlos-controlx/control.sock": false, |
| + "/run/titogo/example.sock": false, |
| + } { |
| + if got := IsControlEndpoint(path); got != want { |
| + t.Errorf("IsControlEndpoint(%q) = %v, want %v", path, got, want) |
| + } |
| + } |
| +} |