edges files publish single owners; the edge mirrors signed door keys (#175 P7)
Task 4 of the P7 plan. A box publishes account/app for a hostname only when every route behind it agrees (path-prefix targets included; an alias takes its primary's; the served catch-all must agree with its primary too). The fold carries the owner from the winning entry, and Owner shares Home's lookup so the check and the dial target cannot disagree. The door-key mirror verifies each record, drops absent, unverifiable, expired and misfiled records at once, keeps a record through a failed read for at most ten minutes (also enforced at read time), and keeps a newer revision over a lower one for the life of the process. Not yet wired into the run path. Built by an Opus subagent from the plan; reviewed before commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Human
Prompt
Go ahead. (start P7)
From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.
Summary
P7 Task 4: single-owner publication, the owner fold, and the door-key mirror.
6 files changed,
+904
−12
internal/edge/doorkeys.go+247 −0internal/edge/doorkeys_test.go+312 −0internal/edge/homemap.go+50 −6internal/edge/homemap_test.go+84 −0internal/host/edgesfile.go+95 −6internal/host/edgesfile_test.go+116 −0
CI passed — run details
diff --git a/internal/edge/doorkeys.go b/internal/edge/doorkeys.go| new file mode 100644 |
| index 000000000..8852b211d |
| --- /dev/null |
| +++ b/internal/edge/doorkeys.go |
| @@ -0,0 +1,247 @@ |
| +package edge |
| + |
| +// The door-key mirror (#175 P7): every edge's copy of the signed door-key |
| +// records under control/door-keys/, verified against the deployment's root |
| +// release key, so the edge-local door can check a key without a bucket read |
| +// on the request path. |
| +// |
| +// It runs its own loop rather than riding the home map's, because the P7 |
| +// plan's freshness contract is stated in its own numbers: |
| +// |
| +// - a record absent from a successful list is dropped at once, so a key |
| +// whose record is gone stops working within one interval (30 s); |
| +// - a failed list keeps the last map, but clears all of it ten minutes after |
| +// the last successful list, so a revocation is honoured within ten minutes |
| +// even when the bucket cannot be listed; |
| +// - a failed GET keeps that one record under the same ten minutes, measured |
| +// from its own last successful read; |
| +// - a record that fails verification (signature, shape, expiry), or whose |
| +// payload names another account or app than its path, is dropped at once: |
| +// it is not a blip but a wrong object, and carrying the old one forward |
| +// would let a bucket writer pin it; |
| +// - a revision below the one this process last admitted is kept out, and the |
| +// newer record kept while it is unexpired. In memory only: a restart |
| +// admits whatever validly signed record the bucket holds, until its own |
| +// expires_at (the plan's Security model states that residual). |
| +// |
| +// Record also applies the expiry and the ten-minute bound at read time, so |
| +// the contract holds between refreshes and if the loop stops. |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "os" |
| + "strings" |
| + "sync" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/doorkey" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// Vars so tests can shorten them. |
| +var ( |
| + // doorKeyInterval is the mirror's poll: a minted key is admitted, and a |
| + // revoked one refused, within one interval of the record landing. |
| + doorKeyInterval = 30 * time.Second |
| + // doorKeyTimeout is one refresh's budget. |
| + doorKeyTimeout = 10 * time.Second |
| +) |
| + |
| +// doorKeyStaleAfter is how long a record outlives the last time the bucket |
| +// vouched for it: a failed list clears the whole map this long after the last |
| +// successful one, and a failed GET drops its record this long after its last |
| +// successful read. |
| +const doorKeyStaleAfter = 10 * time.Minute |
| + |
| +// mirroredDoorKey is one admitted record and when the bucket last vouched |
| +// for it. |
| +type mirroredDoorKey struct { |
| + rec doorkey.Record |
| + readAt time.Time |
| +} |
| + |
| +type doorKeyMirror struct { |
| + s release.Store |
| + pub ed25519.PublicKey |
| + // now is the clock, injectable for the ten-minute bounds. |
| + now func() time.Time |
| + |
| + // refreshMu serializes Refresh, the home map's reason: two overlapping |
| + // polls could otherwise publish out of order. high and said are touched |
| + // only under it. |
| + refreshMu sync.Mutex |
| + // high is the highest revision this process has admitted per |
| + // "<account>/<app>". It survives the record being dropped. |
| + high map[string]uint64 |
| + // said is the last refusal logged per record key, so a standing refusal |
| + // is logged once rather than every poll. |
| + said map[string]string |
| + |
| + mu sync.RWMutex |
| + records map[string]mirroredDoorKey |
| + lastList time.Time |
| +} |
| + |
| +func newDoorKeyMirror(s release.Store, pub ed25519.PublicKey) *doorKeyMirror { |
| + return &doorKeyMirror{s: s, pub: pub, now: time.Now, |
| + high: map[string]uint64{}, said: map[string]string{}, records: map[string]mirroredDoorKey{}} |
| +} |
| + |
| +func doorKeyMapKey(account, app string) string { return account + "/" + app } |
| + |
| +// doorKeyPath splits a listed key into the account and app its path names. |
| +// Anything that is not exactly doorkey.RecordKey(account, app) is not a |
| +// record this deployment's console wrote. |
| +func doorKeyPath(key string) (account, app string, ok bool) { |
| + rest, ok := strings.CutPrefix(key, doorkey.Prefix) |
| + if !ok { |
| + return "", "", false |
| + } |
| + rest, ok = strings.CutSuffix(rest, ".json") |
| + if !ok { |
| + return "", "", false |
| + } |
| + account, app, ok = strings.Cut(rest, "/") |
| + if !ok || account == "" || app == "" || strings.Contains(app, "/") { |
| + return "", "", false |
| + } |
| + return account, app, true |
| +} |
| + |
| +// Refresh rebuilds the map from one list plus a GET per record. A failed list |
| +// or GET is returned; the map it leaves follows the rules in the file header. |
| +func (d *doorKeyMirror) Refresh(ctx context.Context) error { |
| + d.refreshMu.Lock() |
| + defer d.refreshMu.Unlock() |
| + now := d.now() |
| + |
| + keys, err := d.s.List(ctx, doorkey.Prefix) |
| + if err != nil { |
| + d.mu.Lock() |
| + if len(d.records) > 0 && now.Sub(d.lastList) >= doorKeyStaleAfter { |
| + log.Printf("edge: door-key mirror cleared. No successful list of %s since %s", doorkey.Prefix, d.lastList.Format(time.RFC3339)) |
| + d.records = map[string]mirroredDoorKey{} |
| + } |
| + d.mu.Unlock() |
| + return fmt.Errorf("list %s: %w", doorkey.Prefix, err) |
| + } |
| + |
| + prev := d.snapshot() |
| + next := make(map[string]mirroredDoorKey, len(keys)) |
| + var getErrs []error |
| + for _, key := range keys { |
| + account, app, ok := doorKeyPath(key) |
| + if !ok { |
| + continue |
| + } |
| + mk := doorKeyMapKey(account, app) |
| + wire, _, err := d.s.Get(ctx, key) |
| + if errors.Is(err, os.ErrNotExist) { |
| + // Deleted between the list and the GET: absent. |
| + continue |
| + } |
| + if err != nil { |
| + getErrs = append(getErrs, fmt.Errorf("get %s: %w", key, err)) |
| + if old, ok := prev[mk]; ok && now.Sub(old.readAt) < doorKeyStaleAfter { |
| + next[mk] = old |
| + } |
| + continue |
| + } |
| + rec, err := doorkey.Verify(d.pub, wire, now) |
| + if err == nil && (rec.Account != account || rec.App != app) { |
| + err = fmt.Errorf("the record names %s/%s", rec.Account, rec.App) |
| + } |
| + if err != nil { |
| + d.sayOnce(key, fmt.Sprintf("edge: door-key record %s refused and dropped: %v", key, err)) |
| + continue |
| + } |
| + if rec.Revision < d.high[mk] { |
| + d.sayOnce(key, fmt.Sprintf("edge: door-key record %s is revision %d, below revision %d already admitted. Keeping the newer one", key, rec.Revision, d.high[mk])) |
| + // The bucket answered and holds nothing newer than what this |
| + // process has, so the newer record is vouched for as of now, |
| + // while it is unexpired. |
| + if old, ok := prev[mk]; ok && now.Before(old.rec.ExpiresAt) { |
| + next[mk] = mirroredDoorKey{rec: old.rec, readAt: now} |
| + } |
| + continue |
| + } |
| + delete(d.said, key) |
| + d.high[mk] = rec.Revision |
| + next[mk] = mirroredDoorKey{rec: rec, readAt: now} |
| + } |
| + |
| + d.mu.Lock() |
| + d.records = next |
| + d.lastList = now |
| + d.mu.Unlock() |
| + return errors.Join(getErrs...) |
| +} |
| + |
| +// sayOnce logs line unless it is the line last logged for key. |
| +func (d *doorKeyMirror) sayOnce(key, line string) { |
| + if d.said[key] == line { |
| + return |
| + } |
| + d.said[key] = line |
| + log.Print(line) |
| +} |
| + |
| +func (d *doorKeyMirror) snapshot() map[string]mirroredDoorKey { |
| + d.mu.RLock() |
| + defer d.mu.RUnlock() |
| + return d.records |
| +} |
| + |
| +// Record is the admitted record for account/app. It refuses a record whose |
| +// expiry has passed, or that the bucket has not vouched for in the last ten |
| +// minutes, at read time. Nil-safe: a box with no mirror admits nothing. |
| +func (d *doorKeyMirror) Record(account, app string) (doorkey.Record, bool) { |
| + if d == nil { |
| + return doorkey.Record{}, false |
| + } |
| + d.mu.RLock() |
| + e, ok := d.records[doorKeyMapKey(account, app)] |
| + d.mu.RUnlock() |
| + if !ok { |
| + return doorkey.Record{}, false |
| + } |
| + now := d.now() |
| + if !now.Before(e.rec.ExpiresAt) || now.Sub(e.readAt) >= doorKeyStaleAfter { |
| + return doorkey.Record{}, false |
| + } |
| + return e.rec, true |
| +} |
| + |
| +// run polls until stop closes: the edge's standing loop shape (homeMap.run), |
| +// with a 10 s budget per refresh. |
| +func (d *doorKeyMirror) run(stop <-chan struct{}) { |
| + log.Printf("edge: door-key mirror armed (%s poll of %s*)", doorKeyInterval, doorkey.Prefix) |
| + tick := time.NewTicker(doorKeyInterval) |
| + defer tick.Stop() |
| + errs := 0 |
| + for { |
| + select { |
| + case <-stop: |
| + return |
| + case <-tick.C: |
| + } |
| + ctx, cancel := context.WithTimeout(context.Background(), doorKeyTimeout) |
| + err := d.Refresh(ctx) |
| + cancel() |
| + if err != nil { |
| + errs++ |
| + if errs == 1 || errs%10 == 0 { |
| + log.Printf("edge: door-key mirror refresh failed (#%d): %v. Records stay admitted for at most %s after the bucket last vouched for them", errs, err, doorKeyStaleAfter) |
| + } |
| + continue |
| + } |
| + if errs > 0 { |
| + log.Printf("edge: door-key mirror refresh recovered after %d failure(s)", errs) |
| + errs = 0 |
| + } |
| + } |
| +} |
diff --git a/internal/edge/doorkeys_test.go b/internal/edge/doorkeys_test.go| new file mode 100644 |
| index 000000000..53abd1452 |
| --- /dev/null |
| +++ b/internal/edge/doorkeys_test.go |
| @@ -0,0 +1,312 @@ |
| +package edge |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/rand" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/doorkey" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// testClock is the mirror's injectable clock. |
| +type testClock struct { |
| + mu sync.Mutex |
| + t time.Time |
| +} |
| + |
| +func (c *testClock) now() time.Time { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + return c.t |
| +} |
| + |
| +func (c *testClock) advance(d time.Duration) { |
| + c.mu.Lock() |
| + c.t = c.t.Add(d) |
| + c.mu.Unlock() |
| +} |
| + |
| +type doorKeyFixture struct { |
| + pub ed25519.PublicKey |
| + priv ed25519.PrivateKey |
| + clk *testClock |
| +} |
| + |
| +func newDoorKeyFixture(t *testing.T) *doorKeyFixture { |
| + t.Helper() |
| + pub, priv, err := ed25519.GenerateKey(rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return &doorKeyFixture{pub: pub, priv: priv, clk: &testClock{t: time.Now().UTC()}} |
| +} |
| + |
| +func (fx *doorKeyFixture) mirror(s release.Store) *doorKeyMirror { |
| + d := newDoorKeyMirror(s, fx.pub) |
| + d.now = fx.clk.now |
| + return d |
| +} |
| + |
| +// signed is a record for account/app at revision rev, signed now on the |
| +// fixture's clock and good for the whole lifetime. |
| +func (fx *doorKeyFixture) signed(t *testing.T, priv ed25519.PrivateKey, account, app string, rev uint64) []byte { |
| + t.Helper() |
| + now := fx.clk.now() |
| + return fx.signedAt(t, priv, account, app, rev, now, now.Add(doorkey.Lifetime)) |
| +} |
| + |
| +func (fx *doorKeyFixture) signedAt(t *testing.T, priv ed25519.PrivateKey, account, app string, rev uint64, signedAt, expiresAt time.Time) []byte { |
| + t.Helper() |
| + _, entry, err := doorkey.NewToken(account, app) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + wire, err := doorkey.Sign(priv, doorkey.Record{V: 1, Account: account, App: app, Revision: rev, |
| + Keys: []doorkey.Entry{entry}, SignedAt: signedAt, ExpiresAt: expiresAt}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return wire |
| +} |
| + |
| +func refreshDoorKeys(t *testing.T, d *doorKeyMirror) { |
| + t.Helper() |
| + if err := d.Refresh(context.Background()); err != nil { |
| + t.Fatalf("refresh: %v", err) |
| + } |
| +} |
| + |
| +func wantDoorRecord(t *testing.T, d *doorKeyMirror, account, app string, want bool) doorkey.Record { |
| + t.Helper() |
| + rec, ok := d.Record(account, app) |
| + if ok != want { |
| + t.Fatalf("Record(%s, %s) ok = %v, want %v", account, app, ok, want) |
| + } |
| + return rec |
| +} |
| + |
| +// A record listed under its own path, validly signed and unexpired, is |
| +// admitted; one that leaves the list is dropped on the next refresh. |
| +func TestDoorKeyMirrorDropsARecordAbsentFromTheList(t *testing.T) { |
| + fx := newDoorKeyFixture(t) |
| + s := meshTestStore(t) |
| + putJSON(t, s, doorkey.RecordKey("bae", "titogo"), string(fx.signed(t, fx.priv, "bae", "titogo", 1))) |
| + putJSON(t, s, doorkey.RecordKey("bae", "classic"), string(fx.signed(t, fx.priv, "bae", "classic", 1))) |
| + // Not a record path: ignored, not an error. |
| + putJSON(t, s, doorkey.Prefix+"stray.json", "{}") |
| + d := fx.mirror(s) |
| + refreshDoorKeys(t, d) |
| + if rec := wantDoorRecord(t, d, "bae", "titogo", true); rec.Revision != 1 || rec.App != "titogo" { |
| + t.Fatalf("record = %+v", rec) |
| + } |
| + wantDoorRecord(t, d, "bae", "classic", true) |
| + |
| + if err := s.Delete(context.Background(), doorkey.RecordKey("bae", "titogo")); err != nil { |
| + t.Fatal(err) |
| + } |
| + refreshDoorKeys(t, d) |
| + wantDoorRecord(t, d, "bae", "titogo", false) |
| + wantDoorRecord(t, d, "bae", "classic", true) |
| +} |
| + |
| +// A failed list keeps the last map, but only for ten minutes after the last |
| +// successful list: then the whole map is cleared. |
| +func TestDoorKeyMirrorClearsTenMinutesAfterTheLastSuccessfulList(t *testing.T) { |
| + fx := newDoorKeyFixture(t) |
| + s := &listFailingStore{Store: meshTestStore(t)} |
| + putJSON(t, s.Store, doorkey.RecordKey("bae", "titogo"), string(fx.signed(t, fx.priv, "bae", "titogo", 1))) |
| + d := fx.mirror(s) |
| + refreshDoorKeys(t, d) |
| + |
| + s.setFail(true) |
| + fx.clk.advance(9 * time.Minute) |
| + if err := d.Refresh(context.Background()); err == nil { |
| + t.Fatal("a failed list must be reported") |
| + } |
| + wantDoorRecord(t, d, "bae", "titogo", true) |
| + |
| + fx.clk.advance(time.Minute) |
| + if err := d.Refresh(context.Background()); err == nil { |
| + t.Fatal("a failed list must be reported") |
| + } |
| + wantDoorRecord(t, d, "bae", "titogo", false) |
| + |
| + // The bucket comes back: the record is admitted again. |
| + s.setFail(false) |
| + refreshDoorKeys(t, d) |
| + wantDoorRecord(t, d, "bae", "titogo", true) |
| +} |
| + |
| +// The bound holds at read time too: a mirror whose loop has stopped |
| +// refreshing does not keep admitting a record for longer than ten minutes. |
| +func TestDoorKeyMirrorRecordRefusesPastTheBoundWithoutARefresh(t *testing.T) { |
| + fx := newDoorKeyFixture(t) |
| + s := meshTestStore(t) |
| + putJSON(t, s, doorkey.RecordKey("bae", "titogo"), string(fx.signed(t, fx.priv, "bae", "titogo", 1))) |
| + d := fx.mirror(s) |
| + refreshDoorKeys(t, d) |
| + fx.clk.advance(10 * time.Minute) |
| + wantDoorRecord(t, d, "bae", "titogo", false) |
| +} |
| + |
| +// One record's GET failing keeps that record under the same ten-minute bound, |
| +// measured from its own last successful read. Its neighbours are unaffected. |
| +func TestDoorKeyMirrorKeepsARecordThroughAGetFailureForTenMinutes(t *testing.T) { |
| + fx := newDoorKeyFixture(t) |
| + s := &oneKeyFailingStore{Store: meshTestStore(t)} |
| + putJSON(t, s.Store, doorkey.RecordKey("bae", "titogo"), string(fx.signed(t, fx.priv, "bae", "titogo", 1))) |
| + putJSON(t, s.Store, doorkey.RecordKey("bae", "classic"), string(fx.signed(t, fx.priv, "bae", "classic", 1))) |
| + d := fx.mirror(s) |
| + refreshDoorKeys(t, d) |
| + |
| + s.failKey(doorkey.RecordKey("bae", "titogo")) |
| + fx.clk.advance(5 * time.Minute) |
| + if err := d.Refresh(context.Background()); err == nil { |
| + t.Fatal("a failed GET must be reported") |
| + } |
| + wantDoorRecord(t, d, "bae", "titogo", true) |
| + wantDoorRecord(t, d, "bae", "classic", true) |
| + |
| + fx.clk.advance(5 * time.Minute) |
| + if err := d.Refresh(context.Background()); err == nil { |
| + t.Fatal("a failed GET must be reported") |
| + } |
| + wantDoorRecord(t, d, "bae", "titogo", false) |
| + wantDoorRecord(t, d, "bae", "classic", true) |
| +} |
| + |
| +// A record that fails verification (signature, shape, expiry) or whose |
| +// payload names another account or app than its path is dropped at once, |
| +// not carried forward like an unreadable one. |
| +func TestDoorKeyMirrorDropsAnUnverifiableRecordAtOnce(t *testing.T) { |
| + fx := newDoorKeyFixture(t) |
| + _, otherPriv, _ := ed25519.GenerateKey(rand.Reader) |
| + now := fx.clk.now() |
| + for _, c := range []struct { |
| + name string |
| + wire func() []byte |
| + }{ |
| + {"signed by another key", func() []byte { return fx.signed(t, otherPriv, "bae", "titogo", 2) }}, |
| + {"not a signed record", func() []byte { return []byte(`{"payload":"e30=","sig":"AAAA"}`) }}, |
| + {"expired", func() []byte { |
| + return fx.signedAt(t, fx.priv, "bae", "titogo", 2, now.Add(-2*time.Hour), now.Add(-time.Hour)) |
| + }}, |
| + {"another app's record", func() []byte { return fx.signed(t, fx.priv, "bae", "other", 2) }}, |
| + {"another account's record", func() []byte { return fx.signed(t, fx.priv, "zed", "titogo", 2) }}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + captureLog(t) |
| + s := meshTestStore(t) |
| + key := doorkey.RecordKey("bae", "titogo") |
| + putJSON(t, s, key, string(fx.signed(t, fx.priv, "bae", "titogo", 1))) |
| + d := fx.mirror(s) |
| + refreshDoorKeys(t, d) |
| + wantDoorRecord(t, d, "bae", "titogo", true) |
| + putJSON(t, s, key, string(c.wire())) |
| + refreshDoorKeys(t, d) |
| + wantDoorRecord(t, d, "bae", "titogo", false) |
| + if c.name == "another app's record" { |
| + wantDoorRecord(t, d, "bae", "other", false) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Record refuses a record whose expiry has passed at read time, between |
| +// refreshes. |
| +func TestDoorKeyMirrorRecordRefusesAnExpiredRecordAtReadTime(t *testing.T) { |
| + fx := newDoorKeyFixture(t) |
| + s := meshTestStore(t) |
| + now := fx.clk.now() |
| + putJSON(t, s, doorkey.RecordKey("bae", "titogo"), |
| + string(fx.signedAt(t, fx.priv, "bae", "titogo", 1, now, now.Add(time.Minute)))) |
| + d := fx.mirror(s) |
| + refreshDoorKeys(t, d) |
| + wantDoorRecord(t, d, "bae", "titogo", true) |
| + fx.clk.advance(time.Minute) |
| + wantDoorRecord(t, d, "bae", "titogo", false) |
| +} |
| + |
| +// A lower revision than this process last admitted is kept out: the newer |
| +// record stays while it is unexpired, the lower one is logged once, and it is |
| +// never admitted for the life of the process, even after the newer expires. |
| +func TestDoorKeyMirrorKeepsTheNewerRevisionAndLogsTheLowerOnce(t *testing.T) { |
| + buf := captureLog(t) |
| + fx := newDoorKeyFixture(t) |
| + s := meshTestStore(t) |
| + key := doorkey.RecordKey("bae", "titogo") |
| + now := fx.clk.now() |
| + putJSON(t, s, key, string(fx.signedAt(t, fx.priv, "bae", "titogo", 5, now, now.Add(time.Hour)))) |
| + d := fx.mirror(s) |
| + refreshDoorKeys(t, d) |
| + |
| + putJSON(t, s, key, string(fx.signed(t, fx.priv, "bae", "titogo", 4))) |
| + for i := 0; i < 3; i++ { |
| + refreshDoorKeys(t, d) |
| + fx.clk.advance(30 * time.Second) |
| + } |
| + if rec := wantDoorRecord(t, d, "bae", "titogo", true); rec.Revision != 5 { |
| + t.Fatalf("revision %d admitted, want the newer 5 kept", rec.Revision) |
| + } |
| + if n := strings.Count(buf.String(), "revision 4"); n != 1 { |
| + t.Fatalf("the lower revision was logged %d times, want once:\n%s", n, buf.String()) |
| + } |
| + |
| + // The kept record expires; the lower one is still refused. |
| + fx.clk.advance(time.Hour) |
| + refreshDoorKeys(t, d) |
| + wantDoorRecord(t, d, "bae", "titogo", false) |
| + |
| + // An equal revision (a renewal) and a higher one are admitted. |
| + putJSON(t, s, key, string(fx.signed(t, fx.priv, "bae", "titogo", 5))) |
| + refreshDoorKeys(t, d) |
| + wantDoorRecord(t, d, "bae", "titogo", true) |
| + putJSON(t, s, key, string(fx.signed(t, fx.priv, "bae", "titogo", 6))) |
| + refreshDoorKeys(t, d) |
| + if rec := wantDoorRecord(t, d, "bae", "titogo", true); rec.Revision != 6 { |
| + t.Fatalf("revision %d, want 6", rec.Revision) |
| + } |
| +} |
| + |
| +func TestDoorKeyMirrorNilIsInert(t *testing.T) { |
| + var d *doorKeyMirror |
| + if _, ok := d.Record("bae", "titogo"); ok { |
| + t.Fatal("a nil mirror admitted a record") |
| + } |
| +} |
| + |
| +// run polls on its own loop until stop closes. |
| +func TestDoorKeyMirrorRunPollsUntilStopped(t *testing.T) { |
| + captureLog(t) |
| + old := doorKeyInterval |
| + doorKeyInterval = 10 * time.Millisecond |
| + t.Cleanup(func() { doorKeyInterval = old }) |
| + fx := newDoorKeyFixture(t) |
| + s := meshTestStore(t) |
| + d := fx.mirror(s) |
| + stop := make(chan struct{}) |
| + done := make(chan struct{}) |
| + go func() { d.run(stop); close(done) }() |
| + putJSON(t, s, doorkey.RecordKey("bae", "titogo"), string(fx.signed(t, fx.priv, "bae", "titogo", 1))) |
| + deadline := time.Now().Add(5 * time.Second) |
| + for { |
| + if _, ok := d.Record("bae", "titogo"); ok { |
| + break |
| + } |
| + if time.Now().After(deadline) { |
| + t.Fatal("run never admitted the record") |
| + } |
| + time.Sleep(5 * time.Millisecond) |
| + } |
| + close(stop) |
| + select { |
| + case <-done: |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("run did not return after stop") |
| + } |
| +} |
diff --git a/internal/edge/homemap.go b/internal/edge/homemap.go| index 482d6951d..6912e1b71 100644 |
| --- a/internal/edge/homemap.go |
| +++ b/internal/edge/homemap.go |
| @@ -149,6 +149,12 @@ type edgesHostView struct { |
| // Wildcard marks a delegated customer wildcard parent's catch-all entry |
| // (#305): WildcardParents feeds it to the wildcard manager. |
| Wildcard bool `json:"wildcard,omitempty"` |
| + // Account and App are the one app that owns every route the publishing |
| + // box serves under this name (#175 P7). The publisher leaves both out |
| + // when the name is split between apps; so does a publisher that predates |
| + // them. The edge-local door refuses a remote host with no owner. |
| + Account string `json:"account,omitempty"` |
| + App string `json:"app,omitempty"` |
| } |
| // homeEntry is one folded answer: where to dial, and which box that is. |
| @@ -164,6 +170,9 @@ type homeEntry struct { |
| // dial reports whether the entry is a backhaul target (role home). A |
| // cert-only wildcard entry folds with dial false and Home skips it. |
| dial bool |
| + // account and app are the owner the dial target's box published. They |
| + // travel with eip and box and are never merged across entries. |
| + account, app string |
| } |
| // homeSnapshot is one poll's result, swapped in whole and immutable once |
| @@ -563,7 +572,8 @@ func foldHomes(files map[string]edgesFileView, self string) map[string]homeEntry |
| default: |
| continue |
| } |
| - in := homeEntry{eip: f.EIP, box: f.Box, platform: h.Platform && !h.Alias && !h.Wildcard, alias: h.Alias, wildcard: h.Wildcard, dial: h.Role == edgeRoleHome} |
| + in := homeEntry{eip: f.EIP, box: f.Box, platform: h.Platform && !h.Alias && !h.Wildcard, alias: h.Alias, wildcard: h.Wildcard, dial: h.Role == edgeRoleHome, |
| + account: h.Account, app: h.App} |
| prev, dup := hosts[host] |
| if !dup { |
| hosts[host] = in |
| @@ -574,12 +584,16 @@ func foldHomes(files map[string]edgesFileView, self string) map[string]homeEntry |
| // must load everywhere even if some other box also routes the |
| // key), the dial target is a choice. First box in sorted order |
| // wins the dial — except that a ROUTE beats an ALIAS whatever |
| - // the order (#287), and any dial target beats none. |
| + // the order (#287), and any dial target beats none. The owner |
| + // is part of the dial choice, not a class: it comes whole from |
| + // the winner, so a route that replaces an alias never inherits |
| + // the alias's owner, even when it publishes none of its own. |
| merged := prev |
| merged.platform = prev.platform || in.platform |
| merged.wildcard = prev.wildcard || in.wildcard |
| if in.dial && (!prev.dial || (prev.alias && !in.alias)) { |
| merged.eip, merged.box, merged.alias, merged.dial = in.eip, in.box, in.alias, true |
| + merged.account, merged.app = in.account, in.app |
| } |
| hosts[host] = merged |
| } |
| @@ -609,19 +623,49 @@ func (m *homeMap) snapshot() *homeSnapshot { |
| // box it is, this edge cannot exclude its own hosts, and an edge that cannot |
| // exclude itself must not be handing out dial targets at all. |
| func (m *homeMap) Home(host string) (eip, box string, ok bool) { |
| - if m == nil || m.self == "" { |
| + h, ok := m.lookupHome(host) |
| + if !ok { |
| return "", "", false |
| } |
| + return h.eip, h.box, true |
| +} |
| + |
| +// lookupHome is Home's lookup, answering the whole winning entry from one |
| +// snapshot read. Home and Owner both go through it, so they can never pick |
| +// different entries for the same host. |
| +func (m *homeMap) lookupHome(host string) (homeEntry, bool) { |
| + if m == nil || m.self == "" { |
| + return homeEntry{}, false |
| + } |
| hosts := m.snapshot().hosts |
| if h, ok := hosts[host]; ok && h.dial { |
| - return h.eip, h.box, true |
| + return h, true |
| } |
| if i := strings.IndexByte(host, '.'); i > 0 { |
| if h, ok := hosts["*"+host[i:]]; ok && h.dial { |
| - return h.eip, h.box, true |
| + return h, true |
| } |
| } |
| - return "", "", false |
| + return homeEntry{}, false |
| +} |
| + |
| +// Owner answers, from one read of one snapshot, both whom the edge-local |
| +// door must check and where it would dial (#175 P7), so the check and the |
| +// dial cannot disagree. The lookup is Home's, one-label wildcard included, |
| +// and eip and box are exactly what Home returns for the same host. |
| +// |
| +// ok is false, and everything else zero, when the winning entry is not a |
| +// dial target or published no owner: a hostname split between apps, or a |
| +// publisher that predates the field. An ownerless exact entry does not fall |
| +// through to a wildcard that names one, because the exact entry is the one |
| +// Home dials. A nil map, or one with no self label, answers nothing, as Home |
| +// does. |
| +func (m *homeMap) Owner(host string) (account, app, eip, box string, ok bool) { |
| + h, found := m.lookupHome(host) |
| + if !found || h.account == "" || h.app == "" { |
| + return "", "", "", "", false |
| + } |
| + return h.account, h.app, h.eip, h.box, true |
| } |
| // KnownHost reports whether host is an EXACT key in the mirrored home map — a |
diff --git a/internal/edge/homemap_test.go b/internal/edge/homemap_test.go| index ce605928c..fc3594a2d 100644 |
| --- a/internal/edge/homemap_test.go |
| +++ b/internal/edge/homemap_test.go |
| @@ -1549,3 +1549,87 @@ func (o *oneKeyFailingStore) Get(ctx context.Context, key string) ([]byte, strin |
| } |
| return o.Store.Get(ctx, key) |
| } |
| + |
| +// ---- owners (#175 P7, the edge-local door) --------------------------------- |
| + |
| +// Owner answers through the same lookup as Home, exact and one-label |
| +// wildcard, and its dial target is Home's for the same host. |
| +func TestOwnerFoldsThroughExactAndWildcardLookup(t *testing.T) { |
| + s := meshTestStore(t) |
| + putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{`+ |
| + `"acme.go.tito.io":{"role":"home","account":"bae","app":"titogo"},`+ |
| + `"*.classic.tito.io":{"role":"home","account":"bae","app":"classic"}}}`) |
| + m := newHomeMap(s, "annie") |
| + refreshHomeMap(t, m) |
| + for _, c := range []struct{ host, app string }{ |
| + {"acme.go.tito.io", "titogo"}, |
| + {"ti.classic.tito.io", "classic"}, |
| + } { |
| + acct, app, eip, box, ok := m.Owner(c.host) |
| + if !ok || acct != "bae" || app != c.app { |
| + t.Fatalf("Owner(%s) = %s/%s %v, want bae/%s", c.host, acct, app, ok, c.app) |
| + } |
| + heip, hbox, hok := m.Home(c.host) |
| + if !hok || eip != heip || box != hbox { |
| + t.Fatalf("Owner(%s) dials %s/%s, Home dials %s/%s", c.host, eip, box, heip, hbox) |
| + } |
| + } |
| + if _, _, _, _, ok := m.Owner("a.b.classic.tito.io"); ok { |
| + t.Fatal("a two-label host must miss the one-label wildcard, as Home does") |
| + } |
| +} |
| + |
| +// No published owner (a mixed hostname, or a publisher that predates the |
| +// field), a cert-only entry, an own-box entry, or a nil map: ok is false and |
| +// nothing else is answered. |
| +func TestOwnerIsFalseWithoutAnOwnerOrADialTarget(t *testing.T) { |
| + s := meshTestStore(t) |
| + putJSON(t, s, "control/edges/annie.json", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{"own.test":{"role":"home","account":"bae","app":"titogo"}}}`) |
| + putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{`+ |
| + `"mixed.test":{"role":"home"},`+ |
| + `"halfowned.test":{"role":"home","account":"bae"},`+ |
| + `"*.certonly.test":{"role":"wildcard","wildcard":true,"account":"bae","app":"titogo"}}}`) |
| + m := newHomeMap(s, "annie") |
| + refreshHomeMap(t, m) |
| + for _, host := range []string{"mixed.test", "halfowned.test", "x.certonly.test", "own.test", "nowhere.test"} { |
| + if acct, app, eip, box, ok := m.Owner(host); ok || acct != "" || app != "" || eip != "" || box != "" { |
| + t.Errorf("Owner(%s) = %q/%q %q/%q %v, want zeros and false", host, acct, app, eip, box, ok) |
| + } |
| + } |
| + // An exact entry with no owner is the winner: the lookup does not fall |
| + // through to a wildcard that would have named one. |
| + putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{`+ |
| + `"exact.wild.test":{"role":"home"},"*.wild.test":{"role":"home","account":"bae","app":"titogo"}}}`) |
| + refreshHomeMap(t, m) |
| + if _, _, _, _, ok := m.Owner("exact.wild.test"); ok { |
| + t.Error("an ownerless exact entry borrowed the wildcard's owner") |
| + } |
| + var nilMap *homeMap |
| + if acct, app, eip, box, ok := nilMap.Owner("acme.go.tito.io"); ok || acct != "" || app != "" || eip != "" || box != "" { |
| + t.Error("a nil map answered an owner") |
| + } |
| +} |
| + |
| +// A route replacing an alias on a name collision carries the ROUTE's owner, |
| +// whatever box sorted first. The owner is never merged across entries. |
| +func TestOwnerOfARouteReplacingAnAliasIsTheRoutes(t *testing.T) { |
| + s := meshTestStore(t) |
| + putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"shared.test":{"role":"home","alias":true,"account":"bae","app":"squatter"},`+ |
| + `"ownerless.test":{"role":"home","alias":true,"account":"bae","app":"titogo"}}}`) |
| + putJSON(t, s, "control/edges/carol.json", `{"v":1,"box":"carol","eip":"3.3.3.3","hosts":{"shared.test":{"role":"home","account":"bae","app":"titogo"},`+ |
| + `"ownerless.test":{"role":"home"}}}`) |
| + m := newHomeMap(s, "annie") |
| + refreshHomeMap(t, m) |
| + acct, app, eip, box, ok := m.Owner("shared.test") |
| + if !ok || acct != "bae" || app != "titogo" || eip != "3.3.3.3" || box != "carol" { |
| + t.Fatalf("Owner(shared.test) = %s/%s %s/%s %v, want the route's bae/titogo on carol", acct, app, eip, box, ok) |
| + } |
| + // The ownerless route on carol replaces betty's owned alias: its lack of |
| + // an owner is not filled in from the alias it beat. |
| + if _, _, _, box, ok := m.Owner("ownerless.test"); ok { |
| + t.Fatalf("Owner(ownerless.test) = %s, true; the losing alias's owner leaked into the winner", box) |
| + } |
| + if _, box, _ := m.Home("ownerless.test"); box != "carol" { |
| + t.Fatalf("Home(ownerless.test) = %s, want carol", box) |
| + } |
| +} |
diff --git a/internal/host/edgesfile.go b/internal/host/edgesfile.go| index 40a04a93d..323fd1c89 100644 |
| --- a/internal/host/edgesfile.go |
| +++ b/internal/host/edgesfile.go |
| @@ -70,6 +70,71 @@ type edgeHost struct { |
| // catch-all (dial target); with role "wildcard" it is the cert half |
| // alone. Additive (omitempty); never platform. |
| Wildcard bool `json:"wildcard,omitempty"` |
| + // Account and App name the one app that owns everything this box serves |
| + // under the hostname (#175 P7). The edge-local door on another box checks |
| + // a door key against them before it backhauls here. Published only when |
| + // every route behind the name agrees: the row itself, every path-prefix |
| + // row sharing it, and for an alias or a served catch-all, its primary's. |
| + // A hostname split between apps publishes neither, and the door refuses |
| + // it remotely. Additive (omitempty). |
| + Account string `json:"account,omitempty"` |
| + App string `json:"app,omitempty"` |
| +} |
| + |
| +// hostOwner folds the owners of every route served under one hostname. Any |
| +// disagreement, or any route with no account or app, leaves no owner. |
| +type hostOwner struct { |
| + account, app string |
| + set, mixed bool |
| +} |
| + |
| +func (o *hostOwner) add(account, app string) { |
| + switch { |
| + case account == "" || app == "": |
| + o.mixed = true |
| + case !o.set: |
| + o.account, o.app, o.set = account, app, true |
| + case o.account != account || o.app != app: |
| + o.mixed = true |
| + } |
| +} |
| + |
| +func (o hostOwner) owner() (account, app string) { |
| + if !o.set || o.mixed { |
| + return "", "" |
| + } |
| + return o.account, o.app |
| +} |
| + |
| +// servedOwners answers, for each served row's hostname, the owner the edge's |
| +// own dispatch would reach under it: the row's route, and the route each |
| +// path-prefix row on it targets. A target that names a route in the registry |
| +// counts whatever that route's status. When it is not ready the edge falls |
| +// back to the host's own route today, but it counts anyway, so the owner never |
| +// outlives the target turning ready. A target naming no route here can only |
| +// fall back to the host's own route, which is already counted. |
| +func servedOwners(routes []registry.Route, served map[string]edgeHost, paths []registry.RoutePath) map[string]*hostOwner { |
| + byHost := make(map[string]registry.Route, len(routes)) |
| + for _, r := range routes { |
| + byHost[r.Host] = r |
| + } |
| + owners := make(map[string]*hostOwner, len(served)) |
| + for host := range served { |
| + r := byHost[host] |
| + o := &hostOwner{} |
| + o.add(release.RouteAccount(r), r.App) |
| + owners[host] = o |
| + } |
| + for _, p := range paths { |
| + o, ok := owners[p.Host] |
| + if !ok { |
| + continue |
| + } |
| + if t, ok := byHost[p.Target]; ok { |
| + o.add(release.RouteAccount(t), t.App) |
| + } |
| + } |
| + return owners |
| } |
| // edgesFile is the wire shape at edgesFileKey(box), schema v1 (spec Part 3): |
| @@ -237,6 +302,17 @@ func edgesFilePass(ctx context.Context, s release.Store, reg *registry.Registry, |
| // the account apex that rides along. |
| hosts[r.Host] = edgeHost{Role: "home", Platform: r.Platform || r.Provisioned, Replica: r.Replica} |
| } |
| + // Owners (#175 P7): computed over the served rows alone, before any alias |
| + // or catch-all joins the map, so an alias can only ever inherit a route's. |
| + paths, err := reg.ListPaths() |
| + if err != nil { |
| + return fmt.Errorf("edges: list paths: %w", err) |
| + } |
| + owners := servedOwners(routes, hosts, paths) |
| + for host, h := range hosts { |
| + h.Account, h.App = owners[host].owner() |
| + hosts[host] = h |
| + } |
| // Attached custom domains (#151) live in route_aliases, not routes, and |
| // were absent from this file (#287): a non-home edge neither backhauled |
| // them (Home missed) nor answered their validations or let the issuer |
| @@ -285,7 +361,11 @@ func edgesFilePass(ctx context.Context, s release.Store, reg *registry.Registry, |
| if _, taken := hosts[a.Alias]; taken { |
| continue |
| } |
| - hosts[a.Alias] = edgeHost{Role: "home", Alias: true} |
| + // The edge serves an alias with its primary's own entry, prefix map |
| + // included, so the alias's owner is the primary's, never the alias |
| + // row's own columns. |
| + acct, app := owners[a.Host].owner() |
| + hosts[a.Alias] = edgeHost{Role: "home", Alias: true, Account: acct, App: app} |
| } |
| // Replica placements (#162): a row minted from a role=replica record — |
| @@ -319,11 +399,13 @@ func edgesFilePass(ctx context.Context, s release.Store, reg *registry.Registry, |
| // terms); advertising a dial target for it would have region edges |
| // terminate and backhaul any invented name under the parent to a box |
| // that answers 503. |
| - catchallServed := map[string]bool{} |
| + // catchallServed maps each served parent to the primary its "*.<parent>" |
| + // alias row points at, the route the edge serves the catch-all with. |
| + catchallServed := map[string]string{} |
| for _, a := range aliases { |
| if parent, ok := strings.CutPrefix(a.Alias, "*."); ok { |
| - if _, served := hosts[a.Host]; served { |
| - catchallServed[parent] = true |
| + if _, served := owners[a.Host]; served { |
| + catchallServed[parent] = a.Host |
| } |
| } |
| } |
| @@ -357,10 +439,17 @@ func edgesFilePass(ctx context.Context, s release.Store, reg *registry.Registry, |
| // A catch-all we serve IS a materialized claim, which is what the |
| // mark means, so say so. |
| role, alias := "wildcard", false |
| - if catchallServed[wd.Host] { |
| + var acct, app string |
| + if primary, served := catchallServed[wd.Host]; served { |
| role, alias = "home", true |
| + // The owner is the wildcard claim's, and only when the primary |
| + // the catch-all is served by (with its prefixes) agrees with it: |
| + // every route behind the name has to have one owner. |
| + o := *owners[primary] |
| + o.add(wd.Account, wd.App) |
| + acct, app = o.owner() |
| } |
| - hosts[key] = edgeHost{Role: role, Wildcard: true, Alias: alias} |
| + hosts[key] = edgeHost{Role: role, Wildcard: true, Alias: alias, Account: acct, App: app} |
| } |
| if eip == "" && *last != nil { |
diff --git a/internal/host/edgesfile_test.go b/internal/host/edgesfile_test.go| index ad3eaec5a..75436e949 100644 |
| --- a/internal/host/edgesfile_test.go |
| +++ b/internal/host/edgesfile_test.go |
| @@ -1089,3 +1089,119 @@ func TestEdgesFileReplicaMarkFollowsTheRow(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// ---- owners (#175 P7, the edge-local door) --------------------------------- |
| + |
| +// publishHosts runs one pass on box "annie" and reads its hosts back. |
| +func publishHosts(t *testing.T, reg *registry.Registry) map[string]edgeHost { |
| + t.Helper() |
| + s := release.NewDirStore(t.TempDir()) |
| + var last []byte |
| + if err := edgesFilePass(context.Background(), s, reg, edgesFile{Box: "annie"}, "1.2.3.4", &last); err != nil { |
| + t.Fatal(err) |
| + } |
| + body, _, err := s.Get(context.Background(), "control/edges/annie.json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var f edgesFile |
| + if err := json.Unmarshal(body, &f); err != nil { |
| + t.Fatal(err) |
| + } |
| + return f.Hosts |
| +} |
| + |
| +func ownedRoute(t *testing.T, reg *registry.Registry, host, account, app string) { |
| + t.Helper() |
| + if err := reg.Upsert(registry.Route{Host: host, Kind: "service", Addr: "10.0.0.1:3000", Status: "ready", Account: account, App: app}); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func wantOwner(t *testing.T, hosts map[string]edgeHost, host, account, app string) { |
| + t.Helper() |
| + h, ok := hosts[host] |
| + if !ok { |
| + t.Fatalf("%s not published: %+v", host, hosts) |
| + } |
| + if h.Account != account || h.App != app { |
| + t.Errorf("%s owner = %q/%q, want %q/%q", host, h.Account, h.App, account, app) |
| + } |
| +} |
| + |
| +// A home row publishes its own account and app, which the door checks |
| +// remotely. A row with no app has no owner a door key could name. |
| +func TestEdgesFilePublishesTheOwnerOfAHomeRow(t *testing.T) { |
| + reg := edgesTestRegistry(t) |
| + ownedRoute(t, reg, "acme.go.tito.io", "bae", "titogo") |
| + ownedRoute(t, reg, "*.go.tito.io", "bae", "titogo") |
| + seedRoute(t, reg, "appless.example.com") |
| + hosts := publishHosts(t, reg) |
| + wantOwner(t, hosts, "acme.go.tito.io", "bae", "titogo") |
| + wantOwner(t, hosts, "*.go.tito.io", "bae", "titogo") |
| + wantOwner(t, hosts, "appless.example.com", "", "") |
| +} |
| + |
| +// An alias is served by its primary's route, so it carries the primary's |
| +// owner, not the alias row's own columns. |
| +func TestEdgesFilePublishesAnAliasWithItsPrimarysOwner(t *testing.T) { |
| + withheldAliasSaid = sync.Map{} |
| + reg := edgesTestRegistry(t) |
| + ownedRoute(t, reg, "acme.go.tito.io", "bae", "titogo") |
| + if err := reg.UpsertAlias(registry.RouteAlias{Alias: "tickets.acme.com", Host: "acme.go.tito.io", |
| + Account: "bae", App: "somethingelse", DNSStatus: "pointing"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + wantOwner(t, publishHosts(t, reg), "tickets.acme.com", "bae", "titogo") |
| +} |
| + |
| +// The served catch-all carries the wildcard claim's owner, when the primary |
| +// it serves agrees. |
| +func TestEdgesFilePublishesTheServedCatchAllsOwner(t *testing.T) { |
| + reg := edgesTestRegistry(t) |
| + ownedRoute(t, reg, "acme.go.tito.io", "bae", "titogo") |
| + if err := reg.UpsertAlias(registry.RouteAlias{Alias: "*.acme.com", Host: "acme.go.tito.io", Account: "bae", App: "titogo"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, w := range []registry.WildcardDomain{ |
| + {Host: "acme.com", Account: "bae", App: "titogo"}, |
| + {Host: "certonly.com", Account: "bae", App: "titogo"}, |
| + } { |
| + if err := reg.UpsertWildcardDomain(w); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + hosts := publishHosts(t, reg) |
| + wantOwner(t, hosts, "*.acme.com", "bae", "titogo") |
| + // The cert half dials nothing, so it names no owner. |
| + wantOwner(t, hosts, "*.certonly.com", "", "") |
| +} |
| + |
| +// Review focus 2: a hostname whose path prefixes reach two apps publishes no |
| +// owner, nor does its alias, so the door refuses it remotely. A hostname whose |
| +// prefixes all reach one app still publishes that owner. |
| +func TestEdgesFilePublishesNoOwnerForAMixedPathPrefixHostname(t *testing.T) { |
| + withheldAliasSaid = sync.Map{} |
| + reg := edgesTestRegistry(t) |
| + ownedRoute(t, reg, "ti.to", "bae", "titogo") |
| + ownedRoute(t, reg, "a.go.tito.io", "bae", "titogo") |
| + ownedRoute(t, reg, "b.classic.tito.io", "bae", "classic") |
| + ownedRoute(t, reg, "one.example", "bae", "titogo") |
| + for _, p := range []registry.RoutePath{ |
| + {Host: "ti.to", Prefix: "a", Target: "a.go.tito.io", Account: "bae", App: "titogo"}, |
| + {Host: "ti.to", Prefix: "b", Target: "b.classic.tito.io", Account: "bae", App: "titogo"}, |
| + {Host: "one.example", Prefix: "a", Target: "a.go.tito.io", Account: "bae", App: "titogo"}, |
| + } { |
| + if err := reg.UpsertPath(p); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := reg.UpsertAlias(registry.RouteAlias{Alias: "tickets.example", Host: "ti.to", Account: "bae", App: "titogo", DNSStatus: "pointing"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + hosts := publishHosts(t, reg) |
| + wantOwner(t, hosts, "ti.to", "", "") |
| + wantOwner(t, hosts, "tickets.example", "", "") |
| + wantOwner(t, hosts, "one.example", "bae", "titogo") |
| + wantOwner(t, hosts, "b.classic.tito.io", "bae", "classic") |
| +} |