| 1 | package edgesfile |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "errors" |
| 7 | "log" |
| 8 | "slices" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/release" |
| 14 | "github.com/carlosframework/platform/internal/status" |
| 15 | ) |
| 16 | |
| 17 | // The fold can only WIDEN: a box the deployment can see adds its EIP, and the |
| 18 | // operator's env list is always included. That is the safe direction — |
| 19 | // nothing here deletes, and a short fold must degrade to today's behaviour |
| 20 | // (env only), never below it. |
| 21 | func TestEdgeIPsUnionsTheFoldWithTheEnvList(t *testing.T) { |
| 22 | s := foldTestStore(t) |
| 23 | now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) |
| 24 | putEdgesFile(t, s, "annie", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{}}`) |
| 25 | putEdgesFile(t, s, "betty", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{}}`) |
| 26 | putFreshBox(t, s, "annie", now) |
| 27 | putFreshBox(t, s, "betty", now) |
| 28 | |
| 29 | got := EdgeIPs(context.Background(), s, []string{"9.9.9.9"}, now) |
| 30 | slices.Sort(got) |
| 31 | want := []string{"1.1.1.1", "2.2.2.2", "9.9.9.9"} |
| 32 | if !slices.Equal(got, want) { |
| 33 | t.Fatalf("EdgeIPs = %v, want %v", got, want) |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | // A box whose STATUS SNAPSHOT has gone stale drops out — that is what keeps a |
| 38 | // retired box's lingering edges file from holding a dead customer domain at |
| 39 | // "pointing" forever (a lying green). Generation is NOT the signal: an |
| 40 | // unchanged file is never republished, so a healthy box's Generation is |
| 41 | // arbitrarily old. |
| 42 | func TestEdgeIPsDropsABoxWhoseSnapshotIsStale(t *testing.T) { |
| 43 | s := foldTestStore(t) |
| 44 | now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) |
| 45 | putEdgesFile(t, s, "annie", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{}}`) |
| 46 | putEdgesFile(t, s, "retired", `{"v":1,"box":"retired","eip":"3.3.3.3","hosts":{}}`) |
| 47 | putFreshBox(t, s, "annie", now) |
| 48 | putFreshBox(t, s, "retired", now.Add(-72*time.Hour)) |
| 49 | |
| 50 | got := EdgeIPs(context.Background(), s, nil, now) |
| 51 | if slices.Contains(got, "3.3.3.3") { |
| 52 | t.Fatalf("EdgeIPs = %v, want the retired box's EIP dropped", got) |
| 53 | } |
| 54 | if !slices.Contains(got, "1.1.1.1") { |
| 55 | t.Fatalf("EdgeIPs = %v, want the live box kept", got) |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | // An edges file with no matching status snapshot at all is not evidence of |
| 60 | // liveness. Same treatment as a stale one. |
| 61 | func TestEdgeIPsDropsABoxWithNoSnapshot(t *testing.T) { |
| 62 | s := foldTestStore(t) |
| 63 | now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) |
| 64 | putEdgesFile(t, s, "ghost", `{"v":1,"box":"ghost","eip":"4.4.4.4","hosts":{}}`) |
| 65 | if got := EdgeIPs(context.Background(), s, []string{"9.9.9.9"}, now); !slices.Equal(got, []string{"9.9.9.9"}) { |
| 66 | t.Fatalf("EdgeIPs = %v, want env only", got) |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | // A store that cannot be listed degrades to the env list — never to empty. An |
| 71 | // empty edge set would stamp every custom domain "elsewhere" at once. |
| 72 | func TestEdgeIPsFallsBackToEnvWhenTheStoreFails(t *testing.T) { |
| 73 | s := brokenStore{} |
| 74 | now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) |
| 75 | if got := EdgeIPs(context.Background(), s, []string{"9.9.9.9"}, now); !slices.Equal(got, []string{"9.9.9.9"}) { |
| 76 | t.Fatalf("EdgeIPs = %v, want the env list on a store failure", got) |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | // The fixtures above call now "fresh" and now-72h "stale" in their own |
| 81 | // words; this asserts the RULE agrees, so a change to status.SnapshotPeriod |
| 82 | // or to BoxStale's factor turns this test red rather than quietly turning |
| 83 | // the two tests above into assertions about nothing. |
| 84 | func TestFoldFixtureAgreesWithTheStalenessRule(t *testing.T) { |
| 85 | now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) |
| 86 | if status.BoxStale(status.Box{GeneratedAt: now}, now, status.SnapshotPeriod) { |
| 87 | t.Error("a snapshot written this instant must read as fresh") |
| 88 | } |
| 89 | if !status.BoxStale(status.Box{GeneratedAt: now.Add(-72 * time.Hour)}, now, status.SnapshotPeriod) { |
| 90 | t.Error("a snapshot 72h old must read as stale — the retired-box fixture depends on it") |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | // A box publishing no EIP at all (readPublicIP never answered) contributes |
| 95 | // nothing rather than an empty string, which would match no resolution and |
| 96 | // only pollute the guidance strings the console builds from the same set. |
| 97 | func TestEdgeIPsSkipsABoxWithNoAddress(t *testing.T) { |
| 98 | s := foldTestStore(t) |
| 99 | now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) |
| 100 | putEdgesFile(t, s, "annie", `{"v":1,"box":"annie","eip":"","hosts":{}}`) |
| 101 | putFreshBox(t, s, "annie", now) |
| 102 | if got := EdgeIPs(context.Background(), s, nil, now); len(got) != 0 { |
| 103 | t.Fatalf("EdgeIPs = %v, want nothing — a blank EIP is not an address", got) |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | // ---- fixtures --------------------------------------------------------------- |
| 108 | |
| 109 | // foldTestStore is a real DirStore over a temp dir: the fold reads through |
| 110 | // release.Store and status.LoadBoxes, and a hand-rolled fake would have to |
| 111 | // re-implement both prefixes' listing semantics to say anything true. |
| 112 | func foldTestStore(t *testing.T) release.Store { |
| 113 | t.Helper() |
| 114 | return release.NewDirStore(t.TempDir()) |
| 115 | } |
| 116 | |
| 117 | func putEdgesFile(t *testing.T, s release.Store, box, body string) { |
| 118 | t.Helper() |
| 119 | if _, err := s.Put(context.Background(), Key(box), []byte(body), ""); err != nil { |
| 120 | t.Fatalf("put edges file for %s: %v", box, err) |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | // putFreshBox writes one box snapshot through status.WriteBox — the SAME |
| 125 | // writer statusTick uses — so the key derivation and the Label field the |
| 126 | // fold joins on cannot drift from production. at is the snapshot's |
| 127 | // GeneratedAt: the caller decides whether that reads as fresh or stale, and |
| 128 | // TestFoldFixtureAgreesWithTheStalenessRule pins both readings. |
| 129 | func putFreshBox(t *testing.T, s release.Store, label string, at time.Time) { |
| 130 | t.Helper() |
| 131 | if err := status.WriteBox(context.Background(), s, status.Box{Label: label, GeneratedAt: at}); err != nil { |
| 132 | t.Fatalf("write box snapshot for %s: %v", label, err) |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | // brokenStore is a store whose every read fails — the bucket-is-down case. |
| 137 | type brokenStore struct{} |
| 138 | |
| 139 | var errBrokenStore = errors.New("store is down") |
| 140 | |
| 141 | func (brokenStore) Get(context.Context, string) ([]byte, string, error) { |
| 142 | return nil, "", errBrokenStore |
| 143 | } |
| 144 | func (brokenStore) Put(context.Context, string, []byte, string) (string, error) { |
| 145 | return "", errBrokenStore |
| 146 | } |
| 147 | func (brokenStore) List(context.Context, string) ([]string, error) { return nil, errBrokenStore } |
| 148 | func (brokenStore) ListDirs(context.Context, string) ([]string, error) { return nil, errBrokenStore } |
| 149 | func (brokenStore) Delete(context.Context, string) error { return errBrokenStore } |
| 150 | |
| 151 | // Edges files exist and NONE of them proved live: the answer degrades to the |
| 152 | // env list with no error and nothing red, so the log line is the only thing |
| 153 | // an operator can see. The likeliest cause is configuration rather than a |
| 154 | // dead fleet — edges files are written through openDeployStore while box |
| 155 | // snapshots go through openStatusStore, which honours CARLOS_STATUS_BUCKET |
| 156 | // and CARLOS_STATUS_PREFIX, so the two halves of this join are the same |
| 157 | // store by default but not by guarantee. Operational, not contractual: what |
| 158 | // is pinned is that the line appears at all and names where to look. |
| 159 | func TestEdgeIPsSaysSoWhenNothingJoined(t *testing.T) { |
| 160 | buf := captureLog(t) |
| 161 | s := foldTestStore(t) |
| 162 | now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) |
| 163 | putEdgesFile(t, s, "annie", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{}}`) |
| 164 | putEdgesFile(t, s, "betty", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{}}`) |
| 165 | // No snapshots at all — exactly what a split-bucket deployment looks |
| 166 | // like from in here. |
| 167 | if got := EdgeIPs(context.Background(), s, []string{"9.9.9.9"}, now); !slices.Equal(got, []string{"9.9.9.9"}) { |
| 168 | t.Fatalf("EdgeIPs = %v, want env only", got) |
| 169 | } |
| 170 | line := buf.String() |
| 171 | if !strings.Contains(line, "no fresh box snapshot matched") { |
| 172 | t.Errorf("log = %q, want the total-miss tripwire", line) |
| 173 | } |
| 174 | if !strings.Contains(line, "CARLOS_STATUS_BUCKET") { |
| 175 | t.Errorf("log = %q, want it to name where to look", line) |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | // A deployment that has published no edges files yet is an ordinary state, |
| 180 | // not a fault — and so is a partial miss, where one retired box drops out and |
| 181 | // the rest join. Neither may cry wolf, or the line stops being read. |
| 182 | func TestEdgeIPsStaysQuietWhenThereIsNothingToJoinOrSomethingDid(t *testing.T) { |
| 183 | now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) |
| 184 | |
| 185 | buf := captureLog(t) |
| 186 | empty := foldTestStore(t) |
| 187 | EdgeIPs(context.Background(), empty, []string{"9.9.9.9"}, now) |
| 188 | if strings.Contains(buf.String(), "no fresh box snapshot matched") { |
| 189 | t.Errorf("log = %q, want silence: no edges files is not a fault", buf.String()) |
| 190 | } |
| 191 | |
| 192 | buf2 := captureLog(t) |
| 193 | s := foldTestStore(t) |
| 194 | putEdgesFile(t, s, "annie", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{}}`) |
| 195 | putEdgesFile(t, s, "retired", `{"v":1,"box":"retired","eip":"3.3.3.3","hosts":{}}`) |
| 196 | putFreshBox(t, s, "annie", now) |
| 197 | putFreshBox(t, s, "retired", now.Add(-72*time.Hour)) |
| 198 | EdgeIPs(context.Background(), s, nil, now) |
| 199 | if strings.Contains(buf2.String(), "no fresh box snapshot matched") { |
| 200 | t.Errorf("log = %q, want silence: one box joined, which is the rule working", buf2.String()) |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | // captureLog redirects the standard logger for one test — the same |
| 205 | // save/restore-under-Cleanup shape internal/steering's tests use. |
| 206 | func captureLog(t *testing.T) *bytes.Buffer { |
| 207 | t.Helper() |
| 208 | var buf bytes.Buffer |
| 209 | prev, flags := log.Writer(), log.Flags() |
| 210 | log.SetOutput(&buf) |
| 211 | log.SetFlags(0) |
| 212 | t.Cleanup(func() { log.SetOutput(prev); log.SetFlags(flags) }) |
| 213 | return &buf |
| 214 | } |
| 215 | |
| 216 | // DeploymentRoutes is the evidence a hostname claim is checked against |
| 217 | // before it may materialize as an alias, so what it counts and what it |
| 218 | // ignores is the whole rule: NON-alias home entries on LIVE boxes, and |
| 219 | // nothing else. |
| 220 | // |
| 221 | // The alias exclusion is the load-bearing half. An alias IS a materialized |
| 222 | // claim, so counting one would make every claim reserve its own hostname |
| 223 | // against itself and no re-derivation would ever succeed again — the |
| 224 | // self-poisoning failure this exclusion exists to prevent. |
| 225 | func TestDeploymentRoutesCountsRealRoutesAndIgnoresAliases(t *testing.T) { |
| 226 | s := foldTestStore(t) |
| 227 | now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) |
| 228 | putEdgesFile(t, s, "annie", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{ |
| 229 | "go.tito.io":{"role":"home"}, |
| 230 | "tickets.example.com":{"role":"home","alias":true}, |
| 231 | "app.bab.oncarlos.com":{"role":"home","platform":true}}}`) |
| 232 | putFreshBox(t, s, "annie", now) |
| 233 | |
| 234 | got := DeploymentRoutes(context.Background(), s, now) |
| 235 | if !got["go.tito.io"] { |
| 236 | t.Errorf("routes = %v, want the operator-minted route counted", got) |
| 237 | } |
| 238 | if got["tickets.example.com"] { |
| 239 | t.Errorf("routes = %v, want the ALIAS entry ignored — counting it would make every claim reserve its own hostname against itself", got) |
| 240 | } |
| 241 | // A platform mint is still a real route: its hostname is genuinely |
| 242 | // served and is not a claim's own output. |
| 243 | if !got["app.bab.oncarlos.com"] { |
| 244 | t.Errorf("routes = %v, want a platform-minted route counted", got) |
| 245 | } |
| 246 | } |
| 247 | |
| 248 | // A retired box's lingering edges file must not go on reserving hostnames |
| 249 | // forever — the same staleness rule EdgeIPs uses, for the same reason, and |
| 250 | // with sharper consequences: here a phantom entry is a REFUSAL, so a dead |
| 251 | // box would permanently block a hostname nobody serves. |
| 252 | func TestDeploymentRoutesDropsABoxWhoseSnapshotIsStale(t *testing.T) { |
| 253 | s := foldTestStore(t) |
| 254 | now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) |
| 255 | putEdgesFile(t, s, "annie", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{"live.example":{"role":"home"}}}`) |
| 256 | putEdgesFile(t, s, "retired", `{"v":1,"box":"retired","eip":"3.3.3.3","hosts":{"dead.example":{"role":"home"}}}`) |
| 257 | putFreshBox(t, s, "annie", now) |
| 258 | putFreshBox(t, s, "retired", now.Add(-72*time.Hour)) |
| 259 | |
| 260 | got := DeploymentRoutes(context.Background(), s, now) |
| 261 | if got["dead.example"] { |
| 262 | t.Errorf("routes = %v, want the retired box's hostnames dropped", got) |
| 263 | } |
| 264 | if !got["live.example"] { |
| 265 | t.Errorf("routes = %v, want the live box's hostname kept", got) |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | // Best-effort by contract: a store that cannot be read, and no store at all, |
| 270 | // both answer nil — "nothing known", which callers treat as permissive. |
| 271 | // Fail-OPEN is deliberate here even though this is a security guard: a |
| 272 | // bucket that cannot be listed must not take every tenant's custom domain |
| 273 | // down with it, and the per-box route check plus the fold's own route |
| 274 | // preference are the layers underneath. |
| 275 | func TestDeploymentRoutesIsNilWhenNothingCanBeRead(t *testing.T) { |
| 276 | now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) |
| 277 | if got := DeploymentRoutes(context.Background(), nil, now); got != nil { |
| 278 | t.Errorf("nil store: routes = %v, want nil", got) |
| 279 | } |
| 280 | if got := DeploymentRoutes(context.Background(), brokenStore{}, now); got != nil { |
| 281 | t.Errorf("broken store: routes = %v, want nil", got) |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | // The total-miss tripwire, EdgeIPs' line and then some: there a total miss |
| 286 | // degrades to the operator's configured edge list, which is merely the |
| 287 | // pre-fold behaviour. Here it degrades to "no hostname is anyone's", which |
| 288 | // silently DISARMS the guard — an empty map, no error, nothing red, and |
| 289 | // every claim materializing exactly as it did before the rule existed. The |
| 290 | // same three causes (split buckets, every box dark, box-name drift) and the |
| 291 | // same invisibility, so the log line is all an operator gets. |
| 292 | func TestDeploymentRoutesSaysSoWhenNothingJoined(t *testing.T) { |
| 293 | buf := captureLog(t) |
| 294 | s := foldTestStore(t) |
| 295 | now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) |
| 296 | putEdgesFile(t, s, "annie", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{"go.tito.io":{"role":"home"}}}`) |
| 297 | // No snapshots at all — what a split-bucket deployment looks like here. |
| 298 | if got := DeploymentRoutes(context.Background(), s, now); len(got) != 0 { |
| 299 | t.Fatalf("routes = %v, want empty", got) |
| 300 | } |
| 301 | line := buf.String() |
| 302 | if !strings.Contains(line, "no fresh box snapshot matched") { |
| 303 | t.Errorf("log = %q, want the total-miss tripwire", line) |
| 304 | } |
| 305 | if !strings.Contains(line, "CARLOS_STATUS_BUCKET") { |
| 306 | t.Errorf("log = %q, want it to name where to look", line) |
| 307 | } |
| 308 | |
| 309 | // And silence when there is nothing to join: an empty deployment is an |
| 310 | // ordinary state, not a fault, or the line stops being read. |
| 311 | buf2 := captureLog(t) |
| 312 | DeploymentRoutes(context.Background(), foldTestStore(t), now) |
| 313 | if strings.Contains(buf2.String(), "no fresh box snapshot matched") { |
| 314 | t.Errorf("log = %q, want silence: no edges files is not a fault", buf2.String()) |
| 315 | } |
| 316 | } |
| 317 | |