appconfig: the notice says when a write reached nothing or a stamp is blocked
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 files changed,
+167
−24
internal/appconfig/awaitingrestart_test.go+90 −5internal/appconfig/syncexec.go+77 −19
diff --git a/internal/appconfig/awaitingrestart_test.go b/internal/appconfig/awaitingrestart_test.go| index 53e4aaf0f..91e782d6b 100644 |
| --- a/internal/appconfig/awaitingrestart_test.go |
| +++ b/internal/appconfig/awaitingrestart_test.go |
| @@ -3,6 +3,7 @@ package appconfig |
| import ( |
| "encoding/json" |
| "errors" |
| + "strings" |
| "testing" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -62,12 +63,13 @@ func TestAwaitingRestartSkipsUnoptedAndSocketRoutes(t *testing.T) { |
| } |
| // The same addr route, with the app never opted in: no file was ever |
| - // written for it, so there is nothing to restart over. |
| + // written for it, so there is nothing to restart over — but the write |
| + // still reached nothing here, which Undelivered now says. |
| ctx2, s2, _, _, _, _ := execKit(t) |
| got = AwaitingRestart(ctx2, s2, []registry.Route{addrRoute("connect.example.com", "relay", "bab", "")}, |
| "bab", "relay", "", []string{"K"}) |
| - if !got.Empty() { |
| - t.Fatalf("got %+v, want nothing for an app that never opted in", got) |
| + if len(got.Hosts) != 0 || len(got.Undelivered) != 1 || got.Undelivered[0] != "connect.example.com" { |
| + t.Fatalf("got %+v, want no Hosts and connect.example.com in Undelivered for an app that never opted in", got) |
| } |
| } |
| @@ -232,8 +234,9 @@ func TestAwaitingRestartUnknownExecDeliveryIsOff(t *testing.T) { |
| t.Fatalf("Put: %v", err) |
| } |
| routes := []registry.Route{addrRoute("connect.example.com", "relay", "bab", "")} |
| - if got := AwaitingRestart(ctx, s, routes, "bab", "relay", "", nil); !got.Empty() { |
| - t.Fatalf("got %+v, want nothing for an exec_delivery value this binary does not understand", got) |
| + got := AwaitingRestart(ctx, s, routes, "bab", "relay", "", nil) |
| + if len(got.Hosts) != 0 || len(got.Undelivered) != 1 || got.Undelivered[0] != "connect.example.com" { |
| + t.Fatalf("got %+v, want no Hosts and connect.example.com in Undelivered for an exec_delivery value this binary does not understand", got) |
| } |
| } |
| @@ -261,3 +264,85 @@ func TestAwaitingRestartSorted(t *testing.T) { |
| } |
| } |
| } |
| + |
| +func TestAwaitingRestartListsUnoptedAddrRouteAsUndelivered(t *testing.T) { |
| + ctx, s, _, _, _, _ := execKit(t) |
| + if err := SetEnv(ctx, s, "bab", "relay", map[string]string{"K": "v"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + routes := []registry.Route{addrRoute("plain.example.com", "relay", "bab", "")} |
| + got := AwaitingRestart(ctx, s, routes, "bab", "relay", "", []string{"K"}) |
| + if len(got.Undelivered) != 1 || got.Undelivered[0] != "plain.example.com" { |
| + t.Fatalf("Undelivered = %v, want [plain.example.com]", got.Undelivered) |
| + } |
| + if len(got.Hosts) != 0 || got.Empty() { |
| + t.Fatalf("Hosts = %v, Empty = %v", got.Hosts, got.Empty()) |
| + } |
| +} |
| + |
| +func TestAwaitingRestartStampedRouteWithMissingBundleIsUnresolved(t *testing.T) { |
| + ctx, s, _, _, _, _ := execKit(t) |
| + if err := SetEnv(ctx, s, "bab", "relay", map[string]string{"K": "v"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + rt := addrRoute("relay.example.com", "relay", "bab", "missing") |
| + rt.Unit = "relay.service" |
| + got := AwaitingRestart(ctx, s, []registry.Route{rt}, "bab", "relay", "", []string{"K"}) |
| + if len(got.Unresolved) != 1 || got.Unresolved[0] != "relay.example.com" { |
| + t.Fatalf("Unresolved = %v, want the stamped route with a missing bound bundle", got.Unresolved) |
| + } |
| + if len(got.Hosts) != 0 || len(got.Undelivered) != 0 || len(got.Blocked) != 0 { |
| + t.Fatalf("stamped route leaked into another set: %+v", got) |
| + } |
| +} |
| + |
| +func TestAwaitingRestartHealthyStampedRouteIsListedNowhere(t *testing.T) { |
| + ctx, s, _, _, _, _ := execKit(t) |
| + if err := SetEnv(ctx, s, "bab", "relay", map[string]string{"K": "v"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + rt := addrRoute("relay.example.com", "relay", "bab", "") |
| + rt.Unit = "relay.service" |
| + got := AwaitingRestart(ctx, s, []registry.Route{rt}, "bab", "relay", "", []string{"K"}) |
| + if !got.Empty() { |
| + t.Fatalf("healthy stamped route must be silent, got %+v", got) |
| + } |
| +} |
| + |
| +func TestAwaitingRestartBlockedStamps(t *testing.T) { |
| + ctx, s, _, _, _, _ := execKit(t) |
| + if err := SetEnv(ctx, s, "bab", "relay", map[string]string{"K": "v"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + sock := addrRoute("sock.example.com", "relay", "bab", "") |
| + sock.Unit, sock.Addr, sock.Socket = "sock.service", "", "/run/x.sock" |
| + tmpl := addrRoute("tmpl.example.com", "relay", "bab", "") |
| + tmpl.Unit = "carlos-app@.service" |
| + a := addrRoute("a.example.com", "relay", "bab", "") |
| + a.Unit = "shared.service" |
| + // The duplicate is another ACCOUNT's route: it blocks a but is not disclosed. |
| + other := addrRoute("other.example.com", "relay", "bae", "") |
| + other.Unit = "shared.service" |
| + got := AwaitingRestart(ctx, s, []registry.Route{sock, tmpl, a, other}, "bab", "relay", "", []string{"K"}) |
| + byHost := map[string]string{} |
| + for _, b := range got.Blocked { |
| + byHost[b.Host] = b.Reason |
| + } |
| + if len(byHost) != 3 { |
| + t.Fatalf("Blocked = %+v, want sock, tmpl and a", got.Blocked) |
| + } |
| + if !strings.Contains(byHost["sock.example.com"], "socket") || |
| + !strings.Contains(byHost["tmpl.example.com"], "template") || |
| + !strings.Contains(byHost["a.example.com"], "shared.service") { |
| + t.Fatalf("reasons = %v", byHost) |
| + } |
| + if _, leaked := byHost["other.example.com"]; leaked { |
| + t.Fatal("another account's route must not be disclosed") |
| + } |
| + if strings.Contains(byHost["a.example.com"], "other.example.com") { |
| + t.Fatal("the reason must not name another account's host") |
| + } |
| + if got.Empty() { |
| + t.Fatal("Blocked must count as something to say") |
| + } |
| +} |
diff --git a/internal/appconfig/syncexec.go b/internal/appconfig/syncexec.go| index 4591b89c4..fe5b56ec5 100644 |
| --- a/internal/appconfig/syncexec.go |
| +++ b/internal/appconfig/syncexec.go |
| @@ -639,25 +639,67 @@ type RestartHost struct { |
| Unit string |
| } |
| -// RestartPending is what a config write left behind on ONE box: files that |
| -// landed for processes carlos does not own, and therefore does not restart. |
| +// BlockedHost is a stamped route the root pass will refuse for a reason |
| +// knowable from the registry alone (spec §3.4, codex R2-6): the shape |
| +// rail, the name rail, the hostname rail or the one-route-per-unit rule. |
| +// The systemd-side refusals (alias, load state, the EnvironmentFiles |
| +// verification) are not knowable at the door and are not claimed here. |
| +type BlockedHost struct { |
| + Host string |
| + Reason string |
| +} |
| + |
| +// RestartPending is what a config write left behind on ONE box. |
| // |
| -// Unresolved is the honest half and the reason this is not a plain |
| -// []string. A route whose exec_delivery could not be resolved this call — |
| -// its bundle unreadable, or the bundle it is bound to missing outright — |
| -// is a route nothing can say "delivered" or "not delivered" about. Dropping |
| -// it silently would commit the very conflation this notice exists to end: |
| -// "nothing needs a restart" and "I could not tell" rendered identically is |
| -// how discussion #16 got filed in the first place. |
| +// Hosts: files that landed for processes carlos does not own, and |
| +// therefore does not restart. Unresolved: routes nothing can say |
| +// "delivered" or "not delivered" about. Undelivered: routes this write |
| +// reached that no pass delivers a file to (an un-opted addr route with no |
| +// stamp). Blocked: stamped routes the root pass will refuse. Each is its |
| +// own list because the honest sentence differs, and "could not tell" is |
| +// never rendered as any of the others. |
| type RestartPending struct { |
| - Hosts []RestartHost |
| - Unresolved []string |
| + Hosts []RestartHost |
| + Unresolved []string |
| + Undelivered []string |
| + Blocked []BlockedHost |
| } |
| -// Empty reports whether there is nothing to say — no host delivered to and |
| -// none unresolved. The renderers' one test, so "say nothing" is one |
| -// judgement rather than four. |
| -func (r RestartPending) Empty() bool { return len(r.Hosts) == 0 && len(r.Unresolved) == 0 } |
| +// Empty reports whether there is nothing to say. The renderers' one test. |
| +func (r RestartPending) Empty() bool { |
| + return len(r.Hosts) == 0 && len(r.Unresolved) == 0 && len(r.Undelivered) == 0 && len(r.Blocked) == 0 |
| +} |
| + |
| +// BlockedStamps applies the door-side rails to the stamped routes of |
| +// (acct, app) in routes. Uniqueness is checked against the FULL list, so |
| +// a route of another account blocks this one, but only this account's |
| +// hosts are returned and no other account's host is named in a reason. |
| +func BlockedStamps(routes []registry.Route, acct, app string) []BlockedHost { |
| + byUnit := map[string]int{} |
| + for _, rt := range routes { |
| + if Stamped(rt) && StampShape(rt) == nil && DeliverableUnitName(rt.Unit) == nil && BareHostname(rt.Host) { |
| + byUnit[rt.Unit]++ |
| + } |
| + } |
| + var out []BlockedHost |
| + for _, rt := range routes { |
| + if !Stamped(rt) || rt.App != app || release.RouteAccount(rt) != acct { |
| + continue |
| + } |
| + switch { |
| + case StampShape(rt) != nil: |
| + out = append(out, BlockedHost{rt.Host, StampShape(rt).Error()}) |
| + case DeliverableUnitName(rt.Unit) != nil: |
| + out = append(out, BlockedHost{rt.Host, DeliverableUnitName(rt.Unit).Error()}) |
| + case !BareHostname(rt.Host): |
| + out = append(out, BlockedHost{rt.Host, fmt.Sprintf("%q is not a bare hostname", rt.Host)}) |
| + case byUnit[rt.Unit] > 1: |
| + out = append(out, BlockedHost{rt.Host, fmt.Sprintf("two or more routes on this box name %s; one unit takes one route's config", rt.Unit)}) |
| + } |
| + } |
| + sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host }) |
| + return out |
| +} |
| // AwaitingRestart answers, for a config write to app's envName bundle |
| // ("" = the default), which of this box's routes were handed a file that |
| @@ -686,6 +728,11 @@ func (r RestartPending) Empty() bool { return len(r.Hosts) == 0 && len(r.Unresol |
| // short list read as a complete one. |
| func AwaitingRestart(ctx context.Context, s release.Store, routes []registry.Route, acct, app, envName string, keys []string) RestartPending { |
| var out RestartPending |
| + out.Blocked = BlockedStamps(routes, acct, app) |
| + blocked := map[string]bool{} |
| + for _, b := range out.Blocked { |
| + blocked[b.Host] = true |
| + } |
| // Both caches exist for the same reason SyncExec's do: a box with many |
| // TCP-addr routes on one bundle must not cost more than one read for |
| // it. Unlike SyncExec's, they live for one call. |
| @@ -718,7 +765,8 @@ func AwaitingRestart(ctx context.Context, s release.Store, routes []registry.Rou |
| return e |
| } |
| for _, rt := range routes { |
| - if !execAddrCandidate(rt) || rt.App != app || release.RouteAccount(rt) != acct { |
| + stamped := Stamped(rt) && !blocked[rt.Host] |
| + if (!execAddrCandidate(rt) && !stamped) || rt.App != app || release.RouteAccount(rt) != acct { |
| continue |
| } |
| // Which routes a write REACHES. A named bundle is read by the |
| @@ -751,10 +799,19 @@ func AwaitingRestart(ctx context.Context, s release.Store, routes []registry.Rou |
| out.Unresolved = append(out.Unresolved, rt.Host) |
| continue |
| } |
| - // Not opted in — including the Unknown case, which decides "off" |
| - // — means no file was ever written for this route, by this write |
| - // or any other. Nothing to say. |
| + // A stamped route is delivered and restarted by the root pass within |
| + // seconds of this write; it has nothing to be told about unless its |
| + // bundle would not resolve, which the checks above have already |
| + // reported (spec §3.4, codex R1-8). |
| + if stamped { |
| + continue |
| + } |
| + // Not opted in — including the Unknown case, which decides "off" — |
| + // means no pass delivers a file to this route: this write reached |
| + // nothing here. Say so (spec §3.4, codex R1-9: "this write", not |
| + // "no file" — an opted-out route may hold a file from before). |
| if !v.On { |
| + out.Undelivered = append(out.Undelivered, rt.Host) |
| continue |
| } |
| // The shadowing test, and the reason keys is a parameter: a write |
| @@ -778,6 +835,7 @@ func AwaitingRestart(ctx context.Context, s release.Store, routes []registry.Rou |
| // ExecConfigApps'. |
| sort.Slice(out.Hosts, func(i, j int) bool { return out.Hosts[i].Host < out.Hosts[j].Host }) |
| sort.Strings(out.Unresolved) |
| + sort.Strings(out.Undelivered) |
| return out |
| } |