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>
7 files changed,
+263
−13
internal/appconfig/awaitingrestart_test.go+6 −6internal/appconfig/routefingerprint.go+6 −2internal/appconfig/routefingerprint_test.go+32 −0internal/appconfig/stamped.go+90 −0internal/appconfig/stamped_test.go+119 −0internal/appconfig/sync.go+1 −1internal/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 |