carlos / platform Public

relay: status tells a member when to act, no-delivery routes named, CLI printers tested (review fix)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 3db97617eeb4bcad92092d54a8c729db3ebc6f10 parent 976eb0f
7 files changed, +776 −87
  • apimode.go +28 −1
  • docs/site/cli.md +8 −6
  • internal/console/apirelay.go +132 −50
  • internal/console/apirelay_test.go +179 −0
  • internal/console/relayops.go +41 −17
  • relay.go +53 −13
  • relay_test.go +335 −0
diff --git a/apimode.go b/apimode.go
index 868db69c5..8e4c850df 100644
--- a/apimode.go
+++ b/apimode.go
@@ -710,6 +710,18 @@ func notFoundErr() error {
return errors.New("not found: check --account and --app, and that your token's address is a member")
}
+// hasJSONError reports whether a body carries the {"error": "..."} shape —
+// the difference between a door's own diagnosis and the mux's byte-identical
+// text/plain refusal. apiErrorFromBody asks it to decide which message to
+// give; a caller with a better sentence for the second case (relayRotate)
+// asks the same question rather than string-matching the answer.
+func hasJSONError(body []byte) bool {
+ var e struct {
+ Error string `json:"error"`
+ }
+ return json.Unmarshal(body, &e) == nil && e.Error != ""
+}
+
// apiErrorFromBody turns a non-2xx status and its raw body into an error.
// The JSON {"error": "..."} shape is tried FIRST, whatever the status code —
// several doors (promote's unshipped-version 404, rollback's no-history 409)
@@ -1928,7 +1940,7 @@ func (s *apiSession) storeRotate(ctx context.Context, a apiAcct, app string, fin
type apiRelayRoute struct {
Host string `json:"host"`
- Shape string `json:"shape"` // unit | socket-exec | tcp-exec-file
+ Shape string `json:"shape"` // unit | socket-exec | tcp-exec-file | no-delivery | unknown
Delivery string `json:"delivery"`
AckedAt string `json:"acked_at,omitempty"`
Cutover bool `json:"cutover"`
@@ -1943,6 +1955,10 @@ type apiRelayRegion struct {
Listeners []string `json:"listeners"`
Kids []string `json:"kids"`
Stale bool `json:"stale"`
+ // The relay's own last two errors: why it stopped reading. Printed on a
+ // stale row, because the state without the reason is not actionable.
+ MastersError string `json:"masters_error,omitempty"`
+ AuthorityError string `json:"authority_error,omitempty"`
}
type apiRelayReply struct {
@@ -2041,6 +2057,17 @@ func (s *apiSession) relayRotate(ctx context.Context, region string) (apiRelayRo
return apiRelayRotateReply{}, err
}
if status != http.StatusOK {
+ // A 404 with no JSON body is either gate: a console too old to have
+ // this door, or apiRelayOperator refusing an address that is not on
+ // CARLOS_RELAY_OPERATORS — deliberately indistinguishable on the
+ // wire, so the message states both possibilities rather than
+ // notFoundErr()'s advice to check --account and --app, two flags this
+ // verb does not have. Saying both leaks nothing: a caller who is on
+ // the list finds out by the call succeeding.
+ if status == http.StatusNotFound && !hasJSONError(body) {
+ return apiRelayRotateReply{}, errors.New(
+ "no such door on this console, or your address is not on CARLOS_RELAY_OPERATORS")
+ }
return apiRelayRotateReply{}, apiErrorFromBody(status, body)
}
var out apiRelayRotateReply
diff --git a/docs/site/cli.md b/docs/site/cli.md
index a549e4e00..f82a76c93 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -680,12 +680,14 @@ config: a base64-wrapped JSON list of regions, each with its ICE server URLs,
a key and a username suffix. The app mints a TURN credential per call from
the key (see the relay design spec, §2). `status` shows what was issued,
which routes have picked it up, and the health of each region's relay,
-none of which needs box access. `disable` removes it and ends every
-allocation the app holds. `adopted --host <route>` is how a member tells the
-platform that a TCP-address route it restarted by hand now runs the new
-key. `rotate --region <r>` is the operator's verb after staging a new
-master; `keygen` prints one. `serve` is the unit's entry point and not a
-thing to run by hand.
+none of which needs box access. `disable` removes it; every allocation the
+app holds ends on the relays' next authority read, within a minute.
+`adopted --host <route>` is how a member tells the platform that a
+TCP-address route it restarted by hand now runs the new key — `status` names
+the routes waiting on that, since they are the one shape this platform
+cannot restart for you. `rotate --region <r>` is the operator's verb after
+staging a new master; `keygen` prints one. `serve` is the unit's entry point
+and not a thing to run by hand.
`keygen` prints one line — a key id, a key, and `-` for "no retirement date"
— for a region's master key. Stage that line in `/etc/carlos/relay-masters`
diff --git a/internal/console/apirelay.go b/internal/console/apirelay.go
index 13d6e960d..c3a2854a3 100644
--- a/internal/console/apirelay.go
+++ b/internal/console/apirelay.go
@@ -25,11 +25,13 @@
// DELIVERY IS DELIBERATELY THIN FOR NOW. Spec §5 describes five delivery
// states a route can be in, most of them read from a per-route config-ack
// (`control/config-ack/<box>/<route host>.json`) that Task 12 has not written
-// yet. This file reports only what a route's shape and the record's own
-// Cutovers can say today — "adopted by member at <time>" once a member has
-// acknowledged the identities that are issued NOW, "written, awaiting
-// delivery" otherwise — and marks the place Task 12 fills in with a TODO. It
-// does not invent the ack's shape.
+// yet. This file reports only what a route's SHAPE, its config bundle and the
+// record's own Cutovers can say today: that the key was written, that a route
+// this platform cannot restart is waiting on the member, that a route with no
+// config delivery will never receive one at all, and that a member's
+// acknowledgement either does or no longer does name the identities issued
+// now. It marks the place Task 12 fills in with a TODO rather than inventing
+// the ack's shape.
//
// Spec: docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md §2, §5.
package console
@@ -102,10 +104,10 @@ func relayDoorError(w http.ResponseWriter, verb string, err error) {
func relayLooksLikeClientError(err error) bool {
msg := err.Error()
for _, s := range []string{
- "runs no relay in", // relayResolveRegions, relayRotate: unarmed region named
- "no new master is staged", // relayRotate: nothing new in CARLOS_RELAY_MASTERS_<REGION>
- "has not loaded kid", // relayRotate: a box has not picked up the staged master yet
- "has not heartbeated in", // relayRotate: no live relay in the region at all
+ "runs no relay in", // relayResolveRegions, relayRotate: unarmed region named
+ "no new master is staged", // relayRotate: nothing new in CARLOS_RELAY_MASTERS_<REGION>
+ "has not loaded kid", // relayRotate: a box has not picked up the staged master yet
+ "has heartbeated in the last", // relayRotate: no live relay in the region at all
} {
if strings.Contains(msg, s) {
return true
@@ -120,8 +122,12 @@ func relayLooksLikeClientError(err error) bool {
// it: which of spec §5's delivery shapes it is, and what the console can say
// about whether its key has arrived.
type apiRelayRoute struct {
- Host string `json:"host"`
- Shape string `json:"shape"` // "unit" | "socket-exec" | "tcp-exec-file"
+ Host string `json:"host"`
+ // One of the relayShape* constants below: the first three are spec §5's
+ // delivery shapes, "no-delivery" is a route a key can never reach, and
+ // "unknown" is the fail-closed answer for a route this console cannot
+ // describe.
+ Shape string `json:"shape"`
Delivery string `json:"delivery"`
// AckedAt and Cutover are both empty/false until a member has run
// `carlos relay adopted --host` for this route (relay.RouteCutover).
@@ -145,6 +151,12 @@ type apiRelayRegion struct {
// authority read is older than relayHeartbeatFresh (five minutes) cannot
// be read as current, so a dead relay cannot look alive by silence.
Stale bool `json:"stale"`
+ // The relay's own last two errors, verbatim (relay.Heartbeat): the
+ // masters file it could not reload, and the authority record it could not
+ // read. Either one is the REASON a row is stale, and printing the state
+ // without the reason leaves a member with nothing to act on.
+ MastersError string `json:"masters_error,omitempty"`
+ AuthorityError string `json:"authority_error,omitempty"`
}
// apiRelayReply is the one answer shape enable, status, disable and adopted
@@ -190,29 +202,53 @@ type apiRelayRotateReply struct {
RetireNoEarlierThan string `json:"retire_no_earlier_than"` // RFC3339
}
-// relayRouteShape names which of spec §5's three delivery shapes a route is,
-// in relayDeliverable's OWN precedence (relayops.go), branch for branch: a
-// unit wins over a socket, an `instance` route is the platform's own — the
-// config sync restarts it whatever else the record says — and only then does
-// a bare TCP address read as the shape a member has to adopt by hand.
+// The five shapes `carlos relay status` reports a route as. The first three
+// are spec §5's delivery shapes; relayShapeNone is a route the config sync
+// can never reach at all, and relayShapeUnknown is the fail-closed answer for
+// a route that matches none of them.
+const (
+ relayShapeUnit = "unit"
+ relayShapeSocket = "socket-exec"
+ relayShapeFile = "tcp-exec-file"
+ relayShapeNone = "no-delivery"
+ relayShapeUnknown = "unknown"
+)
+
+// relayRouteShape names which shape a route is, in relayDeliverable's OWN
+// branch order (relayops.go), so that "what status calls this route" and
+// "what enable refused, or did not refuse, about it" can never disagree:
+//
+// 1. a named UNIT is a unit whatever else the row carries — the config sync
+// restarts it, so the key arrives without anyone being asked;
+// 2. a unix SOCKET is a socket-exec, restarted the same way;
+// 3. an `instance` route (Kind "" reads as instance) is the platform's OWN,
+// restarted by the config sync even though its record also carries the
+// TCP address the router dials. This branch sits ABOVE the address one
+// deliberately: reading that address as a foreign process would tell a
+// member to run `carlos relay adopted` for a route they must not adopt;
+// 4. only what is left — a TCP address belonging to a process this platform
+// never spawned — is the shape a member has to restart and acknowledge.
//
-// The instance branch sits ABOVE the address one for the same reason
-// relayDeliverable's does: an instance record carries the address the router
-// dials, and reading that address as "a TCP process this platform does not
-// own" would tell a member to run `carlos relay adopted` for a route the
-// platform restarts itself.
+// Whether that last shape can receive the file at all is a SEPARATE question
+// this function cannot answer (it needs the route's config bundle):
+// relayReply asks relayNoDelivery and downgrades the row to relayShapeNone.
+//
+// The default is relayShapeUnknown, not "unit". A route with no unit, no
+// socket, no address and a kind this code does not recognise is something
+// this console cannot describe, and calling it a unit would promise a
+// restart-on-write that nothing is going to perform.
func relayRouteShape(rt relayRoute) string {
switch {
case rt.Unit != "":
- return "unit"
+ return relayShapeUnit
case rt.Socket != "":
- return "socket-exec"
+ return relayShapeSocket
case rt.Kind == "" || rt.Kind == "instance":
- return "unit"
+ return relayShapeUnit
case rt.Addr != "":
- return "tcp-exec-file"
+ return relayShapeFile
default:
- return "unit"
+ return relayShapeUnknown
}
}
@@ -247,35 +283,67 @@ func (s *Server) relayReply(ctx context.Context, acct *Account, app string, rec
// here for a relay key to ever reach.
continue
}
- row := apiRelayRoute{Host: rt.Host, Shape: relayRouteShape(rt), Delivery: "written, awaiting delivery"}
- if !rec.Enabled {
+ shape := relayRouteShape(rt)
+ // A route can be ADDED after an enable, and nothing re-runs enable's
+ // refusal when it is. Asked here, every status answer names such a
+ // route instead of reporting it as fine — which is the whole point of
+ // spec §5's "a route added without delivery after enable shows 'no
+ // config delivery'": it blocks the next retirement, and the member is
+ // the only one who can fix it.
+ if shape == relayShapeFile {
+ none, err := s.relayNoDelivery(ctx, acct.Sqid, app, rt)
+ if err != nil {
+ return apiRelayReply{}, err
+ }
+ if none {
+ shape = relayShapeNone
+ }
+ }
+ row := apiRelayRoute{Host: rt.Host, Shape: shape}
+ switch {
+ case !rec.Enabled:
// A disabled record has no key to deliver: relayDisable removed
// the variable from the bundle. "Written, awaiting delivery"
// would name a write that has been UNDONE, and a member reading
// it would go looking for a key that is on its way out.
row.Delivery = "the relay key was removed from this app's config"
- }
- if cut := relayCutoverFor(rec, rt.Host); cut != nil && rec.Enabled {
- row.AckedAt = cut.At.UTC().Format(time.RFC3339)
- // An ack counts only for the identities it NAMED. relayAdopted
- // stamps the record's issued suffixes into the cutover for
- // exactly this comparison: a rotation since then re-issued the
- // app under a new kid, and reporting the older acknowledgement as
- // current would say a route runs a key it has never seen.
- if maps.Equal(cut.Suffixes, rec.IssuedSuffixes) {
- row.Cutover = true
- row.Delivery = "adopted by member at " + row.AckedAt
- } else {
- row.Delivery = "acknowledged at " + row.AckedAt +
- ", but the identities have changed since — re-run `carlos relay adopted --host " + rt.Host + "`"
+ case shape == relayShapeNone:
+ row.Delivery = "no config delivery: relay will not reach this route; " +
+ "opt it into exec_delivery file or move it to a unit"
+ default:
+ row.Delivery = "written, awaiting delivery"
+ if shape == relayShapeFile {
+ // The ONE shape whose process this platform cannot bounce.
+ // The console cannot know the file has landed until Task 12's
+ // ack exists, but it can say whose move it is — and leaving
+ // that out is how a route sits un-adopted for a month and
+ // blocks a retirement nobody can explain.
+ row.Delivery = "file delivered, awaiting `carlos relay adopted --host " + rt.Host + "` after you restart it"
+ }
+ if cut := relayCutoverFor(rec, rt.Host); cut != nil {
+ row.AckedAt = cut.At.UTC().Format(time.RFC3339)
+ // An ack counts only for the identities it NAMED. relayAdopted
+ // stamps the record's issued suffixes into the cutover for
+ // exactly this comparison: a rotation since then re-issued the
+ // app under a new kid, and reporting the older acknowledgement
+ // as current would say a route runs a key it has never seen.
+ if maps.Equal(cut.Suffixes, rec.IssuedSuffixes) {
+ row.Cutover = true
+ row.Delivery = "adopted by member at " + row.AckedAt
+ } else {
+ row.Delivery = "acknowledged at " + row.AckedAt +
+ ", but the identities have changed since — re-run `carlos relay adopted --host " + rt.Host + "`"
+ }
}
}
// TODO(task 12): once internal/appconfig/sync.go writes
// control/config-ack/<box>/<route host>.json, read it here and
// report the fuller set of states spec §5 describes for a unit or
// socket-exec route ("adopted at <time>, restarted", "restart
- // failed", "hibernating, delivered on wake") instead of the two
- // states above, which is all the console can see without it.
+ // failed", "hibernating, delivered on wake") instead of the states
+ // above, which is all the console can see without it — including
+ // whether the FILE half of a tcp-exec-file route's sentence has
+ // actually happened yet.
out.Routes = append(out.Routes, row)
}
@@ -298,6 +366,13 @@ func (s *Server) relayReply(ctx context.Context, acct *Account, app string, rec
LeaseLive: !hb.ReadAt.IsZero() && now.Sub(hb.ReadAt) <= relay.LeaseMaxAge,
Stale: hb.ReadAt.IsZero() || now.Sub(hb.ReadAt) > relayHeartbeatFresh,
Current: auth != nil && !hb.WrittenAt.IsZero() && !hb.WrittenAt.Before(auth.WrittenAt),
+ // The two "why has this relay stopped moving" fields, carried
+ // straight through. A stale row without them tells a member
+ // their calls will fail and nothing about who fixes it: a
+ // masters error is the operator's staging, an authority error
+ // is this console's bucket.
+ MastersError: hb.MastersError,
+ AuthorityError: hb.AuthorityError,
}
if hb.ReadAt.IsZero() {
row.HeartbeatAge = "never"
@@ -309,16 +384,23 @@ func (s *Server) relayReply(ctx context.Context, acct *Account, app string, rec
}
// "When a disable is complete" (spec §5): the app is out of a region's
- // authority once that region's own armed relay has heartbeated an
- // authority record written at or after the disable. rec.DisabledAt is
- // the record's own timestamp for that publish (relayDisable, relayops.go)
- // — close enough to the aggregate's own written_at for this purpose,
- // since the publish is the very next step in the same walk.
+ // authority once the aggregate has been published without it AND that
+ // region's own armed relay has heartbeated an authority record written at
+ // or after that publish. The published record's OWN written_at is the
+ // timestamp to compare against — the same one the Current field above
+ // uses — because it is the object the relays actually read. rec.DisabledAt
+ // is only this console's note of when it started the walk, and it stands
+ // in when the aggregate has never been published at all (in which case no
+ // relay can possibly have caught up, so every region is pending).
if !rec.Enabled && !rec.DisabledAt.IsZero() {
+ publishedAt := rec.DisabledAt
+ if auth != nil && !auth.WrittenAt.IsZero() {
+ publishedAt = auth.WrittenAt
+ }
for _, region := range rec.Regions {
revoked := false
for _, hb := range hbs {
- if hb.Region == region && !hb.WrittenAt.IsZero() && !hb.WrittenAt.Before(rec.DisabledAt) {
+ if hb.Region == region && !hb.WrittenAt.IsZero() && !hb.WrittenAt.Before(publishedAt) {
revoked = true
break
}
diff --git a/internal/console/apirelay_test.go b/internal/console/apirelay_test.go
index 8ad8777aa..105a816dc 100644
--- a/internal/console/apirelay_test.go
+++ b/internal/console/apirelay_test.go
@@ -19,6 +19,8 @@ import (
"testing"
"time"
+ "github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/relay"
)
@@ -347,3 +349,180 @@ func TestAPIRelayRotateNeedsAnOperatorAndSaysNothingOtherwise(t *testing.T) {
t.Fatalf("rotate with no region = %d, want 400", status)
}
}
+
+// ---- routes: what each shape is, and whose move it is ------------------------
+
+// relayRouteRows indexes a status reply's routes by host, so an assertion
+// names the route it is about rather than a position in a list.
+func relayRouteRows(t *testing.T, out map[string]any) map[string]map[string]any {
+ t.Helper()
+ raw, _ := out["routes"].([]any)
+ rows := map[string]map[string]any{}
+ for _, r := range raw {
+ row, ok := r.(map[string]any)
+ if !ok {
+ t.Fatalf("route row = %#v, want an object", r)
+ }
+ rows[str(t, row, "host")] = row
+ }
+ return rows
+}
+
+// wantRoute asserts one route row's shape and that its delivery sentence
+// contains each fragment.
+func wantRoute(t *testing.T, rows map[string]map[string]any, host, shape string, deliveryHas ...string) {
+ t.Helper()
+ row, ok := rows[host]
+ if !ok {
+ t.Fatalf("no route row for %s (have %v)", host, rows)
+ }
+ if got := str(t, row, "shape"); got != shape {
+ t.Fatalf("%s shape = %q, want %q", host, got, shape)
+ }
+ got := str(t, row, "delivery")
+ for _, want := range deliveryHas {
+ if !strings.Contains(got, want) {
+ t.Fatalf("%s delivery = %q, want it to contain %q", host, got, want)
+ }
+ }
+}
+
+// TestAPIRelayStatusNamesEachRouteShapeAndWhoseMoveItIs is the delivery half
+// of `carlos relay status`, which is the only sight a member with no box
+// access has of it. Four routes, four different answers:
+//
+// - a unit and a socket-exec are restarted by the config sync, so the key
+// arrives without anyone being asked and the row says only that it was
+// written;
+// - a TCP-address route with file delivery is the one shape this platform
+// cannot restart, so its row says whose move it is — spec §5's
+// "awaiting `carlos relay adopted`" — rather than leaving a member to
+// wonder why a rotation never completes;
+// - a route with NO config delivery, added after the enable that would have
+// refused it, is named as the fault it is. `adopted` refuses it too: a
+// cutover there would claim a process holds a key nothing ever wrote for
+// it, and a retirement would later be checked against that claim.
+//
+// Then the ack itself: recorded, it reads as adopted; after a rotation
+// re-issues the app under a new kid, the SAME ack must stop reading as
+// adopted, because it named identities that no longer exist.
+func TestAPIRelayStatusNamesEachRouteShapeAndWhoseMoveItIs(t *testing.T) {
+ fx, _ := relayFixture(t, "code-relay-shapes")
+ ctx := context.Background()
+ token := cliToken(t, fx, "alice@keymail.dev", "code-relay-shapes-2")
+
+ // One named bundle opts into file delivery; the app's default does not.
+ // That is what lets two otherwise identical TCP routes differ, and it is
+ // the per-route precedence relayExecDelivery applies (#195).
+ if err := appconfig.SetExecDeliveryAt(ctx, fx.rel, fx.sqid, "titogo", "prod", appconfig.ExecDeliveryFile); err != nil {
+ t.Fatal(err)
+ }
+ fx.addRoute(t, registry.Route{Host: "a.titogo.oncarlos.com", App: "titogo",
+ Kind: "service", Unit: "titogo-a.service", Status: "ready"})
+ fx.addRoute(t, registry.Route{Host: "b.titogo.oncarlos.com", App: "titogo",
+ Kind: "service", Socket: "/run/titogo-b.sock", Status: "ready"})
+ fx.addRoute(t, registry.Route{Host: "c.titogo.oncarlos.com", App: "titogo",
+ Kind: "service", Addr: "10.0.0.5:8080", Environment: "prod", Status: "ready"})
+
+ if status, out := postJSON(t, relayURL(fx, "titogo"), map[string]any{}, token); status != http.StatusOK {
+ t.Fatalf("enable = %d %v, want 200", status, out)
+ }
+ // Added AFTER the enable, which is the only way it can exist: enable
+ // itself refuses an app that already has one.
+ fx.addRoute(t, registry.Route{Host: "d.titogo.oncarlos.com", App: "titogo",
+ Kind: "service", Addr: "10.0.0.6:8080", Status: "ready"})
+
+ status, out := getJSON(t, relayURL(fx, "titogo"), token)
+ if status != http.StatusOK {
+ t.Fatalf("status = %d %v, want 200", status, out)
+ }
+ rows := relayRouteRows(t, out)
+ if len(rows) != 4 {
+ t.Fatalf("routes = %v, want four rows", rows)
+ }
+ wantRoute(t, rows, "a.titogo.oncarlos.com", "unit", "written, awaiting delivery")
+ wantRoute(t, rows, "b.titogo.oncarlos.com", "socket-exec", "written, awaiting delivery")
+ wantRoute(t, rows, "c.titogo.oncarlos.com", "tcp-exec-file",
+ "carlos relay adopted --host c.titogo.oncarlos.com")
+ wantRoute(t, rows, "d.titogo.oncarlos.com", "no-delivery",
+ "no config delivery", "exec_delivery file")
+
+ // The undeliverable route cannot be acknowledged away.
+ status, out = postJSON(t, relayURL(fx, "titogo")+"/adopted",
+ map[string]any{"host": "d.titogo.oncarlos.com"}, token)
+ if status != http.StatusBadRequest {
+ t.Fatalf("adopted for a no-delivery route = %d %v, want 400", status, out)
+ }
+ if msg := str(t, out, "error"); !strings.Contains(msg, "no config delivery") {
+ t.Fatalf("error = %q, want the reason named", msg)
+ }
+
+ // The deliverable one can, and then reads as adopted.
+ if status, out := postJSON(t, relayURL(fx, "titogo")+"/adopted",
+ map[string]any{"host": "c.titogo.oncarlos.com"}, token); status != http.StatusOK {
+ t.Fatalf("adopted = %d %v, want 200", status, out)
+ }
+ _, out = getJSON(t, relayURL(fx, "titogo"), token)
+ rows = relayRouteRows(t, out)
+ wantRoute(t, rows, "c.titogo.oncarlos.com", "tcp-exec-file", "adopted by member at")
+ if rows["c.titogo.oncarlos.com"]["cutover"] != true {
+ t.Fatalf("cutover = %v after an ack, want true", rows["c.titogo.oncarlos.com"]["cutover"])
+ }
+
+ // A rotation re-issues the app under a new kid. The ack named the OLD
+ // identities, so it must stop reading as adopted — otherwise a member
+ // would believe a route runs a key it has never seen, and the old master
+ // would be retired out from under it.
+ t.Setenv(relayMastersEnv("eu-west-1"), "k1:"+relayTestMaster(1)+",k2:"+relayTestMaster(2))
+ seedHeartbeat(t, fx, "box1", "eu-west-1", time.Minute, "k1", "k2")
+ if _, err := fx.srv.relayRotate(ctx, "eu-west-1"); err != nil {
+ t.Fatalf("rotate: %v", err)
+ }
+ _, out = getJSON(t, relayURL(fx, "titogo"), token)
+ rows = relayRouteRows(t, out)
+ wantRoute(t, rows, "c.titogo.oncarlos.com", "tcp-exec-file",
+ "the identities have changed since", "carlos relay adopted --host c.titogo.oncarlos.com")
+ if rows["c.titogo.oncarlos.com"]["cutover"] != false {
+ t.Fatalf("cutover = %v after a rotation, want false", rows["c.titogo.oncarlos.com"]["cutover"])
+ }
+ if rows["c.titogo.oncarlos.com"]["acked_at"] == nil {
+ t.Fatal("acked_at was dropped — the ack still happened, it just no longer counts")
+ }
+}
+
+// TestAPIRelayRotateStagingRefusalsAreClientErrors pins the two refusal
+// sentences relayLooksLikeClientError matches by wording rather than by
+// sentinel: nothing new staged, and no live relay in the region at all.
+// Both are the OPERATOR's to fix by finishing the runbook, so both are a 400
+// naming what is wrong — and if either sentence is ever reworded without
+// updating the matcher, they become a 502 that reads as "the console broke"
+// and this test is what says so.
+func TestAPIRelayRotateStagingRefusalsAreClientErrors(t *testing.T) {
+ fx, _ := relayFixture(t, "code-relay-staging")
+ token := cliToken(t, fx, "alice@keymail.dev", "code-relay-staging-2")
+ fx.srv.cfg.RelayOperators = []string{"alice@keymail.dev"}
+ rotate := fx.ts.URL + "/api/cli/relay/rotate"
+
+ // (a) nothing new staged: the only entry is already the issuer.
+ seedHeartbeat(t, fx, "box1", "eu-west-1", time.Minute, "k1")
+ status, out := postJSON(t, rotate, map[string]any{"region": "eu-west-1"}, token)
+ if status != http.StatusBadRequest {
+ t.Fatalf("rotate with nothing new staged = %d %v, want 400", status, out)
+ }
+ if msg := str(t, out, "error"); !strings.Contains(msg, "no new master is staged") {
+ t.Fatalf("error = %q, want the nothing-staged sentence", msg)
+ }
+
+ // (b) a new master staged, but every heartbeat in the region is stale.
+ // Issuing now would mint credentials nothing can verify, and a dead relay
+ // must not be able to consent by silence.
+ t.Setenv(relayMastersEnv("eu-west-1"), "k1:"+relayTestMaster(1)+",k2:"+relayTestMaster(2))
+ seedHeartbeat(t, fx, "box1", "eu-west-1", 6*time.Minute, "k1", "k2")
+ status, out = postJSON(t, rotate, map[string]any{"region": "eu-west-1"}, token)
+ if status != http.StatusBadRequest {
+ t.Fatalf("rotate with no live relay = %d %v, want 400", status, out)
+ }
+ if msg := str(t, out, "error"); !strings.Contains(msg, "has heartbeated in the last") {
+ t.Fatalf("error = %q, want the no-live-relay sentence", msg)
+ }
+}
diff --git a/internal/console/relayops.go b/internal/console/relayops.go
index 924219ef8..ad0916ad3 100644
--- a/internal/console/relayops.go
+++ b/internal/console/relayops.go
@@ -858,10 +858,8 @@ func (s *Server) relayExecDelivery(ctx context.Context, acct, app, envName strin
return m.ExecDelivery == appconfig.ExecDeliveryFile, nil
}
-// relayDeliverable is enable's second refusal (spec §5, step 2): a route that
-// could never receive a rotated key must not be enabled, because it would
-// block every future retirement forever and nobody would find out until the
-// rotation stalled.
+// relayNoDelivery answers the ONE question three callers need about a route:
+// can the config sync put a relay key in front of its process at all?
//
// The three shapes: an `instance` route is the platform's own — it has a
// socket or a unit and the config sync restarts it, so it is deliverable. A
@@ -869,27 +867,39 @@ func (s *Server) relayExecDelivery(ctx context.Context, acct, app, envName strin
// deliverable only if it has a socket or a unit to bounce, or its bundle opts
// into file delivery — a bare TCP address whose process this platform never
// spawned is exactly the route the spec refuses.
+//
+// It lives in one function because the three callers must agree: enable
+// REFUSES such a route (below), `carlos relay status` NAMES it (relayReply,
+// apirelay.go), and `carlos relay adopted` refuses to record a cutover for
+// one (relayAdopted). Three copies of this switch would eventually let a
+// member acknowledge a key that had never been written for them.
+func (s *Server) relayNoDelivery(ctx context.Context, acct, app string, rt relayRoute) (bool, error) {
+ switch {
+ case rt.Kind == "static",
+ rt.Kind == "" || rt.Kind == "instance",
+ rt.Socket != "" || rt.Unit != "",
+ rt.Addr == "":
+ return false, nil
+ }
+ on, err := s.relayExecDelivery(ctx, acct, app, rt.Environment)
+ return !on, err
+}
+
+// relayDeliverable is enable's second refusal (spec §5, step 2): a route that
+// could never receive a rotated key must not be enabled, because it would
+// block every future retirement forever and nobody would find out until the
+// rotation stalled.
func (s *Server) relayDeliverable(ctx context.Context, acct, app string) error {
routes, err := s.relayAppRoutes(ctx, acct, app)
if err != nil {
return err
}
for _, rt := range routes {
- switch {
- case rt.Kind == "static":
- continue
- case rt.Kind == "" || rt.Kind == "instance":
- continue
- case rt.Socket != "" || rt.Unit != "":
- continue
- case rt.Addr == "":
- continue
- }
- on, err := s.relayExecDelivery(ctx, acct, app, rt.Environment)
+ none, err := s.relayNoDelivery(ctx, acct, app, rt)
if err != nil {
return err
}
- if !on {
+ if none {
return fmt.Errorf("route %s is a TCP address this platform does not own and its config bundle does not opt into file delivery, "+
"so it could never pick up a rotated relay key — set it with `carlos env exec-delivery file` first: %w", rt.Host, errRelayUndelivered)
}
@@ -1068,9 +1078,23 @@ func (s *Server) relayAdopted(ctx context.Context, acct *Account, app, host, by
if err != nil {
return nil, err
}
- if !slices.ContainsFunc(routes, func(rt relayRoute) bool { return rt.Host == host }) {
+ i := slices.IndexFunc(routes, func(rt relayRoute) bool { return rt.Host == host })
+ if i < 0 {
return nil, fmt.Errorf("%s is not a route of this app: %w", host, errRelayUnknownHost)
}
+ // A route the config sync cannot reach has been sent NOTHING to adopt, so
+ // an acknowledgement of it would be false — and worse than merely false:
+ // a cutover is what the retirement of an old master is eventually checked
+ // against, and one recorded here would say a process has a key it has
+ // never seen. Such a route can only appear after an enable (enable itself
+ // refuses it), which is exactly when a member might reach for this verb
+ // to make the warning go away.
+ if none, err := s.relayNoDelivery(ctx, acct.Sqid, app, routes[i]); err != nil {
+ return nil, err
+ } else if none {
+ return nil, fmt.Errorf("%s has no config delivery, so no relay key has been written for it to adopt — "+
+ "opt it into file delivery with `carlos env exec-delivery file` or move it to a unit: %w", host, errRelayUndelivered)
+ }
cut := relay.RouteCutover{
Host: host,
Suffixes: maps.Clone(rec.IssuedSuffixes),
diff --git a/relay.go b/relay.go
index d674440b3..a9b69efe8 100644
--- a/relay.go
+++ b/relay.go
@@ -51,7 +51,8 @@ const relayUsage = `usage: carlos relay <enable|status|disable|adopted|rotate|ke
and each region relay's heartbeat, lease and loaded masters. The
only sight of any of that without box access.
disable --app <a>
- remove the key and end every allocation the app holds.
+ remove the key. Every allocation the app holds ends on the
+ relays' next authority read, within a minute.
adopted --app <a> --host <route>
tell the platform that a TCP-address route you restarted by hand
now runs the issued key. The one route shape this platform cannot
@@ -344,11 +345,19 @@ func splitList(s string) []string {
func printRelayStatus(w io.Writer, res apiRelayReply) {
c := colorFor(w)
fmt.Fprintf(w, "%s %s\n", c.head("relay for"), res.App)
- if !res.Enabled {
- mutedf(w, "not enabled — carlos relay enable --app %s", res.App)
- } else {
+ switch {
+ case res.Enabled:
fmt.Fprintf(w, " enabled in %s, incarnation %s (op %d, %s)\n",
strings.Join(res.Regions, ", "), res.Incarnation, res.Op, res.Phase)
+ case res.Incarnation != "":
+ // A record that HAS been enabled and is now off still carries an
+ // operation and a phase, and both matter: a disable that stopped at
+ // phase "config" is one the reconcile has yet to finish, and hiding
+ // that behind "not enabled" would make a half-done disable look
+ // complete.
+ fmt.Fprintf(w, " disabled (op %d, %s)\n", res.Op, res.Phase)
+ default:
+ mutedf(w, "not enabled — carlos relay enable --app %s", res.App)
}
// The issued identity per region: account, app, incarnation and kid, the
// public half of the credential the app mints per call. Printed so a
@@ -359,11 +368,29 @@ func printRelayStatus(w io.Writer, res apiRelayReply) {
}
}
for _, rt := range res.Routes {
- fmt.Fprintf(w, " route %s (%s): %s\n", rt.Host, rt.Shape, rt.Delivery)
+ line := fmt.Sprintf(" route %s (%s): %s", rt.Host, rt.Shape, rt.Delivery)
+ // A route the relay key will never reach is the one row here that is
+ // a fault rather than a state: it blocks the next master retirement
+ // until someone fixes it, and only the member can.
+ if rt.Shape == "no-delivery" {
+ warnf(w, "%s", line)
+ continue
+ }
+ fmt.Fprintln(w, line)
}
if len(res.Routes) == 0 && res.Enabled {
mutedf(w, " no routes yet — the key is written and waits for the first one")
}
+ // Disable progress before region health, spec §5's own order: when a
+ // member has just disabled, "is it off everywhere yet" is the question
+ // they typed the command to answer.
+ if len(res.DisablePending) > 0 {
+ warnf(w, " disable pending on %s — those relays have not read the record without this app yet; "+
+ "allocations there end on their next authority read, within a minute",
+ strings.Join(res.DisablePending, ", "))
+ } else if !res.Enabled && res.Incarnation != "" {
+ fmt.Fprintln(w, " revoked: every relay has read a record without this app")
+ }
for _, rg := range res.RegionStatus {
line := fmt.Sprintf(" relay %s in %s: heartbeat %s", rg.Box, rg.Region, rg.HeartbeatAge)
if !rg.Current {
@@ -379,8 +406,11 @@ func printRelayStatus(w io.Writer, res apiRelayReply) {
case rg.Stale:
// STALE is the important word and it gets the warning colour: a
// relay that stopped reading the authority keeps serving until its
- // lease runs out, so "up" and "current" are different facts.
- warnf(w, "%s — STALE (no successful read in over five minutes)", line)
+ // lease runs out, so "up" and "current" are different facts. The
+ // relay's own last error, when it reported one, is the whole
+ // difference between a member filing a bug and an operator
+ // fixing a masters file.
+ warnf(w, "%s — STALE (no successful read in over five minutes)%s", line, relayWhyStale(rg))
case !rg.LeaseLive:
warnf(w, "%s — lease expired, this relay is no longer serving", line)
default:
@@ -388,15 +418,25 @@ func printRelayStatus(w io.Writer, res apiRelayReply) {
}
}
if res.Enabled && len(res.RegionStatus) == 0 {
- warnf(w, "no relay in %s has heartbeated at all — calls will not connect through TURN",
+ warnf(w, " no relay in %s has heartbeated at all — calls will not connect through TURN",
strings.Join(res.Regions, ", "))
}
- if len(res.DisablePending) > 0 {
- warnf(w, "disable pending on %s — those relays have not read the record without this app yet; "+
- "allocations there end on their next read", strings.Join(res.DisablePending, ", "))
- } else if !res.Enabled && res.Incarnation != "" {
- fmt.Fprintln(w, " revoked: every relay has read a record without this app")
+}
+
+// relayWhyStale is the reason half of a stale row: the relay's own last
+// masters-file error, its last authority-read error, or nothing when it
+// reported neither (a relay that simply stopped writing heartbeats has no
+// error to tell us about, and inventing one would be worse than the silence).
+func relayWhyStale(rg apiRelayRegion) string {
+ switch {
+ case rg.MastersError != "" && rg.AuthorityError != "":
+ return ": masters " + rg.MastersError + "; authority " + rg.AuthorityError
+ case rg.MastersError != "":
+ return ": masters " + rg.MastersError
+ case rg.AuthorityError != "":
+ return ": authority " + rg.AuthorityError
}
+ return ""
}
// printRelayRotate is the operator's answer: which master the region issues
diff --git a/relay_test.go b/relay_test.go
new file mode 100644
index 000000000..58cf8343d
--- /dev/null
+++ b/relay_test.go
@@ -0,0 +1,335 @@
+// Tests for `carlos relay`'s client half: the wire shape the five apiSession
+// methods send (matching internal/console/apirelay_test.go's server side),
+// and the honesty rules printRelayStatus and printRelayRotate must follow.
+//
+// The printers get the most attention here, because `carlos relay status` is
+// the ONLY sight a member with no box access has of whether their calls can
+// connect. Every rule pinned below is one where a wrong line would leave them
+// believing something the console did not say: that a route has the key when
+// it is waiting on them, that a relay is healthy when it stopped reading five
+// minutes ago, that a disable is finished when allocations are still live.
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// ---- apiSession.relay* wire shape -------------------------------------------
+
+// TestAPISessionRelayWireShapes pins the path and body of each of the four
+// app-scoped calls plus rotate. rotate is the one with no account segment at
+// all — a literal path, because there is nothing per-app about moving a
+// region's issuer pointer.
+func TestAPISessionRelayWireShapes(t *testing.T) {
+ var gotPath, gotMethod, gotAuth, gotBody string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization")
+ body := new(bytes.Buffer)
+ body.ReadFrom(r.Body)
+ gotBody = strings.TrimSpace(body.String())
+ json.NewEncoder(w).Encode(map[string]any{"app": "meet", "enabled": true})
+ }))
+ defer ts.Close()
+ sess := newTestSession(ts.URL)
+ a := apiAcct{Seg: "meet-team"}
+
+ if _, err := sess.relayEnable(context.Background(), a, "meet", nil); err != nil {
+ t.Fatalf("relayEnable: %v", err)
+ }
+ if gotMethod != http.MethodPost || gotPath != "/api/cli/apps/meet-team/meet/relay" {
+ t.Fatalf("enable = %s %s", gotMethod, gotPath)
+ }
+ // No regions named sends the literal {} the door documents as its
+ // default, never {"regions":null}.
+ if gotBody != "{}" {
+ t.Fatalf("enable body = %q, want {}", gotBody)
+ }
+ if gotAuth != "Bearer cli-token" {
+ t.Fatalf("auth = %q", gotAuth)
+ }
+
+ if _, err := sess.relayEnable(context.Background(), a, "meet", []string{"eu-west-1"}); err != nil {
+ t.Fatalf("relayEnable(regions): %v", err)
+ }
+ if gotBody != `{"regions":["eu-west-1"]}` {
+ t.Fatalf("enable body with regions = %q", gotBody)
+ }
+
+ if _, err := sess.relayGet(context.Background(), a, "meet"); err != nil {
+ t.Fatalf("relayGet: %v", err)
+ }
+ if gotMethod != http.MethodGet || gotPath != "/api/cli/apps/meet-team/meet/relay" {
+ t.Fatalf("status = %s %s", gotMethod, gotPath)
+ }
+
+ if _, err := sess.relayDisable(context.Background(), a, "meet"); err != nil {
+ t.Fatalf("relayDisable: %v", err)
+ }
+ if gotMethod != http.MethodPost || gotPath != "/api/cli/apps/meet-team/meet/relay/disable" {
+ t.Fatalf("disable = %s %s", gotMethod, gotPath)
+ }
+
+ if _, err := sess.relayAdopted(context.Background(), a, "meet", "x.meet.example"); err != nil {
+ t.Fatalf("relayAdopted: %v", err)
+ }
+ if gotPath != "/api/cli/apps/meet-team/meet/relay/adopted" {
+ t.Fatalf("adopted path = %s", gotPath)
+ }
+ if gotBody != `{"host":"x.meet.example"}` {
+ t.Fatalf("adopted body = %q", gotBody)
+ }
+
+ if _, err := sess.relayRotate(context.Background(), "eu-west-1"); err != nil {
+ t.Fatalf("relayRotate: %v", err)
+ }
+ if gotPath != "/api/cli/relay/rotate" {
+ t.Fatalf("rotate path = %s, want the literal deployment-wide door", gotPath)
+ }
+ if gotBody != `{"region":"eu-west-1"}` {
+ t.Fatalf("rotate body = %q", gotBody)
+ }
+}
+
+// TestAPISessionRelayRotateNotFoundNamesTheOperatorList: the rotate door
+// answers a non-operator with the mux's own byte-identical 404, which is
+// deliberate — a 403 would confirm to any token holder that this deployment
+// runs a relay. So the CLI cannot tell "not allowed" from "no such door", and
+// the message must say BOTH rather than fall back to the generic membership
+// advice, which names --account and --app: two flags this verb does not have,
+// sending an operator to check something they cannot even type.
+func TestAPISessionRelayRotateNotFoundNamesTheOperatorList(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.NotFound(w, r)
+ }))
+ defer ts.Close()
+
+ _, err := newTestSession(ts.URL).relayRotate(context.Background(), "eu-west-1")
+ if err == nil {
+ t.Fatal("rotate against a 404 door returned no error")
+ }
+ if !strings.Contains(err.Error(), "CARLOS_RELAY_OPERATORS") {
+ t.Fatalf("error = %q, want the operator list named", err)
+ }
+ if strings.Contains(err.Error(), "--account") || strings.Contains(err.Error(), "--app") {
+ t.Fatalf("error = %q, want no advice about flags this verb does not have", err)
+ }
+}
+
+// TestAPISessionRelayServerDiagnosisSurvives: a 404 that DOES carry a JSON
+// error is a real server sentence ("the relay has never been enabled...") and
+// must reach the member verbatim, not be rewritten into the generic one.
+func TestAPISessionRelayServerDiagnosisSurvives(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ json.NewEncoder(w).Encode(map[string]any{"error": "the relay has never been enabled for this app"})
+ }))
+ defer ts.Close()
+
+ _, err := newTestSession(ts.URL).relayGet(context.Background(), apiAcct{Seg: "t"}, "meet")
+ if err == nil || !strings.Contains(err.Error(), "never been enabled") {
+ t.Fatalf("relayGet 404 = %v, want the console's own text", err)
+ }
+}
+
+// ---- printRelayStatus --------------------------------------------------------
+//
+// Driven against literal replies, no stub server: the wording rules are the
+// thing under test, and they must not depend on the wire plumbing above.
+
+func TestPrintRelayStatus(t *testing.T) {
+ healthy := apiRelayReply{
+ App: "meet", Enabled: true, Incarnation: "0123456789abcdef",
+ Regions: []string{"eu-west-1"}, Op: 3, Phase: "done",
+ Issued: map[string]string{"eu-west-1": "abc:meet:0123456789abcdef:k1"},
+ RegionStatus: []apiRelayRegion{{
+ Region: "eu-west-1", Box: "relay-1", HeartbeatAge: "12s", Current: true,
+ LeaseLive: true, Listeners: []string{"udp:3478"}, Kids: []string{"k1"},
+ }},
+ }
+
+ cases := []struct {
+ name string
+ res apiRelayReply
+ want []string
+ wantNot []string
+ modifier func(*apiRelayReply)
+ }{
+ {
+ name: "healthy",
+ res: healthy,
+ want: []string{"relay for meet", "enabled in eu-west-1", "0123456789abcdef",
+ "op 3", "done", "issued eu-west-1: abc:meet:0123456789abcdef:k1",
+ "relay relay-1 in eu-west-1: heartbeat 12s", "udp:3478", "masters k1", "lease live"},
+ // A healthy relay must never be described as stale or as holding
+ // an old record — the two words a member acts on.
+ wantNot: []string{"STALE", "older record", "disable pending"},
+ },
+ {
+ name: "stale names the relay's own reason",
+ res: healthy,
+ modifier: func(r *apiRelayReply) {
+ r.RegionStatus = []apiRelayRegion{{
+ Region: "eu-west-1", Box: "relay-1", HeartbeatAge: "6m0s", Current: false,
+ LeaseLive: true, Stale: true, Kids: []string{"k1"},
+ AuthorityError: "get control/relay.json: 503",
+ }}
+ },
+ want: []string{"STALE", "holding an older record", "6m0s", "authority", "503"},
+ // Still inside its fifteen-minute lease, so it IS still serving:
+ // saying otherwise would send someone hunting a dead box.
+ wantNot: []string{"no longer serving"},
+ },
+ {
+ name: "lease expired",
+ res: healthy,
+ modifier: func(r *apiRelayReply) {
+ r.RegionStatus = []apiRelayRegion{{
+ Region: "eu-west-1", Box: "relay-1", HeartbeatAge: "40m0s",
+ Current: true, LeaseLive: false, Stale: false,
+ }}
+ },
+ want: []string{"lease expired", "no longer serving"},
+ },
+ {
+ name: "no heartbeat at all",
+ res: healthy,
+ modifier: func(r *apiRelayReply) {
+ r.RegionStatus = nil
+ },
+ want: []string{"no relay in eu-west-1 has heartbeated at all", "will not connect"},
+ wantNot: []string{"lease live"},
+ },
+ {
+ name: "disable pending",
+ res: healthy,
+ modifier: func(r *apiRelayReply) {
+ r.Enabled, r.Op, r.Phase = false, 4, "done"
+ r.Issued = nil
+ r.DisablePending = []string{"eu-west-1"}
+ },
+ // A disabled record still shows its operation and phase: a
+ // disable stuck at phase "config" is not the same state as one
+ // that finished, and "not enabled" alone would hide the
+ // difference.
+ want: []string{"disabled (op 4, done)", "disable pending on eu-west-1",
+ "next authority read, within a minute"},
+ wantNot: []string{"revoked"},
+ },
+ {
+ name: "revoked",
+ res: healthy,
+ modifier: func(r *apiRelayReply) {
+ r.Enabled, r.Op = false, 4
+ r.Issued = nil
+ },
+ want: []string{"revoked: every relay has read a record without this app"},
+ wantNot: []string{"disable pending"},
+ },
+ {
+ name: "never enabled",
+ res: apiRelayReply{App: "meet"},
+ want: []string{"not enabled", "carlos relay enable --app meet"},
+ // Nothing was ever issued, so there is no operation to report.
+ wantNot: []string{"op 0", "revoked"},
+ },
+ {
+ name: "route deliveries",
+ res: healthy,
+ modifier: func(r *apiRelayReply) {
+ r.Routes = []apiRelayRoute{
+ {Host: "a.meet", Shape: "unit", Delivery: "written, awaiting delivery"},
+ {Host: "b.meet", Shape: "socket-exec", Delivery: "written, awaiting delivery"},
+ {Host: "c.meet", Shape: "tcp-exec-file",
+ Delivery: "file delivered, awaiting `carlos relay adopted --host c.meet` after you restart it"},
+ {Host: "d.meet", Shape: "no-delivery",
+ Delivery: "no config delivery: relay will not reach this route; opt it into exec_delivery file or move it to a unit"},
+ {Host: "e.meet", Shape: "tcp-exec-file", Cutover: true,
+ AckedAt: "2026-09-07T10:00:00Z", Delivery: "adopted by member at 2026-09-07T10:00:00Z"},
+ }
+ },
+ want: []string{
+ "route a.meet (unit): written, awaiting delivery",
+ "route b.meet (socket-exec): written",
+ "route c.meet (tcp-exec-file): file delivered, awaiting `carlos relay adopted --host c.meet`",
+ "route d.meet (no-delivery): no config delivery",
+ "route e.meet (tcp-exec-file): adopted by member at 2026-09-07T10:00:00Z",
+ },
+ // The lesson restart.go paid for: never say a box has acted.
+ wantNot: []string{"no routes yet"},
+ },
+ {
+ name: "enabled with no routes",
+ res: healthy,
+ want: []string{"no routes yet"},
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ res := tc.res
+ if tc.modifier != nil {
+ tc.modifier(&res)
+ }
+ var buf bytes.Buffer
+ printRelayStatus(&buf, res)
+ out := buf.String()
+ for _, want := range tc.want {
+ if !strings.Contains(out, want) {
+ t.Fatalf("missing %q:\n%s", want, out)
+ }
+ }
+ for _, no := range tc.wantNot {
+ if strings.Contains(out, no) {
+ t.Fatalf("must not contain %q:\n%s", no, out)
+ }
+ }
+ })
+ }
+}
+
+// TestPrintRelayStatusNeverClaimsDelivery is the output-honesty rule of this
+// whole file in one assertion: the console can say a key was WRITTEN and it
+// can repeat a member's own acknowledgement, but nothing here may claim a
+// running process has picked anything up. Only Task 12's per-route ack can
+// say that, and until it exists no sentence may imply it.
+func TestPrintRelayStatusNeverClaimsDelivery(t *testing.T) {
+ var buf bytes.Buffer
+ printRelayStatus(&buf, apiRelayReply{
+ App: "meet", Enabled: true, Incarnation: "0123456789abcdef",
+ Regions: []string{"eu-west-1"}, Phase: "done",
+ Routes: []apiRelayRoute{{Host: "a.meet", Shape: "unit", Delivery: "written, awaiting delivery"}},
+ })
+ out := strings.ToLower(buf.String())
+ for _, mustNot := range []string{"is using", "now running the", "has the key", "picked it up"} {
+ if strings.Contains(out, mustNot) {
+ t.Fatalf("printRelayStatus over-claims delivery (%q):\n%s", mustNot, buf.String())
+ }
+ }
+}
+
+// ---- printRelayRotate --------------------------------------------------------
+
+// TestPrintRelayRotate: the operator's answer has to carry the retirement
+// deadline, because retiring the old master early kills credentials still
+// inside their own four-hour lifetime — and nothing else in this system will
+// remind them.
+func TestPrintRelayRotate(t *testing.T) {
+ var buf bytes.Buffer
+ printRelayRotate(&buf, apiRelayRotateReply{
+ Region: "eu-west-1", Kid: "k2", Apps: 7,
+ RetireNoEarlierThan: "2026-09-08T16:00:00Z",
+ })
+ out := buf.String()
+ for _, want := range []string{"rotated eu-west-1", "kid k2", "7 app(s) re-issued",
+ "no earlier than 2026-09-08T16:00:00Z", "/etc/carlos/relay-masters"} {
+ if !strings.Contains(out, want) {
+ t.Fatalf("printRelayRotate missing %q:\n%s", want, out)
+ }
+ }
+}