carlos / platform Public

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).
Paul Campbell 1a2d443e110b59943f9a45e7706fc01f308a0cfb parent 6c8d5fd
4 files changed, +217 −0
  • internal/release/adopt.go +10 −0
  • internal/release/adopt_test.go +86 −0
  • internal/release/canary_ttl.go +41 −0
  • internal/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")
+ }
+}