carlos / platform Public

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>
Paul Campbell pushed by paul@keymail.dev 72eb63a8e263afa832382505ffe73a1fd605e7f0 parent 467c302
5 files changed, +123 −8
  • apimode.go +11 −0
  • appconfig_cli.go +18 −6
  • appconfig_restart_test.go +35 −0
  • internal/console/apiconfig.go +22 −2
  • internal/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)
+ }
+ }
+}