notice: undelivered and blocked over both wire types, rendered by the CLI (copy pending copy-review)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5 files changed,
+123
−8
apimode.go+11 −0appconfig_cli.go+18 −6appconfig_restart_test.go+35 −0internal/console/apiconfig.go+22 −2internal/console/apiconfig_restart_test.go+37 −0
diff --git a/apimode.go b/apimode.go| index 226121240..50f26256a 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -2197,6 +2197,17 @@ type apiConfigRestart struct { |
| Known bool `json:"known"` |
| Hosts []apiConfigRestartHost `json:"hosts"` |
| Unresolved []string `json:"unresolved"` |
| + // Undelivered and Blocked are additive (spec ยง3.4): an older console |
| + // sends neither and this binary prints nothing for them. |
| + Undelivered []string `json:"undelivered,omitempty"` |
| + Blocked []apiConfigBlockedHost `json:"blocked,omitempty"` |
| +} |
| + |
| +// apiConfigBlockedHost is a stamped route the root pass will refuse, with |
| +// the door-side reason. |
| +type apiConfigBlockedHost struct { |
| + Host string `json:"host"` |
| + Reason string `json:"reason"` |
| } |
| // apiConfigRestartHost is one delivered-to route. Unit decides which remedy |
diff --git a/appconfig_cli.go b/appconfig_cli.go| index a23bf26c1..17f4d7d3e 100644 |
| --- a/appconfig_cli.go |
| +++ b/appconfig_cli.go |
| @@ -49,6 +49,10 @@ func restartFromAPI(r *apiConfigRestart) *restartAnswer { |
| for _, h := range r.Hosts { |
| out.pending.Hosts = append(out.pending.Hosts, appconfig.RestartHost{Host: h.Host, Unit: h.Unit}) |
| } |
| + out.pending.Undelivered = r.Undelivered |
| + for _, b := range r.Blocked { |
| + out.pending.Blocked = append(out.pending.Blocked, appconfig.BlockedHost{Host: b.Host, Reason: b.Reason}) |
| + } |
| return out |
| } |
| @@ -113,6 +117,12 @@ func printRestartNotice(w io.Writer, app string, a *restartAnswer) { |
| for _, h := range a.pending.Unresolved { |
| fmt.Fprintf(w, "%s %s\n", c.warn("note:"), fmt.Sprintf(restartCopyUnresolved, h)) |
| } |
| + for _, h := range a.pending.Undelivered { |
| + fmt.Fprintf(w, "%s %s\n", c.warn("note:"), fmt.Sprintf(restartCopyUndelivered, h, app)) |
| + } |
| + for _, b := range a.pending.Blocked { |
| + fmt.Fprintf(w, "%s %s\n", c.warn("note:"), fmt.Sprintf(restartCopyBlocked, b.Host, b.Reason)) |
| + } |
| // Once, under the list, rather than on every line: the caveat is about |
| // the LIST โ what this box could see โ so repeating it per host would be |
| // three copies of one sentence saying nothing more the third time. |
| @@ -148,12 +158,14 @@ func checkNoEmptyValues(kv map[string]string) error { |
| const ( |
| // Carries its own "secrets set:" prefix, so its one call site prints it |
| // bare rather than prefixing it again. |
| - secretsCopyEmpty = "secrets set: %s: empty value: nothing stored. To remove a secret use `carlos secrets unset`; for a deliberately empty value use `carlos env set`. (A command substitution that produced nothing is the usual cause.)" |
| - restartCopyPlain = "%s has the file but is still running the old values. carlos routes to this process without owning it. Restart to fix." |
| - restartCopyUnit = "%s has the file but is still running the old values. carlos restart --app %s cycles %s." |
| - restartCopyUnresolved = "could not tell whether %s picked this up. Its config bundle would not load." |
| - restartCopyUnknown = "could not check which routes are still running the old values. This console cannot see the route registry." |
| - restartCopyScope = "(this machine's routes only)" |
| + secretsCopyEmpty = "secrets set: %s: empty value: nothing stored. To remove a secret use `carlos secrets unset`; for a deliberately empty value use `carlos env set`. (A command substitution that produced nothing is the usual cause.)" |
| + restartCopyPlain = "%s has the file but is still running the old values. carlos routes to this process without owning it. Restart to fix." |
| + restartCopyUnit = "%s has the file but is still running the old values. carlos restart --app %s cycles %s." |
| + restartCopyUnresolved = "could not tell whether %s picked this up. Its config bundle would not load." |
| + restartCopyUnknown = "could not check which routes are still running the old values. This console cannot see the route registry." |
| + restartCopyScope = "(this machine's routes only)" |
| + restartCopyUndelivered = "this write did not reach %s. No pass delivers config to it. If a systemd unit runs its process, name it on the box (carlos route --unit); if the app's own supervisor reads a delivered file, opt the bundle in (carlos env exec-delivery --app %s file)." |
| + restartCopyBlocked = "delivery to %s is blocked: %s" |
| ) |
| const envUsage = `usage: carlos env <command> |
diff --git a/appconfig_restart_test.go b/appconfig_restart_test.go| index 7ebdff591..a25e0ad5d 100644 |
| --- a/appconfig_restart_test.go |
| +++ b/appconfig_restart_test.go |
| @@ -140,3 +140,38 @@ func TestRestartFromAPIPreservesNil(t *testing.T) { |
| t.Fatalf("hosts = %+v, want the unit carried through", got.pending.Hosts) |
| } |
| } |
| + |
| +func TestRestartFromAPICarriesUndeliveredAndBlocked(t *testing.T) { |
| + got := restartFromAPI(&apiConfigRestart{Known: true, |
| + Undelivered: []string{"plain.example.com"}, |
| + Blocked: []apiConfigBlockedHost{{Host: "a.example.com", Reason: "two or more routes on this box name shared.service"}}, |
| + }) |
| + if !got.known || len(got.pending.Undelivered) != 1 || len(got.pending.Blocked) != 1 || |
| + got.pending.Blocked[0].Reason == "" { |
| + t.Fatalf("restartFromAPI = %+v", got) |
| + } |
| +} |
| + |
| +func TestPrintRestartNoticeUndeliveredAndBlocked(t *testing.T) { |
| + var b bytes.Buffer |
| + printRestartNotice(&b, "relay", &restartAnswer{known: true, pending: appconfig.RestartPending{ |
| + Undelivered: []string{"plain.example.com"}, |
| + Blocked: []appconfig.BlockedHost{{Host: "a.example.com", Reason: "two or more routes on this box name shared.service; one unit takes one route's config"}}, |
| + }}) |
| + out := b.String() |
| + for _, want := range []string{"note:", "did not reach plain.example.com", "carlos route --unit", "exec-delivery --app relay file", |
| + "blocked", "a.example.com", "shared.service", restartCopyScope} { |
| + if !strings.Contains(out, want) { |
| + t.Errorf("output lacks %q:\n%s", want, out) |
| + } |
| + } |
| +} |
| + |
| +func TestPrintRestartNoticeStillRendersAnOldConsolesUnitRemedy(t *testing.T) { |
| + var b bytes.Buffer |
| + printRestartNotice(&b, "relay", restartFromAPI(&apiConfigRestart{Known: true, |
| + Hosts: []apiConfigRestartHost{{Host: "console.example.com", Unit: "console.service"}}})) |
| + if !strings.Contains(b.String(), "carlos restart --app relay cycles console.service") { |
| + t.Fatalf("old-console unit remedy lost:\n%s", b.String()) |
| + } |
| +} |
diff --git a/internal/console/apiconfig.go b/internal/console/apiconfig.go| index 24d7911d4..f81e6b9d6 100644 |
| --- a/internal/console/apiconfig.go |
| +++ b/internal/console/apiconfig.go |
| @@ -502,6 +502,11 @@ type apiConfigRestart struct { |
| // Unresolved got neither a yes nor a no: their bundle would not read, |
| // or the bundle they are bound to is missing. |
| Unresolved []string `json:"unresolved,omitempty"` |
| + // Undelivered: routes this write reached that no pass delivers to. |
| + // Blocked: stamped routes the root pass will refuse, with the reason. |
| + // Both omitted when empty so an older CLI sees exactly what it saw. |
| + Undelivered []string `json:"undelivered,omitempty"` |
| + Blocked []apiConfigBlockedHost `json:"blocked,omitempty"` |
| } |
| type apiConfigRestartHost struct { |
| @@ -509,6 +514,13 @@ type apiConfigRestartHost struct { |
| Unit string `json:"unit,omitempty"` |
| } |
| +// apiConfigBlockedHost is a stamped route the root pass will refuse, with |
| +// the door-side reason. |
| +type apiConfigBlockedHost struct { |
| + Host string `json:"host"` |
| + Reason string `json:"reason"` |
| +} |
| + |
| // configRestartNotice resolves the notice for one config write, or reports |
| // that it could not. |
| // |
| @@ -534,11 +546,19 @@ func (s *Server) configRestartNotice(ctx context.Context, acct, app, envName str |
| log.Printf("console: api config: registry list: %v", err) |
| return apiConfigRestart{} |
| } |
| - p := appconfig.AwaitingRestart(ctx, s.cfg.Releases, routes, acct, app, envName, keys) |
| - out := apiConfigRestart{Known: true, Unresolved: p.Unresolved} |
| + return restartWire(appconfig.AwaitingRestart(ctx, s.cfg.Releases, routes, acct, app, envName, keys)) |
| +} |
| + |
| +// restartWire is the wire shape of one RestartPending, Known: this box |
| +// looked. Pure, so the field-by-field copy is testable without a server. |
| +func restartWire(p appconfig.RestartPending) apiConfigRestart { |
| + out := apiConfigRestart{Known: true, Unresolved: p.Unresolved, Undelivered: p.Undelivered} |
| for _, h := range p.Hosts { |
| out.Hosts = append(out.Hosts, apiConfigRestartHost{Host: h.Host, Unit: h.Unit}) |
| } |
| + for _, b := range p.Blocked { |
| + out.Blocked = append(out.Blocked, apiConfigBlockedHost{Host: b.Host, Reason: b.Reason}) |
| + } |
| return out |
| } |
diff --git a/internal/console/apiconfig_restart_test.go b/internal/console/apiconfig_restart_test.go| new file mode 100644 |
| index 000000000..5bbef378f |
| --- /dev/null |
| +++ b/internal/console/apiconfig_restart_test.go |
| @@ -0,0 +1,37 @@ |
| +package console |
| + |
| +import ( |
| + "encoding/json" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/appconfig" |
| +) |
| + |
| +func TestRestartWireCarriesEveryList(t *testing.T) { |
| + got := restartWire(appconfig.RestartPending{ |
| + Hosts: []appconfig.RestartHost{{Host: "h", Unit: "u.service"}}, |
| + Unresolved: []string{"x"}, |
| + Undelivered: []string{"plain"}, |
| + Blocked: []appconfig.BlockedHost{{Host: "a", Reason: "socket"}}, |
| + }) |
| + raw, err := json.Marshal(got) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var back map[string]any |
| + json.Unmarshal(raw, &back) |
| + for _, k := range []string{"known", "hosts", "unresolved", "undelivered", "blocked"} { |
| + if _, ok := back[k]; !ok { |
| + t.Errorf("wire lacks %q: %s", k, raw) |
| + } |
| + } |
| + // Empty lists are omitted so an older CLI sees exactly what it saw. |
| + raw, _ = json.Marshal(restartWire(appconfig.RestartPending{})) |
| + back = nil |
| + json.Unmarshal(raw, &back) |
| + for _, k := range []string{"undelivered", "blocked"} { |
| + if _, ok := back[k]; ok { |
| + t.Errorf("empty %q must be omitted: %s", k, raw) |
| + } |
| + } |
| +} |