carlos / platform Public

appconfig: a stamped route is the root pass's; shared shape and name rails (#114)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev d5a8fa1c2e2fe230319f5fba15c4c413f7f6c5b5 parent 3d8a786
7 files changed, +263 −13
  • internal/appconfig/awaitingrestart_test.go +6 −6
  • internal/appconfig/routefingerprint.go +6 −2
  • internal/appconfig/routefingerprint_test.go +32 −0
  • internal/appconfig/stamped.go +90 −0
  • internal/appconfig/stamped_test.go +119 −0
  • internal/appconfig/sync.go +1 −1
  • internal/appconfig/syncexec.go +9 −4
diff --git a/internal/appconfig/awaitingrestart_test.go b/internal/appconfig/awaitingrestart_test.go
index 501ed1d92..53e4aaf0f 100644
--- a/internal/appconfig/awaitingrestart_test.go
+++ b/internal/appconfig/awaitingrestart_test.go
@@ -71,10 +71,10 @@ func TestAwaitingRestartSkipsUnoptedAndSocketRoutes(t *testing.T) {
}
}
-// TestAwaitingRestartCarriesUnit pins the field that decides which remedy
-// the CLI offers. An addr route naming its own unit (issue #110's consoles)
-// is the one addr shape `carlos restart` can actually cycle.
-func TestAwaitingRestartCarriesUnit(t *testing.T) {
+// TestAwaitingRestartLeavesStampedRoutesToTheRootPass: a stamped route is
+// the root pass's (spec §3.1); it is delivered and restarted there, so it
+// is never a host the notice tells someone to restart by hand.
+func TestAwaitingRestartLeavesStampedRoutesToTheRootPass(t *testing.T) {
ctx, s, _, _, _, _ := execKit(t)
if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil {
t.Fatalf("SetExecDelivery: %v", err)
@@ -82,8 +82,8 @@ func TestAwaitingRestartCarriesUnit(t *testing.T) {
rt := addrRoute("console.example.com", "relay", "bab", "")
rt.Unit = "console.service"
got := AwaitingRestart(ctx, s, []registry.Route{rt}, "bab", "relay", "", nil)
- if len(got.Hosts) != 1 || got.Hosts[0].Unit != "console.service" {
- t.Fatalf("Hosts = %+v, want console.service carried through", got.Hosts)
+ if len(got.Hosts) != 0 {
+ t.Fatalf("Hosts = %+v, want none for a stamped route", got.Hosts)
}
}
diff --git a/internal/appconfig/routefingerprint.go b/internal/appconfig/routefingerprint.go
index 10cd89e27..d150d3721 100644
--- a/internal/appconfig/routefingerprint.go
+++ b/internal/appconfig/routefingerprint.go
@@ -68,8 +68,12 @@ func ConfigRouteFingerprints(routes []registry.Route) (exec, unit string) {
if execSyncable(rt) || execAddrCandidate(rt) {
execRows = append(execRows, row)
}
- if syncable(rt) && rt.Backing == registry.BackingUnit {
- unitRows = append(unitRows, row)
+ if unitDelivered(rt) {
+ // Unit is part of the unit row (spec §3.5): a re-stamp to a
+ // different unit changes which drop-in the route belongs in
+ // without changing the env file's bytes, and this digest is
+ // what turns that registry change into a root pass request.
+ unitRows = append(unitRows, row+"|"+rt.Unit)
}
}
return digestRows(execRows), digestRows(unitRows)
diff --git a/internal/appconfig/routefingerprint_test.go b/internal/appconfig/routefingerprint_test.go
index da060b2c3..790f31b47 100644
--- a/internal/appconfig/routefingerprint_test.go
+++ b/internal/appconfig/routefingerprint_test.go
@@ -113,3 +113,35 @@ func TestConfigRouteFingerprintsEmptySetIsStable(t *testing.T) {
t.Fatal("the empty set must have a real digest, not the empty string: the fast loop tells 'never computed' from 'computed, and empty' by its own bool, not by this value")
}
}
+
+func fpStampedRoute(host, unit string) registry.Route {
+ return registry.Route{Host: host, Kind: "instance", App: "woodstar-relay",
+ Account: "bdm", Status: "ready", Addr: "127.0.0.1:8451", Unit: unit}
+}
+
+func TestConfigRouteFingerprintsStampedRouteIsInTheUnitHalf(t *testing.T) {
+ stamped := []registry.Route{fpStampedRoute("relay.bdm.oncarlos.com", "woodstar-relay.service")}
+ none := []registry.Route{}
+ e, u := ConfigRouteFingerprints(stamped)
+ e0, u0 := ConfigRouteFingerprints(none)
+ if e != e0 {
+ t.Fatal("a stamped route must not be in the exec digest")
+ }
+ if u == u0 {
+ t.Fatal("a stamped route must be in the unit digest")
+ }
+ _, u2 := ConfigRouteFingerprints([]registry.Route{fpStampedRoute("relay.bdm.oncarlos.com", "other.service")})
+ if u == u2 {
+ t.Fatal("re-stamping to a different unit must move the unit digest")
+ }
+}
+
+func TestUnitConfigAppsIncludesStampedRoutes(t *testing.T) {
+ got := UnitConfigApps([]registry.Route{fpStampedRoute("relay.bdm.oncarlos.com", "woodstar-relay.service")})
+ if len(got) != 1 || got[0] != [2]string{"bdm", "woodstar-relay"} {
+ t.Fatalf("UnitConfigApps = %v, want [[bdm woodstar-relay]]", got)
+ }
+ if got := ExecConfigApps([]registry.Route{fpStampedRoute("relay.bdm.oncarlos.com", "woodstar-relay.service")}); len(got) != 0 {
+ t.Fatalf("ExecConfigApps = %v, want none for a stamped route", got)
+ }
+}
diff --git a/internal/appconfig/stamped.go b/internal/appconfig/stamped.go
new file mode 100644
index 000000000..39e9acda1
--- /dev/null
+++ b/internal/appconfig/stamped.go
@@ -0,0 +1,90 @@
+// The stamped shape: a route that names the systemd unit owning its
+// process (registry.Route.Unit, issue #110). Config delivery for it is the
+// ROOT pass's (spec 2026-09-07-named-unit-config-delivery-design.md §3.1):
+// the agent's exec sweep never touches it. The predicates and rails here
+// are shared by Sync (the pass), AwaitingRestart (the notice) and the CLI
+// doors (`carlos add`, `carlos route --unit/--hibernate`), so that the
+// three cannot disagree on which routes are stamped, or on why one is
+// refused.
+package appconfig
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// Stamped reports whether rt names its own unit and is a route the root
+// pass may deliver config to. NOT Adoptable narrowed: `carlos route --unit`
+// admits service and platform rows (cli.go unitRail), so delivery admits
+// them too, or a supported stamp would belong to neither delivery half.
+// A row that is not ready is left alone as everywhere else.
+func Stamped(rt registry.Route) bool {
+ return rt.Unit != "" && rt.App != "" &&
+ (rt.Kind == "instance" || rt.Kind == "service") &&
+ (rt.Status == "" || rt.Status == "ready")
+}
+
+// unitDelivered is Sync's eligibility: the carlos-app@ shape (unit backing
+// on an adoptable row) or a stamped route. UnitConfigApps and the unit
+// fingerprint use the same test, for the reason each says beside itself.
+func unitDelivered(rt registry.Route) bool {
+ return (syncable(rt) && rt.Backing == registry.BackingUnit) || Stamped(rt)
+}
+
+// StampShape is the supported shape of a stamped route (spec §3.1): an
+// addr upstream, no socket, no hibernation. A socket route is the
+// activator's to spawn, so no unit can own it; a hibernating route is woken
+// by the activator, and a config restart would start it behind the
+// activator's back. The error text is the refusal reason the pass logs and
+// the notice reports.
+func StampShape(rt registry.Route) error {
+ switch {
+ case rt.Socket != "":
+ return fmt.Errorf("names unit %s but has a socket upstream: a socket route is the activator's to spawn, so no unit can own it", rt.Unit)
+ case rt.Addr == "" && rt.Addrs == "":
+ return fmt.Errorf("names unit %s but has no addr upstream", rt.Unit)
+ case rt.Hibernate:
+ return fmt.Errorf("names unit %s and hibernates: a config restart would start it behind the activator's back", rt.Unit)
+ }
+ return nil
+}
+
+// DeliverableUnitName is the rail at the root write (spec §3.2): a concrete
+// service name whose drop-in directory applies to that unit alone. A
+// template name (carlos-app@.service) has a directory systemd applies to
+// every instance; a prefix name (carlos-.service) has one systemd applies
+// to every unit sharing the prefix; an instance name (x@y.service) is
+// refused with them for simplicity, since nothing stamps one.
+func DeliverableUnitName(unit string) error {
+ if unit == "" || !registry.ValidUnitName(unit) {
+ return fmt.Errorf("%q is not a systemd service unit name", unit)
+ }
+ if strings.Contains(unit, "@") {
+ return fmt.Errorf("%s is a template or instance name; its drop-in directory would apply to every instance of the template", unit)
+ }
+ if strings.HasSuffix(strings.TrimSuffix(unit, ".service"), "-") {
+ return fmt.Errorf("%s is a prefix name; systemd applies its drop-in directory to every unit sharing the prefix", unit)
+ }
+ return nil
+}
+
+var hostLabelRe = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?$`)
+
+// BareHostname: labels of [A-Za-z0-9-] joined by '.', no label empty or
+// edged with '-', at most 253 bytes. syncHost's basename check stops a path
+// separator; this stops a newline, which matters once a host is written
+// into a file systemd parses as directives (spec §3.2).
+func BareHostname(host string) bool {
+ if host == "" || len(host) > 253 {
+ return false
+ }
+ for _, l := range strings.Split(host, ".") {
+ if !hostLabelRe.MatchString(l) {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/appconfig/stamped_test.go b/internal/appconfig/stamped_test.go
new file mode 100644
index 000000000..61c6a55ee
--- /dev/null
+++ b/internal/appconfig/stamped_test.go
@@ -0,0 +1,119 @@
+package appconfig
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+func stampedRoute(host, unit string) registry.Route {
+ return registry.Route{Host: host, Kind: "instance", App: "relay", Account: "bab",
+ Status: "ready", Addr: "127.0.0.1:8451", Unit: unit}
+}
+
+func TestStampedAdmitsInstanceServiceAndPlatformRows(t *testing.T) {
+ inst := stampedRoute("relay.example.com", "relay.service")
+ svc := inst
+ svc.Kind = "service"
+ plat := inst
+ plat.Platform = true
+ for name, rt := range map[string]registry.Route{"instance": inst, "service": svc, "platform": plat} {
+ if !Stamped(rt) {
+ t.Errorf("%s: Stamped = false, want true", name)
+ }
+ if !unitDelivered(rt) {
+ t.Errorf("%s: unitDelivered = false, want true", name)
+ }
+ }
+}
+
+func TestStampedRejectsUnstampedNotReadyStaticAndAppless(t *testing.T) {
+ base := stampedRoute("relay.example.com", "relay.service")
+ cases := map[string]func(registry.Route) registry.Route{
+ "no unit": func(r registry.Route) registry.Route { r.Unit = ""; return r },
+ "pending": func(r registry.Route) registry.Route { r.Status = "pending"; return r },
+ "static": func(r registry.Route) registry.Route { r.Kind = "static"; return r },
+ "no app": func(r registry.Route) registry.Route { r.App = ""; return r },
+ }
+ for name, mut := range cases {
+ if Stamped(mut(base)) {
+ t.Errorf("%s: Stamped = true, want false", name)
+ }
+ }
+}
+
+func TestExecRouteBaseExcludesStampedRoutes(t *testing.T) {
+ rt := stampedRoute("relay.example.com", "relay.service")
+ if execRouteBase(rt) {
+ t.Fatal("execRouteBase admitted a stamped route; the root pass owns it")
+ }
+ if execAddrCandidate(rt) {
+ t.Fatal("execAddrCandidate admitted a stamped route")
+ }
+ rt.Unit = ""
+ if !execAddrCandidate(rt) {
+ t.Fatal("the same route without a stamp must still be an exec addr candidate")
+ }
+}
+
+func TestStampShape(t *testing.T) {
+ ok := stampedRoute("relay.example.com", "relay.service")
+ if err := StampShape(ok); err != nil {
+ t.Fatalf("StampShape(addr route) = %v, want nil", err)
+ }
+ addrs := ok
+ addrs.Addr, addrs.Addrs = "", "127.0.0.1:1,127.0.0.1:2"
+ if err := StampShape(addrs); err != nil {
+ t.Fatalf("StampShape(addrs route) = %v, want nil", err)
+ }
+ sock := ok
+ sock.Socket = "/run/x.sock"
+ if err := StampShape(sock); err == nil || !strings.Contains(err.Error(), "socket") {
+ t.Fatalf("StampShape(socket route) = %v, want a socket refusal", err)
+ }
+ hib := ok
+ hib.Hibernate = true
+ if err := StampShape(hib); err == nil || !strings.Contains(err.Error(), "hibernat") {
+ t.Fatalf("StampShape(hibernating route) = %v, want a hibernate refusal", err)
+ }
+ none := ok
+ none.Addr = ""
+ if err := StampShape(none); err == nil || !strings.Contains(err.Error(), "addr") {
+ t.Fatalf("StampShape(no upstream) = %v, want an addr refusal", err)
+ }
+}
+
+func TestDeliverableUnitName(t *testing.T) {
+ for _, good := range []string{"relay.service", "woodstar-relay.service", "console.service", "a_b.c.service"} {
+ if err := DeliverableUnitName(good); err != nil {
+ t.Errorf("%s: %v, want accepted", good, err)
+ }
+ }
+ for name, want := range map[string]string{
+ "carlos-app@.service": "template",
+ "carlos-app@x.service": "template",
+ "carlos-.service": "prefix",
+ "": "unit name",
+ "relay": "unit name",
+ "a/b.service": "unit name",
+ } {
+ err := DeliverableUnitName(name)
+ if err == nil || !strings.Contains(err.Error(), want) {
+ t.Errorf("%q: %v, want a refusal mentioning %q", name, err, want)
+ }
+ }
+}
+
+func TestBareHostname(t *testing.T) {
+ for _, good := range []string{"relay.example.com", "a", "woodstar-relay.bdm.oncarlos.com", "X1.y2"} {
+ if !BareHostname(good) {
+ t.Errorf("%q: want bare", good)
+ }
+ }
+ for _, bad := range []string{"", ".", "..", "a..b", "-a.b", "a-.b", "a\nUser=root", "a b", "a/b", strings.Repeat("a", 254)} {
+ if BareHostname(bad) {
+ t.Errorf("%q: want refused", bad)
+ }
+ }
+}
diff --git a/internal/appconfig/sync.go b/internal/appconfig/sync.go
index 4fc477344..608c9dc01 100644
--- a/internal/appconfig/sync.go
+++ b/internal/appconfig/sync.go
@@ -300,7 +300,7 @@ func UnitConfigApps(routes []registry.Route) [][2]string {
seen := map[[2]string]bool{}
var out [][2]string
for _, rt := range routes {
- if !syncable(rt) || rt.Backing != registry.BackingUnit {
+ if !unitDelivered(rt) {
continue
}
k := [2]string{release.RouteAccount(rt), rt.App}
diff --git a/internal/appconfig/syncexec.go b/internal/appconfig/syncexec.go
index 239c03e76..4591b89c4 100644
--- a/internal/appconfig/syncexec.go
+++ b/internal/appconfig/syncexec.go
@@ -472,13 +472,18 @@ func SyncExec(ctx context.Context, reg *registry.Registry, s release.Store, priv
// execRouteBase is the lifecycle-managed-instance shape both exec route
// kinds share: the unit path's syncable() exclusions, narrowed to
-// non-unit backing (unit routes belong to the root adopt path). Neither
-// SyncExec eligibility case on its own — see execSyncable and
-// execAddrCandidate, which each narrow it by upstream shape.
+// non-unit backing (unit routes belong to the root adopt path). A STAMPED
+// route (rt.Unit != "") belongs to the root pass too, whatever its backing:
+// see stamped.go and spec §3.1. Excluding it here removes it from
+// execSyncable, execAddrCandidate, ExecConfigApps, the exec fingerprint and
+// AwaitingRestart's delivered-to set at once. Neither SyncExec eligibility
+// case on its own — see execSyncable and execAddrCandidate, which each narrow
+// it by upstream shape.
func execRouteBase(rt registry.Route) bool {
return rt.Kind == "instance" && rt.App != "" && !rt.Platform &&
(rt.Status == "" || rt.Status == "ready") &&
- rt.Backing != registry.BackingUnit
+ rt.Backing != registry.BackingUnit &&
+ rt.Unit == ""
}
// execSyncable: lifecycle-managed instances the ACTIVATOR spawns — and