release: canary TTL — CanaryExpired, and the adopt pass's nag (spec §5)
Every canary/* channel now carries a 7-day lease measured from the pointer's own PromotedAt: CanaryExpired(p, now) is true once a canary pointer has sat unrefreshed past release.CanaryTTL. No new storage — the pointer already carries PromotedAt, so re-promoting a canary refreshes the lease, which is the right semantic: an actively-updated canary never expires. Visibility only, matching §5's prod-wired guard rail right above it: adoptHost logs one line per expired canary route it sees, every pass, before the up-to-date short-circuit — but nothing about expiry blocks adopt, restart, rollback or promote. TestPassAdoptsAnExpiredCanary pins that against a hand-signed, deliberately backdated pointer (the only way to get one under test — every public promotion path stamps the real clock).
4 files changed,
+217
−0
internal/release/adopt.go+10 −0internal/release/adopt_test.go+86 −0internal/release/canary_ttl.go+41 −0internal/release/canary_ttl_test.go+80 −0
diff --git a/internal/release/adopt.go b/internal/release/adopt.go| index dfe39e1c9..5406e9cb0 100644 |
| --- a/internal/release/adopt.go |
| +++ b/internal/release/adopt.go |
| @@ -186,6 +186,16 @@ func adoptHost(ctx context.Context, reg *registry.Registry, s Store, optDir stri |
| if ptr == nil { |
| return |
| } |
| + // The nag (spec §5 "Canaries expire"): visibility only, and deliberately |
| + // BEFORE the up-to-date short-circuit below — an expired canary that |
| + // isn't moving (the common case: the branch shipped once and nobody |
| + // touched it since) must still be seen every pass, not just the passes |
| + // that happen to change something. Nothing here blocks adopt, restart or |
| + // promote; it only prints. |
| + if CanaryExpired(ptr, now) { |
| + fmt.Printf(" %s: canary %s (%s) has been expired %s — still serving, nothing blocked\n", |
| + rt.Host, ptr.Channel, ptr.Version, now.Sub(ptr.PromotedAt).Round(time.Hour)) |
| + } |
| channel := routeChannel(rt) |
| ver := ptr.Version |
| cur, err := LiveVersion(optDir, rt.Host) |
diff --git a/internal/release/adopt_test.go b/internal/release/adopt_test.go| index 624c67f38..498253d49 100644 |
| --- a/internal/release/adopt_test.go |
| +++ b/internal/release/adopt_test.go |
| @@ -2,10 +2,14 @@ package release |
| import ( |
| "context" |
| + "crypto/ed25519" |
| + "encoding/base64" |
| + "encoding/json" |
| "errors" |
| "os" |
| "path/filepath" |
| "testing" |
| + "time" |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| @@ -370,3 +374,85 @@ func TestPassFilterOnlyTouchesRoutesExtraAccepts(t *testing.T) { |
| t.Errorf("stable.example.com live = %q, want unmoved — PassFilter must not touch a route extra rejects", v) |
| } |
| } |
| + |
| +// writeBackdatedPointer signs and writes a channel pointer with an arbitrary |
| +// PromotedAt, bypassing Promote/signPointer's own "stamp now" behavior — the |
| +// only way to get an EXPIRED canary pointer into a store under test, since |
| +// every public promotion path stamps the real clock. Kept in this internal |
| +// test package (never exported) precisely because forging a pointer's lease |
| +// is not something production code should ever be able to do. |
| +func writeBackdatedPointer(t *testing.T, s Store, priv ed25519.PrivateKey, acct, app, channel, version string, promotedAt time.Time) { |
| + t.Helper() |
| + p := &ChannelPointer{Schema: 1, App: app, Channel: channel, Version: version, PromotedAt: promotedAt} |
| + payload, err := json.MarshalIndent(p, "", " ") |
| + if err != nil { |
| + t.Fatalf("marshal backdated pointer: %v", err) |
| + } |
| + b64 := base64.StdEncoding.EncodeToString(payload) |
| + sig := ed25519.Sign(priv, []byte(b64)) |
| + wrapper, err := json.MarshalIndent(signedWrapper{ |
| + Payload: b64, Sig: base64.StdEncoding.EncodeToString(sig), |
| + }, "", " ") |
| + if err != nil { |
| + t.Fatalf("marshal backdated wrapper: %v", err) |
| + } |
| + if _, err := s.Put(context.Background(), ChannelKey(acct, app, channel), wrapper, ""); err != nil { |
| + t.Fatalf("write backdated pointer: %v", err) |
| + } |
| +} |
| + |
| +// TestPassAdoptsAnExpiredCanary pins spec §5's core promise: expiry is |
| +// VISIBILITY, not enforcement. A canary/* route whose pointer is well past |
| +// CanaryTTL must still adopt exactly like a fresh one — nothing about |
| +// expiry may block a repoint, a restart, or a rollback/promote (the same |
| +// signed-pointer plumbing every other channel uses; expiry checks nothing |
| +// about the ladder or the CAS path). |
| +func TestPassAdoptsAnExpiredCanary(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt := t.TempDir() |
| + reg := testRegistry(t) |
| + |
| + shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("build one")) |
| + writeBackdatedPointer(t, s, priv, "bab", "myapp", "canary/x", "v1", |
| + time.Now().Add(-(CanaryTTL + 30*24*time.Hour))) // long expired |
| + addRoute(t, reg, "old-canary.example.com", "myapp", "canary/x", false) |
| + |
| + var restarted []string |
| + restart := func(host string) error { restarted = append(restarted, host); return nil } |
| + if err := Pass(ctx, reg, s, pub, opt, restart); err != nil { |
| + t.Fatalf("Pass: %v", err) |
| + } |
| + |
| + if v, _ := LiveVersion(opt, "old-canary.example.com"); v != "v1" { |
| + t.Fatalf("an expired canary route did not adopt: live = %q, want v1", v) |
| + } |
| + if len(restarted) != 1 || restarted[0] != "old-canary.example.com" { |
| + t.Fatalf("an expired canary route was not restarted: restarted = %v", restarted) |
| + } |
| + rt, err := reg.Get("old-canary.example.com") |
| + if err != nil || rt == nil || rt.Version != "v1" { |
| + t.Fatalf("registry version after adopting an expired canary = %+v, %v", rt, err) |
| + } |
| + |
| + // Promotion off (or onto) an expired canary channel is unaffected too — |
| + // expiry is computed from PromotedAt at read time, never stored, so |
| + // nothing about a pointer's own expiry state can gate a write to it. |
| + shipRelease(t, s, "bab", "myapp", "v2", adoptPlatform, []byte("build two")) |
| + if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v2"); err != nil { |
| + t.Fatalf("promoting a new version onto an expired canary channel: %v", err) |
| + } |
| + p, err := Channel(ctx, s, pub, "bab", "myapp", "canary/x") |
| + if err != nil { |
| + t.Fatalf("Channel after re-promoting an expired canary: %v", err) |
| + } |
| + if p.Version != "v2" { |
| + t.Fatalf("channel = %q after promote, want v2", p.Version) |
| + } |
| + // And the re-promotion refreshed the lease — CanaryExpired.go's whole |
| + // point: an actively-updated canary never expires. |
| + if CanaryExpired(p, time.Now()) { |
| + t.Error("re-promoting an expired canary must refresh its lease, not leave it expired") |
| + } |
| +} |
diff --git a/internal/release/canary_ttl.go b/internal/release/canary_ttl.go| new file mode 100644 |
| index 000000000..2481a4aed |
| --- /dev/null |
| +++ b/internal/release/canary_ttl.go |
| @@ -0,0 +1,41 @@ |
| +// Canary TTL — the pipelines spec's second guard rail (§5 "Canaries |
| +// expire"). Every canary/* channel carries a default 7-day lease, measured |
| +// from the pointer's own PromotedAt: the pointer already carries that |
| +// field, so this needs no new storage, and re-promoting a canary refreshes |
| +// the lease, which is the right semantic — an actively-updated canary never |
| +// expires, however old the branch it started from. |
| +// |
| +// Expiry is visibility, not enforcement, matching §5's "prod-wired" guard |
| +// rail right above it: nothing here stops a route serving, nothing deletes |
| +// a pointer, and promotion to or from an expired canary stays allowed. The |
| +// hazard §5 names is not a canary in production — it's one that outlives |
| +// the branch it was testing and quietly becomes permanent infrastructure |
| +// nobody remembers deploying. Every caller that needs to know computes it |
| +// fresh from the pointer already in hand; nothing persists an "expired" bit |
| +// anywhere. |
| +package release |
| + |
| +import ( |
| + "strings" |
| + "time" |
| +) |
| + |
| +// CanaryTTL is a canary/* channel's default lease length before it is |
| +// flagged expired. Spec §5: "default 7 days". |
| +const CanaryTTL = 7 * 24 * time.Hour |
| + |
| +// CanaryExpired reports whether p is a canary/* channel pointer whose lease |
| +// (PromotedAt + CanaryTTL) has passed as of now. Always false for a channel |
| +// outside canary/* — edge, beta and stable carry no TTL — and false for a |
| +// nil pointer or a zero PromotedAt (an unpromoted or not-yet-read pointer): |
| +// unknown age is never treated as proof of age. Exactly CanaryTTL old is |
| +// NOT yet expired; the TTL names the lease's length. |
| +func CanaryExpired(p *ChannelPointer, now time.Time) bool { |
| + if p == nil || !strings.HasPrefix(p.Channel, RungCanary+"/") { |
| + return false |
| + } |
| + if p.PromotedAt.IsZero() { |
| + return false |
| + } |
| + return now.Sub(p.PromotedAt) > CanaryTTL |
| +} |
diff --git a/internal/release/canary_ttl_test.go b/internal/release/canary_ttl_test.go| new file mode 100644 |
| index 000000000..29c4a4e66 |
| --- /dev/null |
| +++ b/internal/release/canary_ttl_test.go |
| @@ -0,0 +1,80 @@ |
| +package release |
| + |
| +import ( |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestCanaryExpiredFalseForFreshPromotion(t *testing.T) { |
| + p := &ChannelPointer{App: "myapp", Channel: "canary/x", Version: "v1", PromotedAt: time.Now()} |
| + if CanaryExpired(p, time.Now()) { |
| + t.Error("a canary promoted moments ago must not be expired") |
| + } |
| +} |
| + |
| +func TestCanaryExpiredTrueAfterTTL(t *testing.T) { |
| + old := time.Now().Add(-(CanaryTTL + time.Hour)) |
| + p := &ChannelPointer{App: "myapp", Channel: "canary/x", Version: "v1", PromotedAt: old} |
| + if !CanaryExpired(p, time.Now()) { |
| + t.Error("a canary promoted more than CanaryTTL ago must be expired") |
| + } |
| +} |
| + |
| +// Right at the boundary: exactly CanaryTTL old is NOT yet expired — the TTL |
| +// names the lease's length, not a moment strictly before it. |
| +func TestCanaryExpiredBoundary(t *testing.T) { |
| + now := time.Now() |
| + atBoundary := &ChannelPointer{Channel: "canary/x", PromotedAt: now.Add(-CanaryTTL)} |
| + if CanaryExpired(atBoundary, now) { |
| + t.Error("exactly CanaryTTL old must not be expired yet") |
| + } |
| + pastBoundary := &ChannelPointer{Channel: "canary/x", PromotedAt: now.Add(-CanaryTTL - time.Second)} |
| + if !CanaryExpired(pastBoundary, now) { |
| + t.Error("one second past CanaryTTL must be expired") |
| + } |
| +} |
| + |
| +// Re-promoting a canary refreshes PromotedAt, which is the whole point of |
| +// keying age off the pointer's own lease timestamp: an actively-updated |
| +// canary never expires, however old the channel name itself is. |
| +func TestCanaryExpiredRefreshesOnRepromotion(t *testing.T) { |
| + now := time.Now() |
| + stale := &ChannelPointer{Channel: "canary/x", PromotedAt: now.Add(-(CanaryTTL + 24*time.Hour))} |
| + if !CanaryExpired(stale, now) { |
| + t.Fatal("setup: this pointer should read as expired before the repromotion") |
| + } |
| + refreshed := &ChannelPointer{Channel: "canary/x", PromotedAt: now} |
| + if CanaryExpired(refreshed, now) { |
| + t.Error("a freshly re-promoted canary must not be expired, however old the channel is") |
| + } |
| +} |
| + |
| +// Only canary/* channels carry a TTL at all — edge, beta and stable are |
| +// never flagged, however old their PromotedAt is. |
| +func TestCanaryExpiredFalseForNonCanaryChannels(t *testing.T) { |
| + now := time.Now() |
| + ancient := now.Add(-365 * 24 * time.Hour) |
| + for _, ch := range []string{"stable", "beta", "edge", "canary"} { |
| + p := &ChannelPointer{Channel: ch, PromotedAt: ancient} |
| + if CanaryExpired(p, now) { |
| + t.Errorf("channel %q must never be flagged expired (not a canary/* channel)", ch) |
| + } |
| + } |
| +} |
| + |
| +// A pointer written before PromotedAt existed (or any zero value) must not |
| +// be flagged expired — unknown age is not proof of age, and validatePointer |
| +// already refuses a zero PromotedAt on anything read off the wire, so this |
| +// is defense for a directly-constructed pointer, not a live-fleet case. |
| +func TestCanaryExpiredFalseForZeroPromotedAt(t *testing.T) { |
| + p := &ChannelPointer{Channel: "canary/x"} |
| + if CanaryExpired(p, time.Now()) { |
| + t.Error("a zero PromotedAt must not be flagged expired") |
| + } |
| +} |
| + |
| +func TestCanaryExpiredNilPointer(t *testing.T) { |
| + if CanaryExpired(nil, time.Now()) { |
| + t.Error("a nil pointer must not be flagged expired") |
| + } |
| +} |