| 1 | package domains |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "strconv" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/registry" |
| 12 | "github.com/carlosframework/platform/internal/release" |
| 13 | ) |
| 14 | |
| 15 | // addInstance upserts a provisioned instance route the way the provision |
| 16 | // pass mints one: Slug is the host's first label. |
| 17 | func addInstance(t *testing.T, reg *registry.Registry, host, slug, account, app string) { |
| 18 | t.Helper() |
| 19 | if err := reg.Upsert(registry.Route{ |
| 20 | Host: host, Slug: slug, App: app, Account: account, Kind: "instance", Status: "ready", |
| 21 | }); err != nil { |
| 22 | t.Fatal(err) |
| 23 | } |
| 24 | if err := reg.SetProvisioned(host, true); err != nil { |
| 25 | t.Fatal(err) |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | func aliasMap(t *testing.T, reg *registry.Registry) map[string]registry.RouteAlias { |
| 30 | t.Helper() |
| 31 | aliases, err := reg.ListAliases() |
| 32 | if err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | out := map[string]registry.RouteAlias{} |
| 36 | for _, a := range aliases { |
| 37 | out[a.Alias] = a |
| 38 | } |
| 39 | return out |
| 40 | } |
| 41 | |
| 42 | // jellyStore is the letsjelly.dev testbed in miniature: account bad holds |
| 43 | // the apex through its static site, and dev-managed-jelly holds the |
| 44 | // instance catch-all. |
| 45 | func jellyStore(t *testing.T) release.Store { |
| 46 | t.Helper() |
| 47 | s := release.NewDirStore(t.TempDir()) |
| 48 | putClaim(t, s, Claim{Host: "letsjelly.dev", Account: "bad", App: "dev-jelly-site"}) |
| 49 | putClaim(t, s, Claim{Host: "*.letsjelly.dev", Account: "bad", App: "dev-managed-jelly", Instances: true}) |
| 50 | return s |
| 51 | } |
| 52 | |
| 53 | func reconcile(t *testing.T, s release.Store, reg *registry.Registry, deploymentRoutes map[string]bool) { |
| 54 | t.Helper() |
| 55 | if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), deploymentRoutes, nil); err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | func TestInstanceCatchallDerivesOneAliasPerInstance(t *testing.T) { |
| 61 | s := jellyStore(t) |
| 62 | reg := aliasRegWith(t) |
| 63 | addInstance(t, reg, "acme.bad.carlos.exciting.io", "acme", "bad", "dev-managed-jelly") |
| 64 | addInstance(t, reg, "smoketest.bad.carlos.exciting.io", "smoketest", "bad", "dev-managed-jelly") |
| 65 | reconcile(t, s, reg, nil) |
| 66 | got := aliasMap(t, reg) |
| 67 | if len(got) != 2 { |
| 68 | t.Fatalf("got %d aliases, want 2: %+v", len(got), got) |
| 69 | } |
| 70 | a := got["acme.letsjelly.dev"] |
| 71 | if a.Host != "acme.bad.carlos.exciting.io" || a.Account != "bad" || a.App != "dev-managed-jelly" { |
| 72 | t.Fatalf("acme alias = %+v", a) |
| 73 | } |
| 74 | if got["smoketest.letsjelly.dev"].Host != "smoketest.bad.carlos.exciting.io" { |
| 75 | t.Fatalf("smoketest alias = %+v", got["smoketest.letsjelly.dev"]) |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | func TestInstanceCatchallFollowsTheInstance(t *testing.T) { |
| 80 | s := jellyStore(t) |
| 81 | reg := aliasRegWith(t) |
| 82 | addInstance(t, reg, "acme.bad.carlos.exciting.io", "acme", "bad", "dev-managed-jelly") |
| 83 | reconcile(t, s, reg, nil) |
| 84 | if _, ok := aliasMap(t, reg)["acme.letsjelly.dev"]; !ok { |
| 85 | t.Fatal("no alias for a live instance") |
| 86 | } |
| 87 | // The instance leaves the box (deleted, or moved elsewhere): its name |
| 88 | // leaves with it on the next pass. |
| 89 | if err := reg.Delete("acme.bad.carlos.exciting.io"); err != nil { |
| 90 | t.Fatal(err) |
| 91 | } |
| 92 | reconcile(t, s, reg, nil) |
| 93 | if got := aliasMap(t, reg); len(got) != 0 { |
| 94 | t.Fatalf("alias outlived its instance: %+v", got) |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | func TestInstanceCatchallDetachDropsEveryName(t *testing.T) { |
| 99 | s := jellyStore(t) |
| 100 | reg := aliasRegWith(t) |
| 101 | addInstance(t, reg, "acme.bad.carlos.exciting.io", "acme", "bad", "dev-managed-jelly") |
| 102 | reconcile(t, s, reg, nil) |
| 103 | deleteClaim(t, s, "*.letsjelly.dev") |
| 104 | reconcile(t, s, reg, nil) |
| 105 | if got := aliasMap(t, reg); len(got) != 0 { |
| 106 | t.Fatalf("aliases outlived the catch-all: %+v", got) |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | // An exact claim on a derived name wins, whoever holds it: a same-account |
| 111 | // one (the Jelly manager's per-team claims, www, auth) and another |
| 112 | // account's alike. |
| 113 | func TestInstanceCatchallExactClaimWins(t *testing.T) { |
| 114 | s := jellyStore(t) |
| 115 | putClaim(t, s, Claim{Host: "acme.letsjelly.dev", Account: "bad", App: "dev-managed-jelly", Route: "acme.bad.carlos.exciting.io"}) |
| 116 | putClaim(t, s, Claim{Host: "beta.letsjelly.dev", Account: "zzz", App: "other", Route: "x.zzz.carlos.exciting.io"}) |
| 117 | reg := aliasRegWith(t) |
| 118 | addInstance(t, reg, "acme.bad.carlos.exciting.io", "acme", "bad", "dev-managed-jelly") |
| 119 | addInstance(t, reg, "beta.bad.carlos.exciting.io", "beta", "bad", "dev-managed-jelly") |
| 120 | reconcile(t, s, reg, nil) |
| 121 | got := aliasMap(t, reg) |
| 122 | // acme is still served — by its exact claim's row, which says the same. |
| 123 | if got["acme.letsjelly.dev"].Host != "acme.bad.carlos.exciting.io" { |
| 124 | t.Fatalf("acme = %+v", got["acme.letsjelly.dev"]) |
| 125 | } |
| 126 | // beta's exact claim belongs to another account whose route is not on |
| 127 | // this box: no row here at all, and certainly not the derived one. |
| 128 | if a, ok := got["beta.letsjelly.dev"]; ok { |
| 129 | t.Fatalf("derived alias shadowed another account's exact claim: %+v", a) |
| 130 | } |
| 131 | // Removing the exact claim hands the name back to the catch-all. |
| 132 | deleteClaim(t, s, "acme.letsjelly.dev") |
| 133 | reconcile(t, s, reg, nil) |
| 134 | if a := aliasMap(t, reg)["acme.letsjelly.dev"]; a.Host != "acme.bad.carlos.exciting.io" { |
| 135 | t.Fatalf("after the exact claim went, acme = %+v", a) |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | func TestInstanceCatchallRoutesWin(t *testing.T) { |
| 140 | s := jellyStore(t) |
| 141 | reg := aliasRegWith(t) |
| 142 | addInstance(t, reg, "acme.bad.carlos.exciting.io", "acme", "bad", "dev-managed-jelly") |
| 143 | addInstance(t, reg, "beta.bad.carlos.exciting.io", "beta", "bad", "dev-managed-jelly") |
| 144 | addRoute(t, reg, "acme.letsjelly.dev", "bad", "something") |
| 145 | reconcile(t, s, reg, map[string]bool{"beta.letsjelly.dev": true}) |
| 146 | got := aliasMap(t, reg) |
| 147 | if len(got) != 0 { |
| 148 | t.Fatalf("a derived name shadowed a real route: %+v", got) |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | // The catch-all is honoured only while its parent is held by the same |
| 153 | // account — released, or held by someone else, it derives nothing. |
| 154 | func TestInstanceCatchallNeedsTheParentInItsAccount(t *testing.T) { |
| 155 | for name, apex := range map[string]*Claim{ |
| 156 | "released": nil, |
| 157 | "other account": {Host: "letsjelly.dev", Account: "zzz", App: "squat"}, |
| 158 | } { |
| 159 | t.Run(name, func(t *testing.T) { |
| 160 | s := release.NewDirStore(t.TempDir()) |
| 161 | putClaim(t, s, Claim{Host: "*.letsjelly.dev", Account: "bad", App: "dev-managed-jelly", Instances: true}) |
| 162 | if apex != nil { |
| 163 | putClaim(t, s, *apex) |
| 164 | } |
| 165 | reg := aliasRegWith(t) |
| 166 | addInstance(t, reg, "acme.bad.carlos.exciting.io", "acme", "bad", "dev-managed-jelly") |
| 167 | reconcile(t, s, reg, nil) |
| 168 | if got := aliasMap(t, reg); len(got) != 0 { |
| 169 | t.Fatalf("derived without the parent: %+v", got) |
| 170 | } |
| 171 | }) |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | // Only the claim's own account and app, and only provisioned instance rows |
| 176 | // whose Slug is the host's first label. |
| 177 | func TestInstanceCatchallOnlyItsOwnInstances(t *testing.T) { |
| 178 | s := jellyStore(t) |
| 179 | reg := aliasRegWith(t) |
| 180 | addInstance(t, reg, "acme.bad.carlos.exciting.io", "acme", "bad", "dev-managed-jelly") |
| 181 | // A sibling app in the same account. |
| 182 | addInstance(t, reg, "sib.bad.carlos.exciting.io", "sib", "bad", "dev-jelly-manager") |
| 183 | // Another account's app of the same name. |
| 184 | addInstance(t, reg, "evil.zzz.carlos.exciting.io", "evil", "zzz", "dev-managed-jelly") |
| 185 | // A hand-added route (not provisioned), with a slug. |
| 186 | if err := reg.Upsert(registry.Route{Host: "hand.bad.carlos.exciting.io", Slug: "hand", App: "dev-managed-jelly", Account: "bad", Kind: "instance", Status: "ready"}); err != nil { |
| 187 | t.Fatal(err) |
| 188 | } |
| 189 | // A replica row: Slug is the whole host (provision.Replica) ... |
| 190 | addInstance(t, reg, "acme-r.bad.carlos.exciting.io", "acme-r.bad.carlos.exciting.io", "bad", "dev-managed-jelly") |
| 191 | // ... or a label, when the create door wrote it; the mark excludes it. |
| 192 | addInstance(t, reg, "rep.bad.carlos.exciting.io", "rep", "bad", "dev-managed-jelly") |
| 193 | if err := reg.SetReplica("rep.bad.carlos.exciting.io", true); err != nil { |
| 194 | t.Fatal(err) |
| 195 | } |
| 196 | // A sidecar of the app. |
| 197 | if err := reg.Upsert(registry.Route{Host: "side.sidecar.internal", Slug: "side", App: "dev-managed-jelly", Account: "bad", Kind: "sidecar", Status: "ready"}); err != nil { |
| 198 | t.Fatal(err) |
| 199 | } |
| 200 | // A slug that is not the host's first label. |
| 201 | addInstance(t, reg, "odd.bad.carlos.exciting.io", "other", "bad", "dev-managed-jelly") |
| 202 | reconcile(t, s, reg, nil) |
| 203 | got := aliasMap(t, reg) |
| 204 | if len(got) != 1 || got["acme.letsjelly.dev"].Host != "acme.bad.carlos.exciting.io" { |
| 205 | t.Fatalf("got %+v, want only acme.letsjelly.dev", got) |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | // A catch-all carrying fields no door writes on one is not honoured. |
| 210 | func TestInstanceCatchallRefusesHandWrittenShapes(t *testing.T) { |
| 211 | for name, c := range map[string]Claim{ |
| 212 | "route": {Route: "acme.bad.carlos.exciting.io"}, |
| 213 | "wildcard": {Wildcard: true}, |
| 214 | "paths": {Paths: map[string]string{"x": "acme.bad.carlos.exciting.io"}}, |
| 215 | } { |
| 216 | t.Run(name, func(t *testing.T) { |
| 217 | s := release.NewDirStore(t.TempDir()) |
| 218 | putClaim(t, s, Claim{Host: "letsjelly.dev", Account: "bad", App: "dev-jelly-site"}) |
| 219 | c.Host, c.Account, c.App, c.Instances = "*.letsjelly.dev", "bad", "dev-managed-jelly", true |
| 220 | putClaim(t, s, c) |
| 221 | reg := aliasRegWith(t) |
| 222 | addInstance(t, reg, "acme.bad.carlos.exciting.io", "acme", "bad", "dev-managed-jelly") |
| 223 | reconcile(t, s, reg, nil) |
| 224 | for alias := range aliasMap(t, reg) { |
| 225 | if alias == "acme.letsjelly.dev" { |
| 226 | t.Fatalf("derived from a hand-written catch-all (%s)", name) |
| 227 | } |
| 228 | } |
| 229 | }) |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | // The catch-all sits beside the apex's own one-route catch-all: the exact |
| 234 | // derived names win at the edge (an exact key beats "*."), and neither pass |
| 235 | // removes the other's row. |
| 236 | func TestInstanceCatchallBesideARouteCatchall(t *testing.T) { |
| 237 | s := release.NewDirStore(t.TempDir()) |
| 238 | putClaim(t, s, Claim{Host: "letsjelly.dev", Account: "bad", App: "dev-jelly-site", Wildcard: true, Catchall: true}) |
| 239 | putClaim(t, s, Claim{Host: "*.letsjelly.dev", Account: "bad", App: "dev-managed-jelly", Instances: true}) |
| 240 | reg := aliasRegWith(t) |
| 241 | if err := reg.Upsert(registry.Route{Host: "letsjelly.dev", App: "dev-jelly-site", Account: "bad", Kind: "static", Status: "ready"}); err != nil { |
| 242 | t.Fatal(err) |
| 243 | } |
| 244 | addInstance(t, reg, "acme.bad.carlos.exciting.io", "acme", "bad", "dev-managed-jelly") |
| 245 | reconcile(t, s, reg, nil) |
| 246 | got := aliasMap(t, reg) |
| 247 | if got["*.letsjelly.dev"].Host != "letsjelly.dev" || got["acme.letsjelly.dev"].Host != "acme.bad.carlos.exciting.io" { |
| 248 | t.Fatalf("got %+v", got) |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | func TestParseInstanceCatchallHost(t *testing.T) { |
| 253 | for raw, want := range map[string]string{ |
| 254 | "*.letsjelly.dev": "letsjelly.dev", |
| 255 | "*.LetsJelly.dev.": "letsjelly.dev", |
| 256 | "*.a.b.example.com": "a.b.example.com", |
| 257 | "*.dev": "", |
| 258 | "letsjelly.dev": "", |
| 259 | "*letsjelly.dev": "", |
| 260 | "*.*.letsjelly.dev": "", |
| 261 | "x.*.letsjelly.dev": "", |
| 262 | "*.": "", |
| 263 | } { |
| 264 | got, ok := ParseInstanceCatchallHost(raw) |
| 265 | if ok != (want != "") || got != want { |
| 266 | t.Errorf("ParseInstanceCatchallHost(%q) = %q, %v; want %q", raw, got, ok, want) |
| 267 | } |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | func TestAttachInstanceCatchall(t *testing.T) { |
| 272 | ctx := context.Background() |
| 273 | s := release.NewDirStore(t.TempDir()) |
| 274 | if _, err := AttachInstanceCatchall(ctx, s, "letsjelly.dev", "bad", "dev-managed-jelly", "t", "j"); !errors.Is(err, ErrParentNotHeld) { |
| 275 | t.Fatalf("unclaimed parent: err = %v, want ErrParentNotHeld", err) |
| 276 | } |
| 277 | putClaim(t, s, Claim{Host: "letsjelly.dev", Account: "zzz", App: "squat"}) |
| 278 | if _, err := AttachInstanceCatchall(ctx, s, "letsjelly.dev", "bad", "dev-managed-jelly", "t", "j"); !errors.Is(err, ErrParentNotHeld) { |
| 279 | t.Fatalf("parent held elsewhere: err = %v, want ErrParentNotHeld", err) |
| 280 | } |
| 281 | deleteClaim(t, s, "letsjelly.dev") |
| 282 | putClaim(t, s, Claim{Host: "letsjelly.dev", Account: "bad", App: "dev-jelly-site"}) |
| 283 | created, err := AttachInstanceCatchall(ctx, s, "letsjelly.dev", "bad", "dev-managed-jelly", "t", "j") |
| 284 | if err != nil || !created { |
| 285 | t.Fatalf("first attach: created=%v err=%v", created, err) |
| 286 | } |
| 287 | c, err := GetClaim(ctx, s, "*.letsjelly.dev") |
| 288 | if err != nil || !c.Instances || c.Account != "bad" || c.App != "dev-managed-jelly" || c.Route != "" || c.Wildcard || c.Catchall { |
| 289 | t.Fatalf("stored = %+v, %v", c, err) |
| 290 | } |
| 291 | created, err = AttachInstanceCatchall(ctx, s, "letsjelly.dev", "bad", "dev-managed-jelly", "t", "j") |
| 292 | if err != nil || created { |
| 293 | t.Fatalf("re-attach: created=%v err=%v, want idempotent", created, err) |
| 294 | } |
| 295 | // Another app of the same account does not take it over. |
| 296 | if _, err := AttachInstanceCatchall(ctx, s, "letsjelly.dev", "bad", "dev-jelly-site", "t", "j"); !errors.Is(err, ErrHostUnavailable) { |
| 297 | t.Fatalf("sibling app: err = %v, want ErrHostUnavailable", err) |
| 298 | } |
| 299 | // Capability and path writers refuse a catch-all even to its owner. |
| 300 | if _, err := SetClaimCapabilities(ctx, s, "*.letsjelly.dev", "bad", "dev-managed-jelly", true, false); !errors.Is(err, ErrHostUnavailable) { |
| 301 | t.Fatalf("capabilities on a catch-all: err = %v", err) |
| 302 | } |
| 303 | if _, err := SetClaimPaths(ctx, s, "*.letsjelly.dev", "bad", "dev-managed-jelly", map[string]string{"x": "a.b.c"}); !errors.Is(err, ErrHostUnavailable) { |
| 304 | t.Fatalf("paths on a catch-all: err = %v", err) |
| 305 | } |
| 306 | // The parent changes hands: the old catch-all is inert, and the new |
| 307 | // holder replaces it instead of meeting a squat. |
| 308 | deleteClaim(t, s, "letsjelly.dev") |
| 309 | putClaim(t, s, Claim{Host: "letsjelly.dev", Account: "zzz", App: "new"}) |
| 310 | if created, err := AttachInstanceCatchall(ctx, s, "letsjelly.dev", "zzz", "teams", "t", "j"); err != nil || !created { |
| 311 | t.Fatalf("new parent holder: created=%v err=%v", created, err) |
| 312 | } |
| 313 | if c, _ := GetClaim(ctx, s, "*.letsjelly.dev"); c.Account != "zzz" || c.App != "teams" || !c.Instances { |
| 314 | t.Fatalf("after replacement: %+v", c) |
| 315 | } |
| 316 | deleteClaim(t, s, "*.letsjelly.dev") |
| 317 | deleteClaim(t, s, "letsjelly.dev") |
| 318 | putClaim(t, s, Claim{Host: "letsjelly.dev", Account: "bad", App: "dev-jelly-site"}) |
| 319 | if _, err := AttachInstanceCatchall(ctx, s, "letsjelly.dev", "bad", "dev-managed-jelly", "t", "j"); err != nil { |
| 320 | t.Fatal(err) |
| 321 | } |
| 322 | // Detach is the ordinary owner-gated delete. |
| 323 | if removed, err := DetachClaim(ctx, s, "*.letsjelly.dev", "bad", "dev-jelly-site"); err != nil || removed { |
| 324 | t.Fatalf("sibling detach: removed=%v err=%v", removed, err) |
| 325 | } |
| 326 | if removed, err := DetachClaim(ctx, s, "*.letsjelly.dev", "bad", "dev-managed-jelly"); err != nil || !removed { |
| 327 | t.Fatalf("owner detach: removed=%v err=%v", removed, err) |
| 328 | } |
| 329 | } |
| 330 | |
| 331 | func TestForeignInstanceCatchall(t *testing.T) { |
| 332 | ctx := context.Background() |
| 333 | s := jellyStore(t) |
| 334 | for host, want := range map[string]map[string]bool{ |
| 335 | "acme.letsjelly.dev": {"bad": false, "zzz": true}, |
| 336 | "x.acme.letsjelly.dev": {"bad": false, "zzz": false}, // deeper: not the catch-all's |
| 337 | "letsjelly.dev": {"bad": false, "zzz": false}, // the parent itself |
| 338 | "acme.example.com": {"bad": false, "zzz": false}, |
| 339 | } { |
| 340 | for acct, w := range want { |
| 341 | c, err := ForeignInstanceCatchall(ctx, s, host, acct) |
| 342 | if got := c != nil; err != nil || got != w { |
| 343 | t.Errorf("ForeignInstanceCatchall(%s, %s) = %+v, %v; want %v", host, acct, c, err, w) |
| 344 | } |
| 345 | } |
| 346 | } |
| 347 | // Released parent: the catch-all serves nothing, so it holds nothing. |
| 348 | deleteClaim(t, s, "letsjelly.dev") |
| 349 | if c, err := ForeignInstanceCatchall(ctx, s, "acme.letsjelly.dev", "zzz"); err != nil || c != nil { |
| 350 | t.Errorf("after the parent went: %+v, %v; want nil", c, err) |
| 351 | } |
| 352 | } |
| 353 | |
| 354 | // What a binary that predates Instances makes of a catch-all claim: the |
| 355 | // shape it decodes (no Route, no Wildcard, no Paths) is one every pass |
| 356 | // already skips. Simulated by clearing the field this binary knows. |
| 357 | func TestInstanceCatchallIsInertWithoutTheField(t *testing.T) { |
| 358 | s := release.NewDirStore(t.TempDir()) |
| 359 | putClaim(t, s, Claim{Host: "letsjelly.dev", Account: "bad", App: "dev-jelly-site"}) |
| 360 | putClaim(t, s, Claim{Host: "*.letsjelly.dev", Account: "bad", App: "dev-managed-jelly"}) |
| 361 | reg := aliasRegWith(t) |
| 362 | addInstance(t, reg, "acme.bad.carlos.exciting.io", "acme", "bad", "dev-managed-jelly") |
| 363 | reconcile(t, s, reg, nil) |
| 364 | if got := aliasMap(t, reg); len(got) != 0 { |
| 365 | t.Fatalf("got %+v", got) |
| 366 | } |
| 367 | wds, err := reg.ListWildcardDomains() |
| 368 | if err != nil || len(wds) != 0 { |
| 369 | t.Fatalf("wildcard domains %+v, %v", wds, err) |
| 370 | } |
| 371 | } |
| 372 | |
| 373 | // The per-box cap holds, deterministically: the first names in host order |
| 374 | // are kept, and nothing past the cap is written. |
| 375 | func TestInstanceCatchallCap(t *testing.T) { |
| 376 | s := jellyStore(t) |
| 377 | reg := aliasRegWith(t) |
| 378 | for i := 0; i <= maxInstanceAliases; i++ { |
| 379 | label := "t" + strconv.Itoa(10000+i) |
| 380 | addInstance(t, reg, label+".bad.carlos.exciting.io", label, "bad", "dev-managed-jelly") |
| 381 | } |
| 382 | reconcile(t, s, reg, nil) |
| 383 | got := aliasMap(t, reg) |
| 384 | if len(got) != maxInstanceAliases { |
| 385 | t.Fatalf("got %d aliases, want the cap %d", len(got), maxInstanceAliases) |
| 386 | } |
| 387 | if _, ok := got["t"+strconv.Itoa(10000+maxInstanceAliases)+".letsjelly.dev"]; ok { |
| 388 | t.Fatal("the last name in host order was kept past the cap") |
| 389 | } |
| 390 | } |
| 391 | |
| 392 | // unreadableKeyStore fails every read of one key, as a spurious store error would, |
| 393 | // while the key still shows in listings. |
| 394 | type unreadableKeyStore struct { |
| 395 | *release.DirStore |
| 396 | failKey string |
| 397 | } |
| 398 | |
| 399 | func (f unreadableKeyStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 400 | if key == f.failKey { |
| 401 | return nil, "", errors.New("synthetic 403") |
| 402 | } |
| 403 | return f.DirStore.Get(ctx, key) |
| 404 | } |
| 405 | |
| 406 | // A claim whose body cannot be read this pass still beats the derived |
| 407 | // name: precedence is decided from the listing, never from what decoded. |
| 408 | func TestInstanceCatchallUnreadableClaimStillWins(t *testing.T) { |
| 409 | dir := release.NewDirStore(t.TempDir()) |
| 410 | putClaim(t, dir, Claim{Host: "letsjelly.dev", Account: "bad", App: "dev-jelly-site"}) |
| 411 | putClaim(t, dir, Claim{Host: "*.letsjelly.dev", Account: "bad", App: "dev-managed-jelly", Instances: true}) |
| 412 | putClaim(t, dir, Claim{Host: "acme.letsjelly.dev", Account: "zzz", App: "other", Route: "x.zzz.carlos.exciting.io"}) |
| 413 | s := unreadableKeyStore{dir, ClaimKey("acme.letsjelly.dev")} |
| 414 | reg := aliasRegWith(t) |
| 415 | addInstance(t, reg, "acme.bad.carlos.exciting.io", "acme", "bad", "dev-managed-jelly") |
| 416 | for _, cache := range []*ClaimCache{nil, {}} { |
| 417 | if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil, cache); err != nil { |
| 418 | t.Fatal(err) |
| 419 | } |
| 420 | if a, ok := aliasMap(t, reg)["acme.letsjelly.dev"]; ok { |
| 421 | t.Fatalf("derived over an unreadable claim (cache %v): %+v", cache != nil, a) |
| 422 | } |
| 423 | } |
| 424 | } |
| 425 | |
| 426 | // replacingStore hands back the claim at key as it stood, then lets another |
| 427 | // writer replace it before the caller acts — the old owner's detach racing |
| 428 | // the parent's new holder. |
| 429 | type replacingStore struct { |
| 430 | *release.DirStore |
| 431 | key string |
| 432 | next []byte |
| 433 | done bool |
| 434 | } |
| 435 | |
| 436 | func (r *replacingStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 437 | body, etag, err := r.DirStore.Get(ctx, key) |
| 438 | if key == r.key && !r.done && err == nil { |
| 439 | r.done = true |
| 440 | if _, perr := r.DirStore.Put(ctx, key, r.next, etag); perr != nil { |
| 441 | return nil, "", perr |
| 442 | } |
| 443 | } |
| 444 | return body, etag, err |
| 445 | } |
| 446 | |
| 447 | func TestDetachCatchallNeverDeletesAReplacement(t *testing.T) { |
| 448 | ctx := context.Background() |
| 449 | dir := release.NewDirStore(t.TempDir()) |
| 450 | putClaim(t, dir, Claim{Host: "*.letsjelly.dev", Account: "bad", App: "old", Instances: true}) |
| 451 | next, _ := json.Marshal(Claim{Schema: 1, Host: "*.letsjelly.dev", Account: "zzz", App: "new", Instances: true}) |
| 452 | s := &replacingStore{DirStore: dir, key: ClaimKey("*.letsjelly.dev"), next: next} |
| 453 | removed, err := DetachClaim(ctx, s, "*.letsjelly.dev", "bad", "old") |
| 454 | if err != nil || removed { |
| 455 | t.Fatalf("detach over a replacement: removed=%v err=%v, want false and no error", removed, err) |
| 456 | } |
| 457 | c, err := GetClaim(ctx, dir, "*.letsjelly.dev") |
| 458 | if err != nil || c.Account != "zzz" || c.App != "new" { |
| 459 | t.Fatalf("the replacement was not kept: %+v, %v", c, err) |
| 460 | } |
| 461 | } |
| 462 | |
| 463 | // putRecordKey drops an (empty) instance record at its key: the derivation's |
| 464 | // duplicate check reads record KEYS only. |
| 465 | func putRecordKey(t *testing.T, s release.Store, account, app, host string) { |
| 466 | t.Helper() |
| 467 | if _, err := s.Put(context.Background(), instanceRecordsPrefix(account, app)+host+".json", []byte(`{}`), ""); err != nil { |
| 468 | t.Fatal(err) |
| 469 | } |
| 470 | } |
| 471 | |
| 472 | // A label two instances share (after an instance-domain change) derives |
| 473 | // nothing, on every box alike — here, the box holding only one of them. |
| 474 | func TestInstanceCatchallRefusesASharedLabel(t *testing.T) { |
| 475 | s := jellyStore(t) |
| 476 | putRecordKey(t, s, "bad", "dev-managed-jelly", "acme.bad.carlos.exciting.io") |
| 477 | putRecordKey(t, s, "bad", "dev-managed-jelly", "acme.teams.example.net") |
| 478 | putRecordKey(t, s, "bad", "dev-managed-jelly", "beta.bad.carlos.exciting.io") |
| 479 | reg := aliasRegWith(t) |
| 480 | addInstance(t, reg, "acme.bad.carlos.exciting.io", "acme", "bad", "dev-managed-jelly") |
| 481 | addInstance(t, reg, "beta.bad.carlos.exciting.io", "beta", "bad", "dev-managed-jelly") |
| 482 | reconcile(t, s, reg, nil) |
| 483 | got := aliasMap(t, reg) |
| 484 | if _, ok := got["acme.letsjelly.dev"]; ok { |
| 485 | t.Fatalf("a shared label derived a name: %+v", got) |
| 486 | } |
| 487 | if got["beta.letsjelly.dev"].Host != "beta.bad.carlos.exciting.io" { |
| 488 | t.Fatalf("an unshared label lost its name: %+v", got) |
| 489 | } |
| 490 | } |
| 491 | |
| 492 | // listFailStore fails listings under one prefix, and nothing else. |
| 493 | type listFailStore struct { |
| 494 | *release.DirStore |
| 495 | prefix string |
| 496 | } |
| 497 | |
| 498 | func (f listFailStore) List(ctx context.Context, prefix string) ([]string, error) { |
| 499 | if strings.HasPrefix(prefix, f.prefix) { |
| 500 | return nil, errors.New("synthetic list failure") |
| 501 | } |
| 502 | return f.DirStore.List(ctx, prefix) |
| 503 | } |
| 504 | |
| 505 | // When the app's instances cannot be listed, the catch-all's names stay as |
| 506 | // they were for the pass: a blip must not take every team's name down. |
| 507 | func TestInstanceCatchallKeepsNamesWhenTheListingFails(t *testing.T) { |
| 508 | dir := jellyStore(t).(*release.DirStore) |
| 509 | reg := aliasRegWith(t) |
| 510 | addInstance(t, reg, "acme.bad.carlos.exciting.io", "acme", "bad", "dev-managed-jelly") |
| 511 | reconcile(t, dir, reg, nil) |
| 512 | if _, ok := aliasMap(t, reg)["acme.letsjelly.dev"]; !ok { |
| 513 | t.Fatal("precondition: the name derives") |
| 514 | } |
| 515 | failing := listFailStore{dir, instanceRecordsPrefix("bad", "dev-managed-jelly")} |
| 516 | if err := ReconcileAliases(context.Background(), failing, reg, aliasNow(), nil, nil); err != nil { |
| 517 | t.Fatal(err) |
| 518 | } |
| 519 | if a, ok := aliasMap(t, reg)["acme.letsjelly.dev"]; !ok || a.Host != "acme.bad.carlos.exciting.io" { |
| 520 | t.Fatalf("the name was dropped on a listing failure: %+v", aliasMap(t, reg)) |
| 521 | } |
| 522 | } |
| 523 | |
| 524 | func TestInstanceLabelCounts(t *testing.T) { |
| 525 | s := release.NewDirStore(t.TempDir()) |
| 526 | putRecordKey(t, s, "bad", "app", "acme.bad.carlos.exciting.io") |
| 527 | putRecordKey(t, s, "bad", "app", "acme.old.example.net") |
| 528 | putRecordKey(t, s, "bad", "app", "beta.bad.carlos.exciting.io") |
| 529 | putRecordKey(t, s, "bad", "other", "gamma.bad.carlos.exciting.io") |
| 530 | got, err := InstanceLabelCounts(context.Background(), s, "bad", "app") |
| 531 | if err != nil || got["acme"] != 2 || got["beta"] != 1 || got["gamma"] != 0 { |
| 532 | t.Fatalf("counts = %v, %v", got, err) |
| 533 | } |
| 534 | } |
| 535 | |