feat(broker): project broker-native daemon observations
Close the P5.2a broker catalogue contract: a publication carries one broker-native observation per daemon instead of a remote-host projection. - ports: BrokerDaemonObservation keeps configured authority (local flag, endpoint, registration, policy, display origin, rank) separate from observed state (identity, incarnation, protocol version, capabilities, availability, freshness, failure, exact session catalogue). BrokerSnapshot publishes daemons with the local entry first, and the durable projection rule enforces the observed-identity rule the live contract already enforced. - registry: stamps authority from durable membership only, orders remotes by registration order, re-stamps rank when that order changes, refuses membership it cannot publish, and never publishes a projection the wire would refuse. - brokerstore: persists and reloads remote observations only, re-stamping policy from membership on load. - brokerwire: SnapshotBegin/SnapshotDaemon/SnapshotSession carry the new projection; protocol version 55. - daemonmux: narrow the admission field before the cast so an out-of-range wire value cannot alias a valid admission.
- commit
- a734bf58d00fb1df48368ce422b8b3dc33b2f3f6
- author
- brice · · 2026-09-19 09:22 UTC
- parent
- ca3d194
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
43 files · +3185 -970
M docs/architecture.md +25 -1
| @@ -193,7 +193,7 @@ Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values. | |||
| 193 | 193 | `go tool buf generate`, and handle the new variant in `sessionwire`. | |
| 194 | 194 | Never edit generated `*.pb.go`; never add manual IDs or dispatch tables. | |
| 195 | 195 | - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`. | |
| 196 | - | - Bump `internal/protocol.Version` for negotiated wire layout changes (currently `54`). The preamble epoch (`wire.ProtocolEpoch`, QUIC ALPN `vev/1`) bumps only for an intentional clean break. | |
| 196 | + | - Bump `internal/protocol.Version` for negotiated wire layout changes (currently `55`). The preamble epoch (`wire.ProtocolEpoch`, QUIC ALPN `vev/1`) bumps only for an intentional clean break. | |
| 197 | 197 | ||
| 198 | 198 | ## Broker wire core (Plan 001 P3.1, not activated) | |
| 199 | 199 | ||
| @@ -265,6 +265,30 @@ nonblocking drain on false precedes Reset, supporting buffered timer adapters. | |||
| 265 | 265 | Policy identities have independent field-labelled validation and a named bound. | |
| 266 | 266 | These are P2.2 port contracts, not a wire migration or production activation. | |
| 267 | 267 | ||
| 268 | + | ## Broker registry and daemon observation (Plan 001 P5.2a, not activated) | |
| 269 | + | ||
| 270 | + | `internal/usecase/broker.Registry` owns configured hosts and their immutable | |
| 271 | + | observation projections. `ports.BrokerDaemonObservation` is the broker-native | |
| 272 | + | projection of one daemon, local or remote: configured authority (local flag, | |
| 273 | + | endpoint, registration, policy, display origin, rank) is stamped by the | |
| 274 | + | registry from durable membership and never taken from a probe, while observed | |
| 275 | + | identity, process incarnation, protocol version, capability bitmask, | |
| 276 | + | availability, failure/freshness state, and the exact session catalogue come | |
| 277 | + | only from probe results. Unknown identity or version is zero, never invented; | |
| 278 | + | an observed identity always carries a protocol version, and availability is | |
| 279 | + | never zero (an unobserved daemon carries `RemoteAvailabilityUnknown`). A | |
| 280 | + | `ports.BrokerSnapshot` publishes these observations (`Daemons`, local entry | |
| 281 | + | first when present, then remotes in registration order) plus the tombstone | |
| 282 | + | set; `brokerwire` mirrors them through `SnapshotBegin`/`SnapshotDaemon`/ | |
| 283 | + | `SnapshotSession` parts. Local observations are never durable: the snapshot | |
| 284 | + | store persists and reloads remote observations only, with policy re-stamped | |
| 285 | + | from membership on load, so membership stays the single policy authority. | |
| 286 | + | Observing a daemon never attaches to it and never starts a stopped one. | |
| 287 | + | Attachment streams declare one closed admission variant | |
| 288 | + | (`ports.BrokerStreamAdmission`: exact attach/resume, named creation, ephemeral | |
| 289 | + | creation) validated identically at the ports, broker IPC, and daemonmux | |
| 290 | + | boundaries. | |
| 291 | + | ||
| 268 | 292 | ## Broker local IPC (Plan 001 P3.3, not activated) | |
| 269 | 293 | ||
| 270 | 294 | `internal/adapters/brokeripc` carries the P3.1 broker conversation over one | |
M internal/adapters/brokeripc/client.go +2 -1
| @@ -486,7 +486,8 @@ func (c *client) OpenStream(ctx context.Context, request ports.BrokerOpenStreamR | |||
| 486 | 486 | // dispatched, because the local identity is already admitted either way. | |
| 487 | 487 | if _, err := c.sendContext(ctx, brokerwire.OpenStream{ | |
| 488 | 488 | Epoch: scoped.Epoch, Connection: scoped.Connection, Stream: scoped.Stream, | |
| 489 | - | Purpose: scoped.Purpose, Local: scoped.Local, | |
| 489 | + | Purpose: scoped.Purpose, Admission: scoped.Admission, Name: scoped.Name, | |
| 490 | + | Local: scoped.Local, | |
| 490 | 491 | Endpoint: scoped.Endpoint, Registration: scoped.Registration, | |
| 491 | 492 | Target: scoped.Target, Env: scoped.Env, Policy: scoped.Policy, | |
| 492 | 493 | }); err != nil { | |
M internal/adapters/brokeripc/emit.go +24 -20
| @@ -11,12 +11,13 @@ import ( | |||
| 11 | 11 | // Snapshot publication (P3.3). | |
| 12 | 12 | // | |
| 13 | 13 | // A broker publication travels as the exact multipart transfer the P3.1 | |
| 14 | - | // assembler accepts: Begin(index 0, element counts), then every host part in | |
| 15 | - | // host-index order immediately followed by that host's session parts in | |
| 16 | - | // session-index order, then the tombstones, then End at the final index. Host | |
| 17 | - | // parts carry no inline sessions; sessions travel as their own parts. Both | |
| 18 | - | // sides agree on one deterministic layout, so the receiving assembler commits | |
| 19 | - | // only a transfer whose indexes, counts, and order are exact. | |
| 14 | + | // assembler accepts: Begin(index 0, element counts), then every daemon part in | |
| 15 | + | // daemon-index order immediately followed by that daemon's session parts in | |
| 16 | + | // session-index order, then the tombstones, then End at the final index. The | |
| 17 | + | // local daemon, when present, is daemon index 0 and its sessions carry | |
| 18 | + | // Local=true. Daemon parts carry no inline sessions; sessions travel as their | |
| 19 | + | // own parts. Both sides agree on one deterministic layout, so the receiving | |
| 20 | + | // assembler commits only a transfer whose indexes, counts, and order are exact. | |
| 20 | 21 | // | |
| 21 | 22 | // The emitter refuses a transfer the peer's assembler would refuse: more parts | |
| 22 | 23 | // than MaxSnapshotParts, or a total encoded payload above the staged ceiling. | |
| @@ -33,20 +34,21 @@ func snapshotParts(snapshot ports.BrokerSnapshot, epoch ports.BrokerEpoch, conne | |||
| 33 | 34 | if snapshot.Revision == 0 { | |
| 34 | 35 | return nil, errors.New("brokeripc: snapshot publication has no revision") | |
| 35 | 36 | } | |
| 36 | - | if len(snapshot.Hosts) > ports.BrokerMaxHosts || len(snapshot.Removed) > ports.BrokerMaxTombstones { | |
| 37 | - | return nil, errors.New("brokeripc: snapshot publication exceeds host bound") | |
| 37 | + | if len(snapshot.Daemons) > ports.BrokerMaxDaemonsPerSnapshot || len(snapshot.Removed) > ports.BrokerMaxTombstones { | |
| 38 | + | return nil, errors.New("brokeripc: snapshot publication exceeds daemon bound") | |
| 38 | 39 | } | |
| 39 | 40 | sessions := 0 | |
| 40 | - | for _, host := range snapshot.Hosts { | |
| 41 | - | if len(host.Sessions) > ports.BrokerMaxSessionsPerHost { | |
| 41 | + | for _, daemon := range snapshot.Daemons { | |
| 42 | + | if len(daemon.Sessions) > ports.BrokerMaxSessionsPerHost { | |
| 42 | 43 | return nil, errors.New("brokeripc: snapshot publication exceeds session bound") | |
| 43 | 44 | } | |
| 44 | - | sessions += len(host.Sessions) | |
| 45 | + | sessions += len(daemon.Sessions) | |
| 45 | 46 | } | |
| 46 | - | total := 1 + len(snapshot.Hosts) + sessions + len(snapshot.Removed) + 1 | |
| 47 | + | total := 1 + len(snapshot.Daemons) + sessions + len(snapshot.Removed) + 1 | |
| 47 | 48 | if total > brokerwire.MaxSnapshotParts { | |
| 48 | 49 | return nil, errors.New("brokeripc: snapshot publication exceeds part bound") | |
| 49 | 50 | } | |
| 51 | + | localPresent := len(snapshot.Daemons) > 0 && snapshot.Daemons[0].Local | |
| 50 | 52 | ||
| 51 | 53 | parts := make([]brokerwire.SnapshotPart, 0, total) | |
| 52 | 54 | base := func(part brokerwire.SnapshotPartPayload) brokerwire.SnapshotPart { | |
| @@ -56,23 +58,25 @@ func snapshotParts(snapshot ports.BrokerSnapshot, epoch ports.BrokerEpoch, conne | |||
| 56 | 58 | } | |
| 57 | 59 | } | |
| 58 | 60 | parts = append(parts, base(brokerwire.SnapshotBegin{ | |
| 59 | - | HostCount: uint32(len(snapshot.Hosts)), | |
| 61 | + | HostCount: uint32(len(snapshot.Daemons)), | |
| 60 | 62 | SessionCount: uint32(sessions), | |
| 61 | 63 | TombstoneCount: uint32(len(snapshot.Removed)), | |
| 64 | + | LocalPresent: localPresent, | |
| 62 | 65 | })) | |
| 63 | - | for hostIndex, host := range snapshot.Hosts { | |
| 64 | - | projection := host.Clone() | |
| 66 | + | for daemonIndex, daemon := range snapshot.Daemons { | |
| 67 | + | projection := daemon.Clone() | |
| 65 | 68 | sessionCount := len(projection.Sessions) | |
| 66 | 69 | projection.Sessions = nil | |
| 67 | - | parts = append(parts, base(brokerwire.SnapshotHostPart{ | |
| 68 | - | HostIndex: uint32(hostIndex), | |
| 69 | - | Host: projection, | |
| 70 | + | parts = append(parts, base(brokerwire.SnapshotDaemonPart{ | |
| 71 | + | HostIndex: uint32(daemonIndex), | |
| 72 | + | Daemon: projection, | |
| 70 | 73 | SessionCount: uint32(sessionCount), | |
| 71 | 74 | })) | |
| 72 | - | for sessionIndex, session := range host.Sessions { | |
| 75 | + | for sessionIndex, session := range daemon.Sessions { | |
| 73 | 76 | parts = append(parts, base(brokerwire.SnapshotSessionPart{ | |
| 74 | - | HostIndex: uint32(hostIndex), | |
| 77 | + | HostIndex: uint32(daemonIndex), | |
| 75 | 78 | SessionIndex: uint32(sessionIndex), | |
| 79 | + | Local: daemon.Local, | |
| 76 | 80 | Session: cloneCatalogSession(session), | |
| 77 | 81 | })) | |
| 78 | 82 | } | |
M internal/adapters/brokeripc/helpers_test.go +23 -6
| @@ -45,13 +45,14 @@ func testPolicy() ports.BrokerPolicy { | |||
| 45 | 45 | } | |
| 46 | 46 | } | |
| 47 | 47 | ||
| 48 | - | // testHostAt builds one catalogue-valid host projection for index i. | |
| 49 | - | func testHostAt(i int) ports.RemoteHostSnapshot { | |
| 48 | + | // testDaemonAt builds one catalogue-valid remote daemon projection for index i. | |
| 49 | + | func testDaemonAt(i int) ports.BrokerDaemonObservation { | |
| 50 | 50 | endpoint := fmt.Sprintf("user%d@host%d:22", i, i) | |
| 51 | - | return ports.RemoteHostSnapshot{ | |
| 51 | + | return ports.BrokerDaemonObservation{ | |
| 52 | 52 | Endpoint: endpoint, | |
| 53 | 53 | DisplayOrigin: endpoint, | |
| 54 | 54 | Registration: domain.RemoteRegistration{Endpoint: endpoint, Incarnation: [16]byte{0x10, byte(i + 1), 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xf0, 0x11}, Generation: domain.RemoteGeneration(i + 1)}, | |
| 55 | + | Policy: testPolicy(), | |
| 55 | 56 | Availability: domain.RemoteAvailabilityReachable, | |
| 56 | 57 | LastSuccess: time.Unix(1700000000+int64(i), 0).UTC(), | |
| 57 | 58 | InventoryKnown: true, | |
| @@ -71,9 +72,25 @@ func testSessionAt(host, index int) catalogue.RemoteCatalogSession { | |||
| 71 | 72 | ||
| 72 | 73 | // testSnapshot builds one valid publication for an epoch and revision. | |
| 73 | 74 | func testSnapshot(epoch ports.BrokerEpoch, revision ports.BrokerRevision) ports.BrokerSnapshot { | |
| 74 | - | host := testHostAt(0) | |
| 75 | - | host.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(0, 0), testSessionAt(0, 1)} | |
| 76 | - | return ports.BrokerSnapshot{Epoch: epoch, Revision: revision, Hosts: []ports.RemoteHostSnapshot{host}} | |
| 75 | + | daemon := testDaemonAt(0) | |
| 76 | + | daemon.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(0, 0), testSessionAt(0, 1)} | |
| 77 | + | return ports.BrokerSnapshot{Epoch: epoch, Revision: revision, Daemons: []ports.BrokerDaemonObservation{daemon}} | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // testLocalSnapshot builds one valid publication whose first daemon is local: | |
| 81 | + | // the local daemon with one session followed by one remote daemon with one. | |
| 82 | + | func testLocalSnapshot(epoch ports.BrokerEpoch, revision ports.BrokerRevision) ports.BrokerSnapshot { | |
| 83 | + | local := ports.BrokerDaemonObservation{ | |
| 84 | + | Local: true, | |
| 85 | + | DisplayOrigin: "local", | |
| 86 | + | Policy: testPolicy(), | |
| 87 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 88 | + | InventoryKnown: true, | |
| 89 | + | Sessions: []catalogue.RemoteCatalogSession{testSessionAt(0, 0)}, | |
| 90 | + | } | |
| 91 | + | remote := testDaemonAt(0) | |
| 92 | + | remote.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(1, 0)} | |
| 93 | + | return ports.BrokerSnapshot{Epoch: epoch, Revision: revision, Daemons: []ports.BrokerDaemonObservation{local, remote}} | |
| 77 | 94 | } | |
| 78 | 95 | ||
| 79 | 96 | // snapshotHub is a multi-subscriber publication hub standing in for the broker | |
M internal/adapters/brokeripc/limits_test.go +47 -6
| @@ -24,10 +24,10 @@ import ( | |||
| 24 | 24 | // session-less hosts. | |
| 25 | 25 | func TestSnapshotPartsLayoutRoundTrips(t *testing.T) { | |
| 26 | 26 | snapshot := testSnapshot(9, 12) | |
| 27 | - | snapshot.Hosts = append(snapshot.Hosts, testHostAt(1)) | |
| 27 | + | snapshot.Daemons = append(snapshot.Daemons, testDaemonAt(1)) | |
| 28 | 28 | snapshot.Removed = []ports.BrokerHostTombstone{{ | |
| 29 | 29 | Endpoint: "retired@old:22", | |
| 30 | - | Registration: testHostAt(0).Registration, | |
| 30 | + | Registration: testDaemonAt(0).Registration, | |
| 31 | 31 | RetiredRevision: 3, | |
| 32 | 32 | }} | |
| 33 | 33 | snapshot.Removed[0].Registration.Endpoint = "retired@old:22" | |
| @@ -61,9 +61,9 @@ func TestSnapshotPartsLayoutRoundTrips(t *testing.T) { | |||
| 61 | 61 | } | |
| 62 | 62 | require.True(t, found, "the transfer must commit") | |
| 63 | 63 | require.Equal(t, snapshot.Revision, committed.Revision) | |
| 64 | - | require.Len(t, committed.Hosts, 2) | |
| 65 | - | require.Len(t, committed.Hosts[0].Sessions, 2) | |
| 66 | - | for _, host := range committed.Hosts { | |
| 64 | + | require.Len(t, committed.Daemons, 2) | |
| 65 | + | require.Len(t, committed.Daemons[0].Sessions, 2) | |
| 66 | + | for _, host := range committed.Daemons { | |
| 67 | 67 | for _, session := range host.Sessions { | |
| 68 | 68 | require.Equal(t, []catalogue.RemoteCatalogTab{}, session.Tabs) | |
| 69 | 69 | } | |
| @@ -72,13 +72,54 @@ func TestSnapshotPartsLayoutRoundTrips(t *testing.T) { | |||
| 72 | 72 | require.Equal(t, "retired@old:22", committed.Removed[0].Endpoint) | |
| 73 | 73 | } | |
| 74 | 74 | ||
| 75 | + | // TestSnapshotPartsLocalDaemonLayoutRoundTrips proves the emitter and the | |
| 76 | + | // assembler agree on a local-first transfer: Begin advertises local_present, | |
| 77 | + | // the local daemon is index 0 with its session bound locally, and the committed | |
| 78 | + | // snapshot reproduces the local-first publication. | |
| 79 | + | func TestSnapshotPartsLocalDaemonLayoutRoundTrips(t *testing.T) { | |
| 80 | + | snapshot := testLocalSnapshot(4, 2) | |
| 81 | + | require.NoError(t, snapshot.Validate()) | |
| 82 | + | ||
| 83 | + | parts, err := snapshotParts(snapshot, 4, ports.BrokerConnectionID{4, 4}, 2) | |
| 84 | + | require.NoError(t, err) | |
| 85 | + | require.Len(t, parts, 1+2+2+1) | |
| 86 | + | ||
| 87 | + | begin, ok := parts[0].Part.(brokerwire.SnapshotBegin) | |
| 88 | + | require.True(t, ok) | |
| 89 | + | require.True(t, begin.LocalPresent) | |
| 90 | + | require.Equal(t, uint32(2), begin.HostCount) | |
| 91 | + | require.Equal(t, uint32(2), begin.SessionCount) | |
| 92 | + | ||
| 93 | + | assembler := brokerwire.NewSnapshotAssembler(4, ports.BrokerConnectionID{4, 4}, brokerwire.WithGeneration(2)) | |
| 94 | + | var committed ports.BrokerSnapshot | |
| 95 | + | found := false | |
| 96 | + | for index, part := range parts { | |
| 97 | + | require.Equal(t, uint32(index), part.Index) | |
| 98 | + | done, assembled, err := assembler.Add(part) | |
| 99 | + | require.NoError(t, err, "part %d", index) | |
| 100 | + | if done { | |
| 101 | + | committed = assembled | |
| 102 | + | found = true | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | require.True(t, found, "the transfer must commit") | |
| 106 | + | require.Equal(t, snapshot.Revision, committed.Revision) | |
| 107 | + | require.Len(t, committed.Daemons, 2) | |
| 108 | + | require.True(t, committed.Daemons[0].Local) | |
| 109 | + | require.Empty(t, committed.Daemons[0].Endpoint) | |
| 110 | + | require.Len(t, committed.Daemons[0].Sessions, 1) | |
| 111 | + | require.Equal(t, snapshot.Daemons[0].Sessions[0].Name, committed.Daemons[0].Sessions[0].Name) | |
| 112 | + | require.Equal(t, "user0@host0:22", committed.Daemons[1].Endpoint) | |
| 113 | + | require.Len(t, committed.Daemons[1].Sessions, 1) | |
| 114 | + | } | |
| 115 | + | ||
| 75 | 116 | // TestSnapshotPartsRefusesOverBoundInventory proves the emitter fails closed on | |
| 76 | 117 | // an inventory the peer's assembler would refuse, instead of silently sending a | |
| 77 | 118 | // transfer that can never commit. | |
| 78 | 119 | func TestSnapshotPartsRefusesOverBoundInventory(t *testing.T) { | |
| 79 | 120 | snapshot := testSnapshot(1, 1) | |
| 80 | 121 | over := make([]catalogue.RemoteCatalogSession, ports.BrokerMaxSessionsPerHost+1) | |
| 81 | - | snapshot.Hosts[0].Sessions = over | |
| 122 | + | snapshot.Daemons[0].Sessions = over | |
| 82 | 123 | _, err := snapshotParts(snapshot, 1, ports.BrokerConnectionID{1}, 1) | |
| 83 | 124 | require.Error(t, err) | |
| 84 | 125 | ||
M internal/adapters/brokeripc/session_test.go +35 -8
| @@ -132,6 +132,33 @@ func TestSessionCloseStreamFillsConnectionScope(t *testing.T) { | |||
| 132 | 132 | require.ErrorIs(t, session.CloseStream(ports.BrokerConnectionID{0x99}, 2), ports.BrokerAdmissionStale) | |
| 133 | 133 | } | |
| 134 | 134 | ||
| 135 | + | // TestOpenStreamAdmissionRoundTrips proves the attachment-admission contract | |
| 136 | + | // travels the IPC wire: a create-named request reaches the core with exactly | |
| 137 | + | // its admission and validated name. | |
| 138 | + | func TestOpenStreamAdmissionRoundTrips(t *testing.T) { | |
| 139 | + | e := startEndpoint(t, Config{}) | |
| 140 | + | client, _ := e.pair() | |
| 141 | + | core := e.authority.last() | |
| 142 | + | require.NotNil(t, core) | |
| 143 | + | ||
| 144 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 145 | + | defer cancel() | |
| 146 | + | stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{ | |
| 147 | + | Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionCreateNamed, Name: "work", | |
| 148 | + | Local: true, Policy: testPolicy(), | |
| 149 | + | }) | |
| 150 | + | require.NoError(t, err) | |
| 151 | + | require.NotNil(t, stream) | |
| 152 | + | ||
| 153 | + | core.mu.Lock() | |
| 154 | + | defer core.mu.Unlock() | |
| 155 | + | require.Len(t, core.opens, 1) | |
| 156 | + | require.Equal(t, ports.BrokerAdmissionCreateNamed, core.opens[0].Admission) | |
| 157 | + | require.Equal(t, "work", core.opens[0].Name) | |
| 158 | + | require.Equal(t, ports.BrokerStreamAttachment, core.opens[0].Purpose) | |
| 159 | + | require.True(t, core.opens[0].Local) | |
| 160 | + | } | |
| 161 | + | ||
| 135 | 162 | // TestRegistrationAssignsScopeAtAccept proves the client adapter adopts exactly | |
| 136 | 163 | // the scope the listener assigned at accept. | |
| 137 | 164 | func TestRegistrationAssignsScopeAtAccept(t *testing.T) { | |
| @@ -158,10 +185,10 @@ func TestSubscribePublishesSnapshot(t *testing.T) { | |||
| 158 | 185 | ||
| 159 | 186 | got := client.Snapshot() | |
| 160 | 187 | require.Equal(t, e.epoch, got.Epoch) | |
| 161 | - | require.Len(t, got.Hosts, 1) | |
| 162 | - | require.Equal(t, snapshot.Hosts[0].Endpoint, got.Hosts[0].Endpoint) | |
| 163 | - | require.Len(t, got.Hosts[0].Sessions, 2) | |
| 164 | - | require.Equal(t, snapshot.Hosts[0].Sessions[0].Name, got.Hosts[0].Sessions[0].Name) | |
| 188 | + | require.Len(t, got.Daemons, 1) | |
| 189 | + | require.Equal(t, snapshot.Daemons[0].Endpoint, got.Daemons[0].Endpoint) | |
| 190 | + | require.Len(t, got.Daemons[0].Sessions, 2) | |
| 191 | + | require.Equal(t, snapshot.Daemons[0].Sessions[0].Name, got.Daemons[0].Sessions[0].Name) | |
| 165 | 192 | ||
| 166 | 193 | select { | |
| 167 | 194 | case <-sub.Changed(): | |
| @@ -619,11 +646,11 @@ func TestRequestReconcileIsBoundedHint(t *testing.T) { | |||
| 619 | 646 | snapshot := e.publish(e.epoch, 4) | |
| 620 | 647 | require.Eventually(t, func() bool { return client.Snapshot().Revision == 4 }, 5*time.Second, 10*time.Millisecond) | |
| 621 | 648 | ||
| 622 | - | client.RequestReconcile(snapshot.Hosts[0].Endpoint) | |
| 649 | + | client.RequestReconcile(snapshot.Daemons[0].Endpoint) | |
| 623 | 650 | require.Eventually(t, func() bool { | |
| 624 | 651 | core.mu.Lock() | |
| 625 | 652 | defer core.mu.Unlock() | |
| 626 | - | return len(core.reconcile) == 1 && core.reconcile[0] == snapshot.Hosts[0].Endpoint | |
| 653 | + | return len(core.reconcile) == 1 && core.reconcile[0] == snapshot.Daemons[0].Endpoint | |
| 627 | 654 | }, 5*time.Second, 10*time.Millisecond) | |
| 628 | 655 | } | |
| 629 | 656 | ||
| @@ -689,12 +716,12 @@ func TestSessionHandshakeFailureIsStreamLocal(t *testing.T) { | |||
| 689 | 716 | func TestDomainFixturesAreValid(t *testing.T) { | |
| 690 | 717 | snapshot := testSnapshot(1, 1) | |
| 691 | 718 | require.NoError(t, snapshot.Validate()) | |
| 692 | - | for _, host := range snapshot.Hosts { | |
| 719 | + | for _, host := range snapshot.Daemons { | |
| 693 | 720 | require.NoError(t, ports.ValidateDurableHostProjection(host)) | |
| 694 | 721 | } | |
| 695 | 722 | require.NoError(t, testPolicy().Validate()) | |
| 696 | 723 | catalogueSession := testSessionAt(0, 0) | |
| 697 | 724 | require.False(t, strings.HasPrefix(catalogueSession.Name, " ")) | |
| 698 | - | require.NotEqual(t, domain.RemoteAvailabilityUnknown, testHostAt(0).Availability) | |
| 725 | + | require.NotEqual(t, domain.RemoteAvailabilityUnknown, testDaemonAt(0).Availability) | |
| 699 | 726 | require.True(t, errors.Is(errors.Join(ErrScopeMismatch, ports.BrokerAdmissionStale), ports.BrokerAdmissionStale)) | |
| 700 | 727 | } | |
M internal/adapters/brokeripc/stream.go +2 -1
| @@ -203,7 +203,8 @@ func (s *serverSession) startStream(m brokerwire.OpenStream) { | |||
| 203 | 203 | return | |
| 204 | 204 | } | |
| 205 | 205 | request := ports.BrokerOpenStreamRequest{ | |
| 206 | - | Epoch: m.Epoch, Purpose: m.Purpose, Local: m.Local, | |
| 206 | + | Epoch: m.Epoch, Purpose: m.Purpose, Admission: m.Admission, Name: m.Name, | |
| 207 | + | Local: m.Local, | |
| 207 | 208 | Connection: m.Connection, Stream: m.Stream, | |
| 208 | 209 | Endpoint: m.Endpoint, Registration: m.Registration, | |
| 209 | 210 | Target: m.Target, Env: m.Env, Policy: m.Policy, | |
M internal/adapters/brokerstore/README.md +6 -1
| @@ -82,7 +82,12 @@ record remains authoritative for generation. Transient checking/error objects | |||
| 82 | 82 | are never persisted. Retirement tombstones are process-local fencing state and | |
| 83 | 83 | are never durable: the registry's persistable copy and the store both clear | |
| 84 | 84 | `Snapshot.Removed` before writing, so reopening sees membership without stale | |
| 85 | - | tombstones. No environment, launch, or trust policy is guessed. | |
| 85 | + | tombstones. Policy is membership authority, not observation state, so the | |
| 86 | + | durable observation format omits it: the loader re-stamps every restored | |
| 87 | + | observation from the matching `ports.BrokerHostRecord`, keeping membership the | |
| 88 | + | single policy authority. A local daemon observation is process-local and never | |
| 89 | + | durable: the store rejects one outright. No environment, launch, or trust policy | |
| 90 | + | is guessed. | |
| 86 | 91 | ||
| 87 | 92 | Production cutover, lifecycle supervision, and IPC composition remain later | |
| 88 | 93 | phases. Close ordering must drain registry snapshot writers before releasing the | |
M internal/adapters/brokerstore/legacy.go +14 -2
| @@ -131,12 +131,24 @@ func migrate(o Options) (recovery, error) { | |||
| 131 | 131 | // Unbound v2/v3 cache data is validated and backed up, never promoted | |
| 132 | 132 | // to authority. v4 can seed only its exact surviving incarnation. | |
| 133 | 133 | if e.Host == h.Registration.Endpoint && e.Incarnation == h.Registration.Incarnation { | |
| 134 | - | r.State.Snapshot.Hosts = append(r.State.Snapshot.Hosts, ports.RemoteHostSnapshot{Endpoint: e.Host, Registration: h.Registration, Availability: domain.RemoteAvailabilityUnknown, InventoryKnown: true, LastSuccess: e.FetchedAt, Sessions: e.Sessions}) | |
| 134 | + | // A legacy cache entry carries only observed sessions: daemon | |
| 135 | + | // identity, incarnation, protocol version, and capabilities stay | |
| 136 | + | // zero (never invented), and policy stays absent because the | |
| 137 | + | // loader re-stamps it from membership. | |
| 138 | + | r.State.Snapshot.Daemons = append(r.State.Snapshot.Daemons, ports.BrokerDaemonObservation{ | |
| 139 | + | Endpoint: e.Host, | |
| 140 | + | DisplayOrigin: domain.RemoteDisplayOrigin(e.Host), | |
| 141 | + | Registration: h.Registration, | |
| 142 | + | Availability: domain.RemoteAvailabilityUnknown, | |
| 143 | + | InventoryKnown: true, | |
| 144 | + | LastSuccess: e.FetchedAt, | |
| 145 | + | Sessions: e.Sessions, | |
| 146 | + | }) | |
| 135 | 147 | } | |
| 136 | 148 | } | |
| 137 | 149 | } | |
| 138 | 150 | } | |
| 139 | - | if len(r.State.Snapshot.Hosts) > 0 { | |
| 151 | + | if len(r.State.Snapshot.Daemons) > 0 { | |
| 140 | 152 | var epoch [8]byte | |
| 141 | 153 | if _, err = rand.Read(epoch[:]); err != nil { | |
| 142 | 154 | return r, err | |
M internal/adapters/brokerstore/legacy_test.go +2 -2
| @@ -60,10 +60,10 @@ func TestDefensiveCopiesAndConcurrentAccess(t *testing.T) { | |||
| 60 | 60 | h.Hosts[0].Policy.Trust = "changed" | |
| 61 | 61 | snap, err := s.Load() | |
| 62 | 62 | require.NoError(t, err) | |
| 63 | - | snap.Hosts[0].Sessions[0].Tabs[0].Name = "changed" | |
| 63 | + | snap.Daemons[0].Sessions[0].Tabs[0].Name = "changed" | |
| 64 | 64 | again, err := s.Load() | |
| 65 | 65 | require.NoError(t, err) | |
| 66 | - | require.Equal(t, "shell", again.Hosts[0].Sessions[0].Tabs[0].Name) | |
| 66 | + | require.Equal(t, "shell", again.Daemons[0].Sessions[0].Tabs[0].Name) | |
| 67 | 67 | hosts, err := s.LoadHosts() | |
| 68 | 68 | require.NoError(t, err) | |
| 69 | 69 | require.Equal(t, "known-hosts", hosts.Hosts[0].Policy.Trust) | |
M internal/adapters/brokerstore/store.go +90 -20
| @@ -17,6 +17,7 @@ import ( | |||
| 17 | 17 | "sync" | |
| 18 | 18 | "syscall" | |
| 19 | 19 | ||
| 20 | + | "github.com/bnema/vev/internal/domain" | |
| 20 | 21 | "github.com/bnema/vev/internal/ports" | |
| 21 | 22 | "github.com/bnema/vev/pkg/safedir" | |
| 22 | 23 | ) | |
| @@ -286,14 +287,18 @@ func (s *Store) Store(snapshot ports.BrokerSnapshot) error { | |||
| 286 | 287 | } | |
| 287 | 288 | // Removed tombstones and in-flight checking/failure detail are process-local | |
| 288 | 289 | // fencing and observation state; the durable snapshot never carries them, so | |
| 289 | - | // sanitize the caller's copy before validating the durable shape. | |
| 290 | + | // sanitize the caller's copy before validating the durable shape. Policy is | |
| 291 | + | // membership authority, not observation state, so the durable format omits | |
| 292 | + | // it too: membership is the single policy authority and the loader re-stamps | |
| 293 | + | // every restored observation from the matching record. | |
| 290 | 294 | snapshot = snapshot.Clone() | |
| 291 | 295 | snapshot.Removed = nil | |
| 292 | - | for i := range snapshot.Hosts { | |
| 293 | - | snapshot.Hosts[i].Checking = false | |
| 294 | - | snapshot.Hosts[i].LastFailure.Err = nil | |
| 296 | + | for i := range snapshot.Daemons { | |
| 297 | + | snapshot.Daemons[i].Checking = false | |
| 298 | + | snapshot.Daemons[i].LastFailure.Err = nil | |
| 299 | + | snapshot.Daemons[i].Policy = ports.BrokerPolicy{} | |
| 295 | 300 | } | |
| 296 | - | if err := snapshot.Validate(); err != nil { | |
| 301 | + | if err := validateDurableSnapshot(snapshot); err != nil { | |
| 297 | 302 | return err | |
| 298 | 303 | } | |
| 299 | 304 | if s.writeEpoch != 0 && s.writeEpoch != snapshot.Epoch { | |
| @@ -304,10 +309,10 @@ func (s *Store) Store(snapshot ports.BrokerSnapshot) error { | |||
| 304 | 309 | return ErrStale | |
| 305 | 310 | } | |
| 306 | 311 | // Reject rather than partially adopt a queued publication from retired hosts. | |
| 307 | - | for _, h := range snapshot.Hosts { | |
| 312 | + | for _, daemon := range snapshot.Daemons { | |
| 308 | 313 | found := false | |
| 309 | 314 | for _, a := range s.state.Hosts.Hosts { | |
| 310 | - | if h.Registration.Equal(a.Registration) { | |
| 315 | + | if daemon.Registration.Equal(a.Registration) { | |
| 311 | 316 | found = true | |
| 312 | 317 | } | |
| 313 | 318 | } | |
| @@ -323,14 +328,15 @@ func (s *Store) Store(snapshot ports.BrokerSnapshot) error { | |||
| 323 | 328 | s.writeEpoch = snapshot.Epoch | |
| 324 | 329 | return nil | |
| 325 | 330 | } | |
| 331 | + | ||
| 326 | 332 | func filter(snapshot ports.BrokerSnapshot, hosts ports.BrokerHosts) ports.BrokerSnapshot { | |
| 327 | 333 | out := snapshot.Clone() | |
| 328 | - | out.Hosts = nil | |
| 334 | + | out.Daemons = nil | |
| 329 | 335 | out.Removed = nil | |
| 330 | - | for _, h := range snapshot.Hosts { | |
| 336 | + | for _, daemon := range snapshot.Daemons { | |
| 331 | 337 | for _, a := range hosts.Hosts { | |
| 332 | - | if h.Registration.Equal(a.Registration) { | |
| 333 | - | out.Hosts = append(out.Hosts, h.Clone()) | |
| 338 | + | if daemon.Registration.Equal(a.Registration) { | |
| 339 | + | out.Daemons = append(out.Daemons, daemon.Clone()) | |
| 334 | 340 | } | |
| 335 | 341 | } | |
| 336 | 342 | } | |
| @@ -360,32 +366,96 @@ func validate(st state) error { | |||
| 360 | 366 | return invalid("%v", err) | |
| 361 | 367 | } | |
| 362 | 368 | if st.Snapshot.Epoch == 0 { | |
| 363 | - | if st.Snapshot.Revision != 0 || len(st.Snapshot.Hosts) != 0 || len(st.Snapshot.Removed) != 0 { | |
| 369 | + | if st.Snapshot.Revision != 0 || len(st.Snapshot.Daemons) != 0 || len(st.Snapshot.Removed) != 0 { | |
| 364 | 370 | return invalid("invalid empty snapshot") | |
| 365 | 371 | } | |
| 366 | 372 | return nil | |
| 367 | 373 | } | |
| 368 | - | if err := st.Snapshot.Validate(); err != nil { | |
| 374 | + | if err := validateDurableSnapshot(st.Snapshot); err != nil { | |
| 369 | 375 | return invalid("%v", err) | |
| 370 | 376 | } | |
| 371 | - | for _, h := range st.Snapshot.Hosts { | |
| 377 | + | for _, daemon := range st.Snapshot.Daemons { | |
| 372 | 378 | found := false | |
| 373 | 379 | for _, a := range st.Hosts.Hosts { | |
| 374 | - | if h.Registration.Equal(a.Registration) { | |
| 380 | + | if daemon.Registration.Equal(a.Registration) { | |
| 375 | 381 | found = true | |
| 376 | 382 | } | |
| 377 | 383 | } | |
| 378 | - | if !found || h.Checking || h.LastFailure.Err != nil { | |
| 384 | + | if !found || daemon.Checking || daemon.LastFailure.Err != nil { | |
| 379 | 385 | return invalid("non-authoritative snapshot") | |
| 380 | 386 | } | |
| 381 | - | // The store enforces the same durable projection rules the registry | |
| 382 | - | // checks before publishing, so an accepted publication always persists. | |
| 383 | - | if err := ports.ValidateDurableHostProjection(h); err != nil { | |
| 384 | - | return invalid("%v", err) | |
| 387 | + | } | |
| 388 | + | return nil | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | // validateDurableSnapshot reports whether a snapshot satisfies the durable | |
| 392 | + | // shape: an empty placeholder stays empty, and a populated snapshot carries | |
| 393 | + | // only non-local daemon observations bound to an exact valid registration with | |
| 394 | + | // a closed availability/failure range and a catalogue-valid inventory. Policy | |
| 395 | + | // and display authority are deliberately excluded: the durable format omits | |
| 396 | + | // policy (membership is the single policy authority and the loader re-stamps | |
| 397 | + | // it) and display hints are derived presentation state, never durable | |
| 398 | + | // authority. Transient checking and live failure causes are sanitized away by | |
| 399 | + | // the callers before this rule runs. | |
| 400 | + | func validateDurableSnapshot(snapshot ports.BrokerSnapshot) error { | |
| 401 | + | if snapshot.Epoch == 0 { | |
| 402 | + | if snapshot.Revision != 0 || len(snapshot.Daemons) != 0 || len(snapshot.Removed) != 0 { | |
| 403 | + | return errors.New("invalid empty snapshot") | |
| 404 | + | } | |
| 405 | + | return nil | |
| 406 | + | } | |
| 407 | + | if snapshot.Revision == 0 { | |
| 408 | + | return errors.New("snapshot has no revision") | |
| 409 | + | } | |
| 410 | + | if len(snapshot.Daemons) > ports.BrokerMaxDaemonsPerSnapshot { | |
| 411 | + | return errors.New("snapshot has too many daemons") | |
| 412 | + | } | |
| 413 | + | seen := make(map[string]struct{}, len(snapshot.Daemons)) | |
| 414 | + | for _, daemon := range snapshot.Daemons { | |
| 415 | + | if err := validateDurableObservation(daemon); err != nil { | |
| 416 | + | return err | |
| 417 | + | } | |
| 418 | + | if _, duplicate := seen[daemon.Endpoint]; duplicate { | |
| 419 | + | return errors.New("snapshot has duplicate host") | |
| 420 | + | } | |
| 421 | + | seen[daemon.Endpoint] = struct{}{} | |
| 422 | + | } | |
| 423 | + | if len(snapshot.Removed) > ports.BrokerMaxTombstones { | |
| 424 | + | return errors.New("snapshot has too many tombstones") | |
| 425 | + | } | |
| 426 | + | for _, tombstone := range snapshot.Removed { | |
| 427 | + | if err := tombstone.Validate(); err != nil { | |
| 428 | + | return err | |
| 429 | + | } | |
| 430 | + | if _, live := seen[tombstone.Endpoint]; live { | |
| 431 | + | return errors.New("snapshot carries a live host as tombstone") | |
| 385 | 432 | } | |
| 386 | 433 | } | |
| 387 | 434 | return nil | |
| 388 | 435 | } | |
| 436 | + | ||
| 437 | + | // validateDurableObservation reports whether one daemon observation may be | |
| 438 | + | // durable: it is remote-only (a local daemon observation is never durable), it | |
| 439 | + | // carries an exact valid registration matching its endpoint, and it satisfies | |
| 440 | + | // the ports durable projection rules shared with the registry. | |
| 441 | + | func validateDurableObservation(daemon ports.BrokerDaemonObservation) error { | |
| 442 | + | if daemon.Local { | |
| 443 | + | return errors.New("local daemon observation is never durable") | |
| 444 | + | } | |
| 445 | + | if err := domain.ValidateRemoteHostTarget(daemon.Endpoint); err != nil { | |
| 446 | + | return err | |
| 447 | + | } | |
| 448 | + | if err := daemon.Registration.Validate(); err != nil { | |
| 449 | + | return err | |
| 450 | + | } | |
| 451 | + | if daemon.Endpoint != daemon.Registration.Endpoint { | |
| 452 | + | return errors.New("observation endpoint does not match registration") | |
| 453 | + | } | |
| 454 | + | if len(daemon.Sessions) > 0 && !daemon.InventoryKnown { | |
| 455 | + | return errors.New("observation carries sessions without known inventory") | |
| 456 | + | } | |
| 457 | + | return ports.ValidateDurableHostProjection(daemon) | |
| 458 | + | } | |
| 389 | 459 | func (s *Store) commit(next state) error { | |
| 390 | 460 | if err := validate(next); err != nil { | |
| 391 | 461 | return err | |
M internal/adapters/brokerstore/store_test.go +41 -7
| @@ -17,9 +17,26 @@ import ( | |||
| 17 | 17 | "github.com/stretchr/testify/require" | |
| 18 | 18 | ) | |
| 19 | 19 | ||
| 20 | + | func testPolicy() ports.BrokerPolicy { | |
| 21 | + | return ports.BrokerPolicy{ProtocolVersion: 1, CatalogSchemaVersion: 1, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, Transport: "quic", Trust: "known-hosts", Launch: "explicit", Isolation: "user"} | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // observation builds a remote daemon observation that satisfies the durable | |
| 25 | + | // shape: an exact registration, a derived display origin, and a reachable | |
| 26 | + | // availability. Policy is intentionally left absent: the loader stamps it from | |
| 27 | + | // membership, never from the durable snapshot. | |
| 28 | + | func observation(endpoint string, registration domain.RemoteRegistration) ports.BrokerDaemonObservation { | |
| 29 | + | return ports.BrokerDaemonObservation{ | |
| 30 | + | Endpoint: endpoint, | |
| 31 | + | DisplayOrigin: domain.RemoteDisplayOrigin(endpoint), | |
| 32 | + | Registration: registration, | |
| 33 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 20 | 37 | func options(t *testing.T) Options { | |
| 21 | 38 | t.Helper() | |
| 22 | - | return Options{Dir: filepath.Join(t.TempDir(), "broker"), LegacyHosts: "testdata/hosts-v3.json", LegacyCache: "testdata/cache-v4.json", Policies: map[string]ports.BrokerPolicy{"user@arch": {ProtocolVersion: 1, CatalogSchemaVersion: 1, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, Transport: "quic", Trust: "known-hosts", Launch: "explicit", Isolation: "user"}}} | |
| 39 | + | return Options{Dir: filepath.Join(t.TempDir(), "broker"), LegacyHosts: "testdata/hosts-v3.json", LegacyCache: "testdata/cache-v4.json", Policies: map[string]ports.BrokerPolicy{"user@arch": testPolicy()}} | |
| 23 | 40 | } | |
| 24 | 41 | func TestPriorFixtures(t *testing.T) { | |
| 25 | 42 | for h := 1; h <= 3; h++ { | |
| @@ -46,11 +63,11 @@ func TestPriorFixtures(t *testing.T) { | |||
| 46 | 63 | snap, err := s.Load() | |
| 47 | 64 | require.NoError(t, err) | |
| 48 | 65 | if h == 3 && c == 4 { | |
| 49 | - | require.Len(t, snap.Hosts, 1) | |
| 50 | - | require.Len(t, snap.Hosts[0].Sessions, 1) | |
| 51 | - | require.Equal(t, "t_work", snap.Hosts[0].Sessions[0].Tabs[0].ID) | |
| 66 | + | require.Len(t, snap.Daemons, 1) | |
| 67 | + | require.Len(t, snap.Daemons[0].Sessions, 1) | |
| 68 | + | require.Equal(t, "t_work", snap.Daemons[0].Sessions[0].Tabs[0].ID) | |
| 52 | 69 | } else { | |
| 53 | - | require.Empty(t, snap.Hosts) | |
| 70 | + | require.Empty(t, snap.Daemons) | |
| 54 | 71 | } | |
| 55 | 72 | require.NoError(t, s.Close()) | |
| 56 | 73 | o.LegacyHosts = "missing" | |
| @@ -453,7 +470,7 @@ func TestAuthorityGenerationPolicyAndRemoveReAdd(t *testing.T) { | |||
| 453 | 470 | ||
| 454 | 471 | // A queued publication from the retired incarnation is refused even though | |
| 455 | 472 | // its revision is newer. | |
| 456 | - | require.ErrorIs(t, s.Store(ports.BrokerSnapshot{Epoch: 9, Revision: 1, Hosts: []ports.RemoteHostSnapshot{{Endpoint: base.Registration.Endpoint, Registration: base.Registration, Availability: domain.RemoteAvailabilityReachable}}}), ErrStale) | |
| 473 | + | require.ErrorIs(t, s.Store(ports.BrokerSnapshot{Epoch: 9, Revision: 1, Daemons: []ports.BrokerDaemonObservation{observation(base.Registration.Endpoint, base.Registration)}}), ErrStale) | |
| 457 | 474 | ||
| 458 | 475 | // Re-adding the endpoint needs a fresh incarnation; the retired | |
| 459 | 476 | // observation never comes back for it. | |
| @@ -620,9 +637,26 @@ func TestFencingAndCommitFaults(t *testing.T) { | |||
| 620 | 637 | defer s.Close() | |
| 621 | 638 | got, err := s.Load() | |
| 622 | 639 | require.NoError(t, err) | |
| 623 | - | require.Empty(t, got.Hosts) | |
| 640 | + | require.Empty(t, got.Daemons) | |
| 624 | 641 | gotH, err := s.LoadHosts() | |
| 625 | 642 | require.NoError(t, err) | |
| 626 | 643 | require.Equal(t, h.Hosts, gotH.Hosts) | |
| 627 | 644 | }) | |
| 628 | 645 | } | |
| 646 | + | ||
| 647 | + | // TestLocalDaemonObservationIsNeverDurable pins the durable boundary: a local | |
| 648 | + | // daemon observation is process-local state and the store refuses it outright, | |
| 649 | + | // so it can never become durable authority. | |
| 650 | + | func TestLocalDaemonObservationIsNeverDurable(t *testing.T) { | |
| 651 | + | s, err := OpenOffline(options(t)) | |
| 652 | + | require.NoError(t, err) | |
| 653 | + | defer s.Close() | |
| 654 | + | err = s.Store(ports.BrokerSnapshot{Epoch: 9, Revision: 1, Daemons: []ports.BrokerDaemonObservation{{ | |
| 655 | + | Local: true, | |
| 656 | + | DisplayOrigin: "local", | |
| 657 | + | Policy: testPolicy(), | |
| 658 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 659 | + | }}}) | |
| 660 | + | require.Error(t, err) | |
| 661 | + | require.Contains(t, err.Error(), "never durable") | |
| 662 | + | } | |
M internal/adapters/brokerwire/codec.go +195 -80
| @@ -419,7 +419,8 @@ func unsubscribeFromWire(message *wire.Unsubscribe) (Unsubscribe, error) { | |||
| 419 | 419 | ||
| 420 | 420 | func openStreamToWire(m OpenStream) (*wire.OpenStream, error) { | |
| 421 | 421 | request := ports.BrokerOpenStreamRequest{ | |
| 422 | - | Epoch: m.Epoch, Purpose: m.Purpose, Local: m.Local, | |
| 422 | + | Epoch: m.Epoch, Purpose: m.Purpose, Admission: m.Admission, Name: m.Name, | |
| 423 | + | Local: m.Local, | |
| 423 | 424 | Connection: m.Connection, Stream: m.Stream, | |
| 424 | 425 | Endpoint: m.Endpoint, Registration: m.Registration, | |
| 425 | 426 | Target: m.Target, Env: m.Env, Policy: m.Policy, | |
| @@ -450,22 +451,64 @@ func openStreamToWire(m OpenStream) (*wire.OpenStream, error) { | |||
| 450 | 451 | default: | |
| 451 | 452 | return nil, ErrInvalidMessage | |
| 452 | 453 | } | |
| 454 | + | admission, err := admissionToWire(m.Admission) | |
| 455 | + | if err != nil { | |
| 456 | + | return nil, err | |
| 457 | + | } | |
| 453 | 458 | out := &wire.OpenStream{ | |
| 454 | 459 | Ref: refToWire(m.Epoch, m.Connection, m.Stream), | |
| 455 | 460 | Purpose: purpose, Local: m.Local, Endpoint: m.Endpoint, | |
| 456 | 461 | Env: append([]string(nil), m.Env...), | |
| 457 | 462 | Policy: policyToWire(m.Policy), | |
| 463 | + | // The admission taxonomy mirrors the port values (0 none, 1 exact, | |
| 464 | + | // 2 create named, 3 create ephemeral); the name travels only for the | |
| 465 | + | // create-named variant. | |
| 466 | + | Admission: admission, Name: m.Name, | |
| 458 | 467 | } | |
| 459 | 468 | if !m.Local { | |
| 460 | 469 | out.Registration = registrationToWire(m.Registration) | |
| 461 | 470 | } | |
| 462 | - | if m.Purpose == ports.BrokerStreamAttachment { | |
| 463 | - | target := m.Target | |
| 464 | - | out.Target = exactTargetToWire(target) | |
| 471 | + | // Only exact attach/resume carries a session target; a creation | |
| 472 | + | // admission carries a zero target that must never be encoded as a | |
| 473 | + | // present message. | |
| 474 | + | if m.Purpose == ports.BrokerStreamAttachment && m.Admission == ports.BrokerAdmissionExact { | |
| 475 | + | out.Target = exactTargetToWire(m.Target) | |
| 465 | 476 | } | |
| 466 | 477 | return out, nil | |
| 467 | 478 | } | |
| 468 | 479 | ||
| 480 | + | // admissionToWire maps the closed admission taxonomy onto its wire code. | |
| 481 | + | func admissionToWire(admission ports.BrokerStreamAdmission) (uint32, error) { | |
| 482 | + | switch admission { | |
| 483 | + | case 0: | |
| 484 | + | return 0, nil | |
| 485 | + | case ports.BrokerAdmissionExact: | |
| 486 | + | return 1, nil | |
| 487 | + | case ports.BrokerAdmissionCreateNamed: | |
| 488 | + | return 2, nil | |
| 489 | + | case ports.BrokerAdmissionCreateEphemeral: | |
| 490 | + | return 3, nil | |
| 491 | + | default: | |
| 492 | + | return 0, errConvertRange | |
| 493 | + | } | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | // admissionFromWire maps a wire admission code onto the closed taxonomy. | |
| 497 | + | func admissionFromWire(value uint32) (ports.BrokerStreamAdmission, error) { | |
| 498 | + | switch value { | |
| 499 | + | case 0: | |
| 500 | + | return 0, nil | |
| 501 | + | case 1: | |
| 502 | + | return ports.BrokerAdmissionExact, nil | |
| 503 | + | case 2: | |
| 504 | + | return ports.BrokerAdmissionCreateNamed, nil | |
| 505 | + | case 3: | |
| 506 | + | return ports.BrokerAdmissionCreateEphemeral, nil | |
| 507 | + | default: | |
| 508 | + | return 0, errConvertRange | |
| 509 | + | } | |
| 510 | + | } | |
| 511 | + | ||
| 469 | 512 | func openStreamFromWire(message *wire.OpenStream) (OpenStream, error) { | |
| 470 | 513 | var out OpenStream | |
| 471 | 514 | if message == nil { | |
| @@ -486,6 +529,10 @@ func openStreamFromWire(message *wire.OpenStream) (OpenStream, error) { | |||
| 486 | 529 | default: | |
| 487 | 530 | return OpenStream{}, ErrInvalidMessage | |
| 488 | 531 | } | |
| 532 | + | admission, err := admissionFromWire(message.GetAdmission()) | |
| 533 | + | if err != nil { | |
| 534 | + | return OpenStream{}, ErrInvalidMessage | |
| 535 | + | } | |
| 489 | 536 | local := message.GetLocal() | |
| 490 | 537 | var registration domain.RemoteRegistration | |
| 491 | 538 | if !local { | |
| @@ -496,8 +543,10 @@ func openStreamFromWire(message *wire.OpenStream) (OpenStream, error) { | |||
| 496 | 543 | } else if message.GetRegistration() != nil { | |
| 497 | 544 | return OpenStream{}, ErrInvalidMessage | |
| 498 | 545 | } | |
| 546 | + | // Only exact attach/resume carries a session target. A present target on | |
| 547 | + | // any other admission or purpose is malformed. | |
| 499 | 548 | var target protocol.ExactSessionTarget | |
| 500 | - | if purpose == ports.BrokerStreamAttachment { | |
| 549 | + | if purpose == ports.BrokerStreamAttachment && admission == ports.BrokerAdmissionExact { | |
| 501 | 550 | target, err = exactTargetFromWire(message.GetTarget()) | |
| 502 | 551 | if err != nil { | |
| 503 | 552 | return OpenStream{}, ErrInvalidMessage | |
| @@ -519,11 +568,14 @@ func openStreamFromWire(message *wire.OpenStream) (OpenStream, error) { | |||
| 519 | 568 | endpoint := message.GetEndpoint() | |
| 520 | 569 | candidate := OpenStream{ | |
| 521 | 570 | Epoch: epoch, Connection: connection, Stream: stream, | |
| 522 | - | Purpose: purpose, Local: local, Endpoint: endpoint, | |
| 571 | + | Purpose: purpose, Admission: admission, Name: message.GetName(), | |
| 572 | + | Local: local, | |
| 573 | + | Endpoint: endpoint, | |
| 523 | 574 | Registration: registration, Target: target, Env: env, Policy: policy, | |
| 524 | 575 | } | |
| 525 | 576 | request := ports.BrokerOpenStreamRequest{ | |
| 526 | - | Epoch: candidate.Epoch, Purpose: candidate.Purpose, Local: candidate.Local, | |
| 577 | + | Epoch: candidate.Epoch, Purpose: candidate.Purpose, | |
| 578 | + | Admission: candidate.Admission, Name: candidate.Name, Local: candidate.Local, | |
| 527 | 579 | Connection: candidate.Connection, Stream: candidate.Stream, | |
| 528 | 580 | Endpoint: candidate.Endpoint, Registration: candidate.Registration, | |
| 529 | 581 | Target: candidate.Target, Env: candidate.Env, Policy: candidate.Policy, | |
| @@ -780,26 +832,30 @@ func snapshotPartToWire(m SnapshotPart) (*wire.SnapshotPart, error) { | |||
| 780 | 832 | } | |
| 781 | 833 | switch part := m.Part.(type) { | |
| 782 | 834 | case SnapshotBegin: | |
| 783 | - | if part.HostCount > ports.BrokerMaxHosts || | |
| 784 | - | part.SessionCount > ports.BrokerMaxHosts*ports.BrokerMaxSessionsPerHost || | |
| 835 | + | if part.HostCount > ports.BrokerMaxDaemonsPerSnapshot || | |
| 836 | + | part.SessionCount > ports.BrokerMaxDaemonsPerSnapshot*ports.BrokerMaxSessionsPerHost || | |
| 785 | 837 | part.TombstoneCount > ports.BrokerMaxTombstones { | |
| 786 | 838 | return nil, ErrTooLarge | |
| 787 | 839 | } | |
| 840 | + | if part.LocalPresent && part.HostCount == 0 { | |
| 841 | + | return nil, ErrInvalidMessage | |
| 842 | + | } | |
| 788 | 843 | out.Part = &wire.SnapshotPart_Begin{Begin: &wire.SnapshotBegin{ | |
| 789 | 844 | HostCount: part.HostCount, SessionCount: part.SessionCount, TombstoneCount: part.TombstoneCount, | |
| 845 | + | LocalPresent: part.LocalPresent, | |
| 790 | 846 | }} | |
| 791 | 847 | case *SnapshotBegin: | |
| 792 | 848 | if part == nil { | |
| 793 | 849 | return nil, ErrInvalidMessage | |
| 794 | 850 | } | |
| 795 | 851 | return snapshotPartToWire(SnapshotPart{Epoch: m.Epoch, Connection: m.Connection, Generation: m.Generation, Revision: m.Revision, Index: m.Index, Part: *part}) | |
| 796 | - | case SnapshotHostPart: | |
| 797 | - | converted, err := snapshotHostToWire(part) | |
| 852 | + | case SnapshotDaemonPart: | |
| 853 | + | converted, err := snapshotDaemonToWire(part) | |
| 798 | 854 | if err != nil { | |
| 799 | 855 | return nil, err | |
| 800 | 856 | } | |
| 801 | - | out.Part = &wire.SnapshotPart_Host{Host: converted} | |
| 802 | - | case *SnapshotHostPart: | |
| 857 | + | out.Part = &wire.SnapshotPart_Daemon{Daemon: converted} | |
| 858 | + | case *SnapshotDaemonPart: | |
| 803 | 859 | if part == nil { | |
| 804 | 860 | return nil, ErrInvalidMessage | |
| 805 | 861 | } | |
| @@ -867,34 +923,38 @@ func snapshotPartFromWire(message *wire.SnapshotPart) (SnapshotPart, error) { | |||
| 867 | 923 | if payload.Begin == nil { | |
| 868 | 924 | return SnapshotPart{}, ErrInvalidMessage | |
| 869 | 925 | } | |
| 870 | - | if payload.Begin.GetHostCount() > ports.BrokerMaxHosts || | |
| 871 | - | payload.Begin.GetSessionCount() > ports.BrokerMaxHosts*ports.BrokerMaxSessionsPerHost || | |
| 926 | + | if payload.Begin.GetHostCount() > ports.BrokerMaxDaemonsPerSnapshot || | |
| 927 | + | payload.Begin.GetSessionCount() > ports.BrokerMaxDaemonsPerSnapshot*ports.BrokerMaxSessionsPerHost || | |
| 872 | 928 | payload.Begin.GetTombstoneCount() > ports.BrokerMaxTombstones { | |
| 873 | 929 | return SnapshotPart{}, ErrTooLarge | |
| 874 | 930 | } | |
| 931 | + | if payload.Begin.GetLocalPresent() && payload.Begin.GetHostCount() == 0 { | |
| 932 | + | return SnapshotPart{}, ErrInvalidMessage | |
| 933 | + | } | |
| 875 | 934 | part.Part = SnapshotBegin{ | |
| 876 | 935 | HostCount: payload.Begin.GetHostCount(), | |
| 877 | 936 | SessionCount: payload.Begin.GetSessionCount(), | |
| 878 | 937 | TombstoneCount: payload.Begin.GetTombstoneCount(), | |
| 938 | + | LocalPresent: payload.Begin.GetLocalPresent(), | |
| 879 | 939 | } | |
| 880 | - | case *wire.SnapshotPart_Host: | |
| 881 | - | if payload.Host == nil { | |
| 940 | + | case *wire.SnapshotPart_Daemon: | |
| 941 | + | if payload.Daemon == nil { | |
| 882 | 942 | return SnapshotPart{}, ErrInvalidMessage | |
| 883 | 943 | } | |
| 884 | - | host, index, sessionCount, err := snapshotHostFromWire(payload.Host) | |
| 944 | + | daemon, index, sessionCount, err := snapshotDaemonFromWire(payload.Daemon) | |
| 885 | 945 | if err != nil { | |
| 886 | 946 | return SnapshotPart{}, err | |
| 887 | 947 | } | |
| 888 | - | part.Part = SnapshotHostPart{HostIndex: index, Host: host, SessionCount: sessionCount} | |
| 948 | + | part.Part = SnapshotDaemonPart{HostIndex: index, Daemon: daemon, SessionCount: sessionCount} | |
| 889 | 949 | case *wire.SnapshotPart_Session: | |
| 890 | 950 | if payload.Session == nil { | |
| 891 | 951 | return SnapshotPart{}, ErrInvalidMessage | |
| 892 | 952 | } | |
| 893 | - | session, hostIndex, sessionIndex, err := snapshotSessionFromWire(payload.Session) | |
| 953 | + | session, hostIndex, sessionIndex, local, err := snapshotSessionFromWire(payload.Session) | |
| 894 | 954 | if err != nil { | |
| 895 | 955 | return SnapshotPart{}, err | |
| 896 | 956 | } | |
| 897 | - | part.Part = SnapshotSessionPart{HostIndex: hostIndex, SessionIndex: sessionIndex, Session: session} | |
| 957 | + | part.Part = SnapshotSessionPart{HostIndex: hostIndex, SessionIndex: sessionIndex, Local: local, Session: session} | |
| 898 | 958 | case *wire.SnapshotPart_Tombstone: | |
| 899 | 959 | if payload.Tombstone == nil { | |
| 900 | 960 | return SnapshotPart{}, ErrInvalidMessage | |
| @@ -922,118 +982,167 @@ func snapshotPartFromWire(message *wire.SnapshotPart) (SnapshotPart, error) { | |||
| 922 | 982 | return part, nil | |
| 923 | 983 | } | |
| 924 | 984 | ||
| 925 | - | func snapshotHostToWire(part SnapshotHostPart) (*wire.SnapshotHost, error) { | |
| 926 | - | if part.HostIndex >= ports.BrokerMaxHosts { | |
| 985 | + | // snapshotDaemonToWire converts one daemon part losslessly. It refuses a | |
| 986 | + | // malformed local/remote authority pairing, a partial identity, an | |
| 987 | + | // out-of-range enum or rank, a session inventory that could not be durable, | |
| 988 | + | // inline sessions (they travel separately), and any index or count above | |
| 989 | + | // the snapshot bounds. | |
| 990 | + | func snapshotDaemonToWire(part SnapshotDaemonPart) (*wire.SnapshotDaemon, error) { | |
| 991 | + | if part.HostIndex >= ports.BrokerMaxDaemonsPerSnapshot { | |
| 927 | 992 | return nil, ErrTooLarge | |
| 928 | 993 | } | |
| 929 | - | host := part.Host | |
| 930 | - | if err := validateSnapshotHostFields(host); err != nil { | |
| 994 | + | daemon := part.Daemon | |
| 995 | + | if err := validateSnapshotDaemonFields(daemon); err != nil { | |
| 931 | 996 | return nil, err | |
| 932 | 997 | } | |
| 933 | - | if err := host.Registration.Validate(); err != nil { | |
| 934 | - | return nil, ErrInvalidMessage | |
| 935 | - | } | |
| 936 | - | if host.Endpoint != host.Registration.Endpoint { | |
| 998 | + | if err := daemon.Validate(); err != nil { | |
| 937 | 999 | return nil, ErrInvalidMessage | |
| 938 | 1000 | } | |
| 939 | - | if err := ports.ValidateDurableHostProjection(host); err != nil { | |
| 940 | - | return nil, ErrInvalidMessage | |
| 1001 | + | if !daemon.Local { | |
| 1002 | + | if err := ports.ValidateDurableHostProjection(daemon); err != nil { | |
| 1003 | + | return nil, ErrInvalidMessage | |
| 1004 | + | } | |
| 941 | 1005 | } | |
| 942 | - | if len(host.Sessions) != 0 { | |
| 1006 | + | if len(daemon.Sessions) != 0 { | |
| 943 | 1007 | return nil, ErrInvalidMessage | |
| 944 | 1008 | } | |
| 945 | 1009 | if part.SessionCount > ports.BrokerMaxSessionsPerHost { | |
| 946 | 1010 | return nil, ErrTooLarge | |
| 947 | 1011 | } | |
| 948 | - | lastAttemptSeconds, lastAttemptNanos := brokerTimeToWire(host.LastAttempt) | |
| 949 | - | lastSuccessSeconds, lastSuccessNanos := brokerTimeToWire(host.LastSuccess) | |
| 950 | - | nextDueSeconds, nextDueNanos := brokerTimeToWire(host.NextDue) | |
| 951 | - | return &wire.SnapshotHost{ | |
| 952 | - | HostIndex: part.HostIndex, Endpoint: host.Endpoint, DisplayOrigin: host.DisplayOrigin, | |
| 953 | - | Rank: uint32(host.Rank), Registration: registrationToWire(host.Registration), | |
| 954 | - | Availability: uint32(host.Availability), Checking: host.Checking, | |
| 1012 | + | lastAttemptSeconds, lastAttemptNanos := brokerTimeToWire(daemon.LastAttempt) | |
| 1013 | + | lastSuccessSeconds, lastSuccessNanos := brokerTimeToWire(daemon.LastSuccess) | |
| 1014 | + | nextDueSeconds, nextDueNanos := brokerTimeToWire(daemon.NextDue) | |
| 1015 | + | var registration *wire.RemoteRegistration | |
| 1016 | + | if !daemon.Local { | |
| 1017 | + | registration = registrationToWire(daemon.Registration) | |
| 1018 | + | } | |
| 1019 | + | var incarnation []byte | |
| 1020 | + | if !daemon.Incarnation.IsZero() { | |
| 1021 | + | incarnation = append([]byte(nil), daemon.Incarnation[:]...) | |
| 1022 | + | } | |
| 1023 | + | return &wire.SnapshotDaemon{ | |
| 1024 | + | HostIndex: part.HostIndex, Local: daemon.Local, | |
| 1025 | + | Endpoint: daemon.Endpoint, DisplayOrigin: daemon.DisplayOrigin, | |
| 1026 | + | Rank: uint32(daemon.Rank), Registration: registration, | |
| 1027 | + | Policy: policyToWire(daemon.Policy), | |
| 1028 | + | DaemonIdentity: string(daemon.Identity), DaemonIncarnation: incarnation, | |
| 1029 | + | ProtocolVersion: uint32(daemon.ProtocolVersion), Capabilities: daemon.Capabilities, | |
| 1030 | + | Availability: uint32(daemon.Availability), Checking: daemon.Checking, | |
| 955 | 1031 | LastAttempt: &wire.BrokerTimestamp{Seconds: lastAttemptSeconds, Nanos: lastAttemptNanos}, | |
| 956 | 1032 | LastSuccess: &wire.BrokerTimestamp{Seconds: lastSuccessSeconds, Nanos: lastSuccessNanos}, | |
| 957 | 1033 | NextDue: &wire.BrokerTimestamp{Seconds: nextDueSeconds, Nanos: nextDueNanos}, | |
| 958 | - | ConsecutiveFailures: uint64(host.ConsecutiveFailures), FailureEpisode: host.FailureEpisode, | |
| 959 | - | FailureKind: uint32(host.LastFailure.Kind), | |
| 960 | - | InventoryKnown: host.InventoryKnown, SessionCount: part.SessionCount, | |
| 1034 | + | ConsecutiveFailures: uint64(daemon.ConsecutiveFailures), FailureEpisode: daemon.FailureEpisode, | |
| 1035 | + | FailureKind: uint32(daemon.LastFailure.Kind), | |
| 1036 | + | InventoryKnown: daemon.InventoryKnown, SessionCount: part.SessionCount, | |
| 961 | 1037 | }, nil | |
| 962 | 1038 | } | |
| 963 | 1039 | ||
| 964 | - | func snapshotHostFromWire(message *wire.SnapshotHost) (ports.RemoteHostSnapshot, uint32, uint32, error) { | |
| 965 | - | var host ports.RemoteHostSnapshot | |
| 1040 | + | func snapshotDaemonFromWire(message *wire.SnapshotDaemon) (ports.BrokerDaemonObservation, uint32, uint32, error) { | |
| 1041 | + | var daemon ports.BrokerDaemonObservation | |
| 966 | 1042 | if message == nil { | |
| 967 | - | return host, 0, 0, ErrInvalidMessage | |
| 1043 | + | return daemon, 0, 0, ErrInvalidMessage | |
| 968 | 1044 | } | |
| 969 | - | if message.GetHostIndex() >= ports.BrokerMaxHosts { | |
| 970 | - | return ports.RemoteHostSnapshot{}, 0, 0, ErrTooLarge | |
| 1045 | + | if message.GetHostIndex() >= ports.BrokerMaxDaemonsPerSnapshot { | |
| 1046 | + | return daemon, 0, 0, ErrTooLarge | |
| 971 | 1047 | } | |
| 1048 | + | local := message.GetLocal() | |
| 972 | 1049 | endpoint := message.GetEndpoint() | |
| 973 | - | if err := validateBrokerEndpoint(endpoint); err != nil { | |
| 974 | - | return ports.RemoteHostSnapshot{}, 0, 0, err | |
| 975 | - | } | |
| 976 | 1050 | origin := message.GetDisplayOrigin() | |
| 977 | 1051 | if err := validateBrokerOrigin(origin); err != nil { | |
| 978 | - | return ports.RemoteHostSnapshot{}, 0, 0, err | |
| 1052 | + | return daemon, 0, 0, err | |
| 979 | 1053 | } | |
| 980 | - | registration, err := registrationFromWire(message.GetRegistration()) | |
| 1054 | + | var registration domain.RemoteRegistration | |
| 1055 | + | if local { | |
| 1056 | + | if message.GetRegistration() != nil || endpoint != "" { | |
| 1057 | + | return daemon, 0, 0, ErrInvalidMessage | |
| 1058 | + | } | |
| 1059 | + | } else { | |
| 1060 | + | if err := validateBrokerEndpoint(endpoint); err != nil { | |
| 1061 | + | return daemon, 0, 0, err | |
| 1062 | + | } | |
| 1063 | + | converted, err := registrationFromWire(message.GetRegistration()) | |
| 1064 | + | if err != nil { | |
| 1065 | + | return daemon, 0, 0, ErrInvalidMessage | |
| 1066 | + | } | |
| 1067 | + | if endpoint != converted.Endpoint { | |
| 1068 | + | return daemon, 0, 0, ErrInvalidMessage | |
| 1069 | + | } | |
| 1070 | + | registration = converted | |
| 1071 | + | } | |
| 1072 | + | policy, err := policyFromWire(message.GetPolicy()) | |
| 981 | 1073 | if err != nil { | |
| 982 | - | return ports.RemoteHostSnapshot{}, 0, 0, ErrInvalidMessage | |
| 1074 | + | return daemon, 0, 0, ErrInvalidMessage | |
| 983 | 1075 | } | |
| 984 | - | if endpoint != registration.Endpoint { | |
| 985 | - | return ports.RemoteHostSnapshot{}, 0, 0, ErrInvalidMessage | |
| 1076 | + | raw := message.GetDaemonIncarnation() | |
| 1077 | + | if len(raw) != 0 && len(raw) != len(daemon.Incarnation) { | |
| 1078 | + | return daemon, 0, 0, errConvertRange | |
| 1079 | + | } | |
| 1080 | + | if len(raw) == len(daemon.Incarnation) { | |
| 1081 | + | copy(daemon.Incarnation[:], raw) | |
| 1082 | + | } | |
| 1083 | + | protocolVersion, err := brokerEnum16[uint16](message.GetProtocolVersion()) | |
| 1084 | + | if err != nil { | |
| 1085 | + | return daemon, 0, 0, ErrInvalidMessage | |
| 986 | 1086 | } | |
| 987 | 1087 | availability, err := brokerEnum8[domain.RemoteAvailability](message.GetAvailability()) | |
| 988 | 1088 | if err != nil { | |
| 989 | - | return ports.RemoteHostSnapshot{}, 0, 0, err | |
| 1089 | + | return daemon, 0, 0, ErrInvalidMessage | |
| 990 | 1090 | } | |
| 991 | 1091 | if availability < domain.RemoteAvailabilityUnknown || availability > domain.RemoteAvailabilityInvalidResponse { | |
| 992 | - | return ports.RemoteHostSnapshot{}, 0, 0, ErrInvalidMessage | |
| 1092 | + | return daemon, 0, 0, ErrInvalidMessage | |
| 993 | 1093 | } | |
| 994 | 1094 | failureKind, err := brokerEnum8[domain.RemoteFailureKind](message.GetFailureKind()) | |
| 995 | 1095 | if err != nil { | |
| 996 | - | return ports.RemoteHostSnapshot{}, 0, 0, err | |
| 1096 | + | return daemon, 0, 0, ErrInvalidMessage | |
| 997 | 1097 | } | |
| 998 | 1098 | if failureKind > domain.RemoteFailureInvalidResponse { | |
| 999 | - | return ports.RemoteHostSnapshot{}, 0, 0, ErrInvalidMessage | |
| 1099 | + | return daemon, 0, 0, ErrInvalidMessage | |
| 1000 | 1100 | } | |
| 1001 | 1101 | lastAttempt, err := brokerTimeFromWire(message.GetLastAttempt().GetSeconds(), message.GetLastAttempt().GetNanos()) | |
| 1002 | 1102 | if err != nil { | |
| 1003 | - | return ports.RemoteHostSnapshot{}, 0, 0, ErrInvalidMessage | |
| 1103 | + | return daemon, 0, 0, ErrInvalidMessage | |
| 1004 | 1104 | } | |
| 1005 | 1105 | lastSuccess, err := brokerTimeFromWire(message.GetLastSuccess().GetSeconds(), message.GetLastSuccess().GetNanos()) | |
| 1006 | 1106 | if err != nil { | |
| 1007 | - | return ports.RemoteHostSnapshot{}, 0, 0, ErrInvalidMessage | |
| 1107 | + | return daemon, 0, 0, ErrInvalidMessage | |
| 1008 | 1108 | } | |
| 1009 | 1109 | nextDue, err := brokerTimeFromWire(message.GetNextDue().GetSeconds(), message.GetNextDue().GetNanos()) | |
| 1010 | 1110 | if err != nil { | |
| 1011 | - | return ports.RemoteHostSnapshot{}, 0, 0, ErrInvalidMessage | |
| 1111 | + | return daemon, 0, 0, ErrInvalidMessage | |
| 1012 | 1112 | } | |
| 1013 | 1113 | if message.GetConsecutiveFailures() > math.MaxUint32 { | |
| 1014 | - | return ports.RemoteHostSnapshot{}, 0, 0, errConvertRange | |
| 1114 | + | return daemon, 0, 0, errConvertRange | |
| 1015 | 1115 | } | |
| 1016 | - | sessionCount := message.GetSessionCount() | |
| 1017 | - | if sessionCount > ports.BrokerMaxSessionsPerHost { | |
| 1018 | - | return ports.RemoteHostSnapshot{}, 0, 0, ErrTooLarge | |
| 1116 | + | if message.GetSessionCount() > ports.BrokerMaxSessionsPerHost { | |
| 1117 | + | return daemon, 0, 0, ErrTooLarge | |
| 1019 | 1118 | } | |
| 1020 | - | host = ports.RemoteHostSnapshot{ | |
| 1021 | - | Endpoint: endpoint, DisplayOrigin: origin, Rank: int(message.GetRank()), | |
| 1022 | - | Registration: registration, Availability: availability, Checking: message.GetChecking(), | |
| 1119 | + | daemon = ports.BrokerDaemonObservation{ | |
| 1120 | + | Local: local, Endpoint: endpoint, DisplayOrigin: origin, Rank: int(message.GetRank()), | |
| 1121 | + | Registration: registration, Policy: policy, | |
| 1122 | + | Identity: ports.BrokerDaemonIdentity(message.GetDaemonIdentity()), Incarnation: daemon.Incarnation, | |
| 1123 | + | ProtocolVersion: protocolVersion, Capabilities: message.GetCapabilities(), | |
| 1124 | + | Availability: availability, Checking: message.GetChecking(), | |
| 1023 | 1125 | LastAttempt: lastAttempt, LastSuccess: lastSuccess, NextDue: nextDue, | |
| 1024 | 1126 | ConsecutiveFailures: uint(message.GetConsecutiveFailures()), FailureEpisode: message.GetFailureEpisode(), | |
| 1025 | 1127 | LastFailure: domain.RemoteFailure{Kind: failureKind}, | |
| 1026 | 1128 | InventoryKnown: message.GetInventoryKnown(), | |
| 1027 | 1129 | Sessions: []catalogue.RemoteCatalogSession{}, | |
| 1028 | 1130 | } | |
| 1029 | - | if err := validateSnapshotHostFields(host); err != nil { | |
| 1030 | - | return ports.RemoteHostSnapshot{}, 0, 0, err | |
| 1131 | + | if err := validateSnapshotDaemonFields(daemon); err != nil { | |
| 1132 | + | return daemon, 0, 0, err | |
| 1031 | 1133 | } | |
| 1032 | - | return host, message.GetHostIndex(), message.GetSessionCount(), nil | |
| 1134 | + | if err := daemon.Validate(); err != nil { | |
| 1135 | + | return daemon, 0, 0, ErrInvalidMessage | |
| 1136 | + | } | |
| 1137 | + | return daemon, message.GetHostIndex(), message.GetSessionCount(), nil | |
| 1033 | 1138 | } | |
| 1034 | 1139 | ||
| 1035 | 1140 | func snapshotSessionToWire(part SnapshotSessionPart) (*wire.SnapshotSession, error) { | |
| 1036 | - | if part.HostIndex >= ports.BrokerMaxHosts { | |
| 1141 | + | if part.Local { | |
| 1142 | + | if part.HostIndex != 0 { | |
| 1143 | + | return nil, ErrInvalidMessage | |
| 1144 | + | } | |
| 1145 | + | } else if part.HostIndex >= ports.BrokerMaxDaemonsPerSnapshot { | |
| 1037 | 1146 | return nil, ErrTooLarge | |
| 1038 | 1147 | } | |
| 1039 | 1148 | if part.SessionIndex >= ports.BrokerMaxSessionsPerHost { | |
| @@ -1044,26 +1153,32 @@ func snapshotSessionToWire(part SnapshotSessionPart) (*wire.SnapshotSession, err | |||
| 1044 | 1153 | } | |
| 1045 | 1154 | return &wire.SnapshotSession{ | |
| 1046 | 1155 | HostIndex: part.HostIndex, SessionIndex: part.SessionIndex, | |
| 1156 | + | Local: part.Local, | |
| 1047 | 1157 | Session: catalogSessionToWire(part.Session), | |
| 1048 | 1158 | }, nil | |
| 1049 | 1159 | } | |
| 1050 | 1160 | ||
| 1051 | - | func snapshotSessionFromWire(message *wire.SnapshotSession) (catalogue.RemoteCatalogSession, uint32, uint32, error) { | |
| 1161 | + | func snapshotSessionFromWire(message *wire.SnapshotSession) (catalogue.RemoteCatalogSession, uint32, uint32, bool, error) { | |
| 1052 | 1162 | var session catalogue.RemoteCatalogSession | |
| 1053 | 1163 | if message == nil { | |
| 1054 | - | return session, 0, 0, ErrInvalidMessage | |
| 1164 | + | return session, 0, 0, false, ErrInvalidMessage | |
| 1055 | 1165 | } | |
| 1056 | - | if message.GetHostIndex() >= ports.BrokerMaxHosts { | |
| 1057 | - | return catalogue.RemoteCatalogSession{}, 0, 0, ErrTooLarge | |
| 1166 | + | local := message.GetLocal() | |
| 1167 | + | if local { | |
| 1168 | + | if message.GetHostIndex() != 0 { | |
| 1169 | + | return session, 0, 0, false, ErrInvalidMessage | |
| 1170 | + | } | |
| 1171 | + | } else if message.GetHostIndex() >= ports.BrokerMaxDaemonsPerSnapshot { | |
| 1172 | + | return session, 0, 0, false, ErrTooLarge | |
| 1058 | 1173 | } | |
| 1059 | 1174 | if message.GetSessionIndex() >= ports.BrokerMaxSessionsPerHost { | |
| 1060 | - | return catalogue.RemoteCatalogSession{}, 0, 0, ErrTooLarge | |
| 1175 | + | return session, 0, 0, false, ErrTooLarge | |
| 1061 | 1176 | } | |
| 1062 | 1177 | converted, err := catalogSessionFromWire(message.GetSession()) | |
| 1063 | 1178 | if err != nil { | |
| 1064 | - | return catalogue.RemoteCatalogSession{}, 0, 0, err | |
| 1179 | + | return session, 0, 0, false, err | |
| 1065 | 1180 | } | |
| 1066 | - | return converted, message.GetHostIndex(), message.GetSessionIndex(), nil | |
| 1181 | + | return converted, message.GetHostIndex(), message.GetSessionIndex(), local, nil | |
| 1067 | 1182 | } | |
| 1068 | 1183 | ||
| 1069 | 1184 | func operationResultToWire(m OperationResult) (*wire.OperationResult, error) { | |
M internal/adapters/brokerwire/codec_test.go +205 -47
| @@ -74,19 +74,39 @@ func testCatalogSession() catalogue.RemoteCatalogSession { | |||
| 74 | 74 | } | |
| 75 | 75 | } | |
| 76 | 76 | ||
| 77 | - | func testHostSnapshot() ports.RemoteHostSnapshot { | |
| 78 | - | return ports.RemoteHostSnapshot{ | |
| 79 | - | Endpoint: "dev@host:22", | |
| 80 | - | DisplayOrigin: "dev@host", | |
| 81 | - | Rank: 2, | |
| 82 | - | Registration: testRegistration(), | |
| 77 | + | // testDaemonObservation builds one observed, catalogue-valid remote daemon | |
| 78 | + | // projection. Sessions are empty: they travel as separate parts. | |
| 79 | + | func testDaemonObservation() ports.BrokerDaemonObservation { | |
| 80 | + | return ports.BrokerDaemonObservation{ | |
| 81 | + | Endpoint: "dev@host:22", | |
| 82 | + | DisplayOrigin: "dev@host", | |
| 83 | + | Rank: 2, | |
| 84 | + | Registration: testRegistration(), | |
| 85 | + | Policy: testPolicy(), | |
| 86 | + | Identity: ports.BrokerDaemonIdentity("authed-daemon"), | |
| 87 | + | Incarnation: ports.BrokerDaemonIncarnation{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, | |
| 88 | + | ProtocolVersion: protocol.Version, | |
| 89 | + | Capabilities: protocol.CapabilityResume | protocol.CapabilityUDP, | |
| 90 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 91 | + | Checking: true, | |
| 92 | + | LastAttempt: time.Unix(1700000000, 1).UTC(), | |
| 93 | + | LastSuccess: time.Unix(1700000001, 2).UTC(), | |
| 94 | + | NextDue: time.Unix(1700000002, 3).UTC(), | |
| 95 | + | FailureEpisode: 6, | |
| 96 | + | LastFailure: domain.RemoteFailure{Kind: domain.RemoteFailureTransport}, | |
| 97 | + | InventoryKnown: true, | |
| 98 | + | Sessions: []catalogue.RemoteCatalogSession{}, | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // testLocalDaemonObservation builds the local daemon projection: no endpoint, | |
| 103 | + | // no registration, always a valid policy, and an explicit availability. | |
| 104 | + | func testLocalDaemonObservation() ports.BrokerDaemonObservation { | |
| 105 | + | return ports.BrokerDaemonObservation{ | |
| 106 | + | Local: true, | |
| 107 | + | DisplayOrigin: "local", | |
| 108 | + | Policy: testPolicy(), | |
| 83 | 109 | Availability: domain.RemoteAvailabilityReachable, | |
| 84 | - | Checking: true, | |
| 85 | - | LastAttempt: time.Unix(1700000000, 1).UTC(), | |
| 86 | - | LastSuccess: time.Unix(1700000001, 2).UTC(), | |
| 87 | - | NextDue: time.Unix(1700000002, 3).UTC(), | |
| 88 | - | FailureEpisode: 6, | |
| 89 | - | LastFailure: domain.RemoteFailure{Kind: domain.RemoteFailureTransport}, | |
| 90 | 110 | InventoryKnown: true, | |
| 91 | 111 | Sessions: []catalogue.RemoteCatalogSession{}, | |
| 92 | 112 | } | |
| @@ -334,7 +354,7 @@ func TestBrokerClientVariants(t *testing.T) { | |||
| 334 | 354 | {"add_host", AddHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22"}, 105}, | |
| 335 | 355 | {"remove_host", RemoveHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22"}, 106}, | |
| 336 | 356 | {"reconcile", Reconcile{Epoch: 7, Connection: connection, Registration: testRegistration()}, 107}, | |
| 337 | - | {"open_stream", OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Env: []string{"TERM=xterm"}, Policy: testPolicy()}, 108}, | |
| 357 | + | {"open_stream", OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Env: []string{"TERM=xterm"}, Policy: testPolicy()}, 108}, | |
| 338 | 358 | {"client_stream_data", ClientStreamData{Epoch: 7, Connection: connection, Stream: 3, Data: []byte("frame")}, 109}, | |
| 339 | 359 | {"close_stream", CloseStream{Epoch: 7, Connection: connection, Stream: 3}, 110}, | |
| 340 | 360 | } | |
| @@ -353,6 +373,141 @@ func TestBrokerClientVariants(t *testing.T) { | |||
| 353 | 373 | } | |
| 354 | 374 | } | |
| 355 | 375 | ||
| 376 | + | // TestBrokerOpenStreamAdmissionVariants pins the attachment-admission | |
| 377 | + | // contract: exact, create-named, and create-ephemeral round-trip, control | |
| 378 | + | // carries none, and wrong admission/purpose/name/target combinations are | |
| 379 | + | // refused exactly like ports.BrokerOpenStreamRequest.Validate. | |
| 380 | + | func TestBrokerOpenStreamAdmissionVariants(t *testing.T) { | |
| 381 | + | connection := testConnectionID(0x11) | |
| 382 | + | base := func() OpenStream { | |
| 383 | + | return OpenStream{ | |
| 384 | + | Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, | |
| 385 | + | Endpoint: "dev@host:22", Registration: testRegistration(), Policy: testPolicy(), | |
| 386 | + | } | |
| 387 | + | } | |
| 388 | + | roundTrips := []struct { | |
| 389 | + | name string | |
| 390 | + | message OpenStream | |
| 391 | + | }{ | |
| 392 | + | {"exact", func() OpenStream { | |
| 393 | + | message := base() | |
| 394 | + | message.Admission = ports.BrokerAdmissionExact | |
| 395 | + | message.Target = testTarget() | |
| 396 | + | return message | |
| 397 | + | }()}, | |
| 398 | + | {"create named", func() OpenStream { | |
| 399 | + | message := base() | |
| 400 | + | message.Admission = ports.BrokerAdmissionCreateNamed | |
| 401 | + | message.Name = "work" | |
| 402 | + | return message | |
| 403 | + | }()}, | |
| 404 | + | {"create ephemeral", func() OpenStream { | |
| 405 | + | message := base() | |
| 406 | + | message.Admission = ports.BrokerAdmissionCreateEphemeral | |
| 407 | + | return message | |
| 408 | + | }()}, | |
| 409 | + | {"control carries none", OpenStream{ | |
| 410 | + | Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamControl, | |
| 411 | + | Local: true, Policy: testPolicy(), | |
| 412 | + | }}, | |
| 413 | + | } | |
| 414 | + | for _, tc := range roundTrips { | |
| 415 | + | t.Run(tc.name, func(t *testing.T) { | |
| 416 | + | raw := mustEncodeClient(t, tc.message) | |
| 417 | + | decoded, err := DecodeClient(raw, testEnvelopeCeiling, testChunkCeiling) | |
| 418 | + | require.NoError(t, err) | |
| 419 | + | require.Equal(t, tc.message, decoded) | |
| 420 | + | }) | |
| 421 | + | } | |
| 422 | + | ||
| 423 | + | t.Run("wrong combinations refused", func(t *testing.T) { | |
| 424 | + | exact := func() OpenStream { | |
| 425 | + | message := base() | |
| 426 | + | message.Admission = ports.BrokerAdmissionExact | |
| 427 | + | message.Target = testTarget() | |
| 428 | + | return message | |
| 429 | + | } | |
| 430 | + | cases := map[string]OpenStream{ | |
| 431 | + | "attachment without admission": func() OpenStream { m := base(); return m }(), | |
| 432 | + | "exact with name": func() OpenStream { | |
| 433 | + | m := exact() | |
| 434 | + | m.Name = "work" | |
| 435 | + | return m | |
| 436 | + | }(), | |
| 437 | + | "named with target": func() OpenStream { | |
| 438 | + | m := base() | |
| 439 | + | m.Admission = ports.BrokerAdmissionCreateNamed | |
| 440 | + | m.Name = "work" | |
| 441 | + | m.Target = testTarget() | |
| 442 | + | return m | |
| 443 | + | }(), | |
| 444 | + | "named without name": func() OpenStream { | |
| 445 | + | m := base() | |
| 446 | + | m.Admission = ports.BrokerAdmissionCreateNamed | |
| 447 | + | return m | |
| 448 | + | }(), | |
| 449 | + | "ephemeral with name": func() OpenStream { | |
| 450 | + | m := base() | |
| 451 | + | m.Admission = ports.BrokerAdmissionCreateEphemeral | |
| 452 | + | m.Name = "work" | |
| 453 | + | return m | |
| 454 | + | }(), | |
| 455 | + | "control with admission": OpenStream{ | |
| 456 | + | Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamControl, | |
| 457 | + | Local: true, Policy: testPolicy(), Admission: ports.BrokerAdmissionExact, | |
| 458 | + | }, | |
| 459 | + | } | |
| 460 | + | for name, message := range cases { | |
| 461 | + | t.Run(name, func(t *testing.T) { | |
| 462 | + | _, err := EncodeClient(message, testEnvelopeCeiling, testChunkCeiling) | |
| 463 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 464 | + | }) | |
| 465 | + | } | |
| 466 | + | }) | |
| 467 | + | ||
| 468 | + | t.Run("unknown admission code refused on decode", func(t *testing.T) { | |
| 469 | + | message := base() | |
| 470 | + | message.Admission = ports.BrokerAdmissionExact | |
| 471 | + | message.Target = testTarget() | |
| 472 | + | raw := mustEncodeClient(t, message) | |
| 473 | + | envelope := &wire.BrokerClientEnvelope{} | |
| 474 | + | require.NoError(t, wire.ScanEnvelope(envelope, raw)) | |
| 475 | + | require.NoError(t, proto.Unmarshal(raw, envelope)) | |
| 476 | + | envelope.GetOpenStream().Admission = 9 | |
| 477 | + | mutated, err := proto.Marshal(envelope) | |
| 478 | + | require.NoError(t, err) | |
| 479 | + | _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling) | |
| 480 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 481 | + | }) | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | // TestBrokerSnapshotDaemonByteForByte proves the daemon and session parts are | |
| 485 | + | // byte-for-byte stable across an encode/decode/re-encode cycle, including an | |
| 486 | + | // observed identity/incarnation/version/capability set, the local daemon, and a | |
| 487 | + | // locally-bound session. | |
| 488 | + | func TestBrokerSnapshotDaemonByteForByte(t *testing.T) { | |
| 489 | + | connection := testConnectionID(0x11) | |
| 490 | + | messages := []struct { | |
| 491 | + | name string | |
| 492 | + | message SnapshotPart | |
| 493 | + | }{ | |
| 494 | + | {"observed remote daemon", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 1, Part: SnapshotDaemonPart{HostIndex: 1, Daemon: testDaemonObservation(), SessionCount: 1}}}, | |
| 495 | + | {"local daemon", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 0, Part: SnapshotDaemonPart{HostIndex: 0, Daemon: testLocalDaemonObservation(), SessionCount: 1}}}, | |
| 496 | + | {"local session", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 2, Part: SnapshotSessionPart{HostIndex: 0, SessionIndex: 0, Local: true, Session: testCatalogSession()}}}, | |
| 497 | + | } | |
| 498 | + | for _, tc := range messages { | |
| 499 | + | t.Run(tc.name, func(t *testing.T) { | |
| 500 | + | raw := mustEncodeServer(t, tc.message) | |
| 501 | + | decoded, err := DecodeServer(raw, testEnvelopeCeiling, testChunkCeiling) | |
| 502 | + | require.NoError(t, err) | |
| 503 | + | require.Equal(t, tc.message, decoded) | |
| 504 | + | again, err := EncodeServer(decoded, testEnvelopeCeiling, testChunkCeiling) | |
| 505 | + | require.NoError(t, err) | |
| 506 | + | require.Equal(t, raw, again, "re-encode must be byte-for-byte identical") | |
| 507 | + | }) | |
| 508 | + | } | |
| 509 | + | } | |
| 510 | + | ||
| 356 | 511 | // TestBrokerServerVariants inventories every server variant tag. | |
| 357 | 512 | func TestBrokerServerVariants(t *testing.T) { | |
| 358 | 513 | connection := testConnectionID(0x11) | |
| @@ -363,11 +518,13 @@ func TestBrokerServerVariants(t *testing.T) { | |||
| 363 | 518 | tag protowire.Number | |
| 364 | 519 | }{ | |
| 365 | 520 | {"registered", Registered{Epoch: 7, Connection: connection}, 201}, | |
| 366 | - | {"snapshot_begin", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 0, Part: SnapshotBegin{HostCount: 1, SessionCount: 2, TombstoneCount: 0}}, 202}, | |
| 367 | - | {"snapshot_host", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 1, Part: SnapshotHostPart{HostIndex: 0, Host: testHostSnapshot(), SessionCount: 1}}, 202}, | |
| 368 | - | {"snapshot_session", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 2, Part: SnapshotSessionPart{HostIndex: 0, SessionIndex: 0, Session: testCatalogSession()}}, 202}, | |
| 369 | - | {"snapshot_tombstone", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 3, Part: SnapshotTombstonePart{TombstoneIndex: 0, Registration: testRegistration(), RetiredRevision: 4}}, 202}, | |
| 370 | - | {"snapshot_end", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 4, Part: SnapshotEnd{}}, 202}, | |
| 521 | + | {"snapshot_begin", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 0, Part: SnapshotBegin{HostCount: 2, SessionCount: 2, TombstoneCount: 0, LocalPresent: true}}, 202}, | |
| 522 | + | {"snapshot_daemon", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 1, Part: SnapshotDaemonPart{HostIndex: 0, Daemon: testLocalDaemonObservation(), SessionCount: 0}}, 202}, | |
| 523 | + | {"snapshot_daemon_remote", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 2, Part: SnapshotDaemonPart{HostIndex: 1, Daemon: testDaemonObservation(), SessionCount: 1}}, 202}, | |
| 524 | + | {"snapshot_session", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 3, Part: SnapshotSessionPart{HostIndex: 1, SessionIndex: 0, Session: testCatalogSession()}}, 202}, | |
| 525 | + | {"snapshot_session_local", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 4, Part: SnapshotSessionPart{HostIndex: 0, SessionIndex: 0, Local: true, Session: testCatalogSession()}}, 202}, | |
| 526 | + | {"snapshot_tombstone", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 5, Part: SnapshotTombstonePart{TombstoneIndex: 0, Registration: testRegistration(), RetiredRevision: 4}}, 202}, | |
| 527 | + | {"snapshot_end", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 6, Part: SnapshotEnd{}}, 202}, | |
| 371 | 528 | {"operation_result", OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeFailed, Removed: true, Error: testErrorDetail(), HasError: true}, 203}, | |
| 372 | 529 | {"stream_opened", StreamOpened{Epoch: 7, Connection: connection, Stream: 3}, 204}, | |
| 373 | 530 | {"server_stream_data", ServerStreamData{Epoch: 7, Connection: connection, Stream: 3, Data: []byte("frame")}, 205}, | |
| @@ -445,15 +602,16 @@ func TestBrokerTabIndexBound(t *testing.T) { | |||
| 445 | 602 | require.ErrorIs(t, err, errConvertRange) | |
| 446 | 603 | } | |
| 447 | 604 | ||
| 448 | - | // mutateHostIndex re-encodes one host snapshot part envelope with its host | |
| 449 | - | // index overwritten, so decode can be fed a value encode would never emit. | |
| 450 | - | func mutateHostIndex(t *testing.T, part SnapshotPart, hostIndex uint32) []byte { | |
| 605 | + | // mutateDaemonIndex re-encodes one daemon snapshot part envelope with its | |
| 606 | + | // daemon index overwritten, so decode can be fed a value encode would never | |
| 607 | + | // emit. | |
| 608 | + | func mutateDaemonIndex(t *testing.T, part SnapshotPart, hostIndex uint32) []byte { | |
| 451 | 609 | t.Helper() | |
| 452 | 610 | raw := mustEncodeServer(t, part) | |
| 453 | 611 | envelope := &wire.BrokerServerEnvelope{} | |
| 454 | 612 | require.NoError(t, wire.ScanEnvelope(envelope, raw)) | |
| 455 | 613 | require.NoError(t, proto.Unmarshal(raw, envelope)) | |
| 456 | - | envelope.GetSnapshotPart().GetHost().HostIndex = hostIndex | |
| 614 | + | envelope.GetSnapshotPart().GetDaemon().HostIndex = hostIndex | |
| 457 | 615 | mutated, err := proto.Marshal(envelope) | |
| 458 | 616 | require.NoError(t, err) | |
| 459 | 617 | return mutated | |
| @@ -476,57 +634,57 @@ func mutateSessionIndexes(t *testing.T, part SnapshotPart, hostIndex, sessionInd | |||
| 476 | 634 | ||
| 477 | 635 | // TestBrokerSnapshotIndexBounds proves snapshot part indexes are strict | |
| 478 | 636 | // ranges: max-1 is accepted on both encode and decode, and max is refused as a | |
| 479 | - | // range refusal on both paths. Host indexes are bounded by BrokerMaxHosts and | |
| 480 | - | // session indexes by BrokerMaxSessionsPerHost. | |
| 637 | + | // range refusal on both paths. Daemon indexes are bounded by | |
| 638 | + | // BrokerMaxDaemonsPerSnapshot and session indexes by BrokerMaxSessionsPerHost. | |
| 481 | 639 | func TestBrokerSnapshotIndexBounds(t *testing.T) { | |
| 482 | 640 | connection := testConnectionID(0x11) | |
| 483 | - | hostPart := func(hostIndex uint32) SnapshotPart { | |
| 484 | - | return SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 1, Part: SnapshotHostPart{HostIndex: hostIndex, Host: testHostSnapshot()}} | |
| 641 | + | daemonPart := func(hostIndex uint32) SnapshotPart { | |
| 642 | + | return SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 1, Part: SnapshotDaemonPart{HostIndex: hostIndex, Daemon: testDaemonObservation()}} | |
| 485 | 643 | } | |
| 486 | 644 | sessionPart := func(hostIndex, sessionIndex uint32) SnapshotPart { | |
| 487 | 645 | return SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 2, Part: SnapshotSessionPart{HostIndex: hostIndex, SessionIndex: sessionIndex, Session: testCatalogSession()}} | |
| 488 | 646 | } | |
| 489 | 647 | ||
| 490 | - | t.Run("host part at max-1 accepted", func(t *testing.T) { | |
| 491 | - | message := hostPart(ports.BrokerMaxHosts - 1) | |
| 648 | + | t.Run("daemon part at max-1 accepted", func(t *testing.T) { | |
| 649 | + | message := daemonPart(ports.BrokerMaxDaemonsPerSnapshot - 1) | |
| 492 | 650 | raw := mustEncodeServer(t, message) | |
| 493 | 651 | decoded, err := DecodeServer(raw, testEnvelopeCeiling, testChunkCeiling) | |
| 494 | 652 | require.NoError(t, err) | |
| 495 | 653 | require.Equal(t, message, decoded) | |
| 496 | - | decoded, err = DecodeServer(mutateHostIndex(t, message, ports.BrokerMaxHosts-1), testEnvelopeCeiling, testChunkCeiling) | |
| 654 | + | decoded, err = DecodeServer(mutateDaemonIndex(t, message, ports.BrokerMaxDaemonsPerSnapshot-1), testEnvelopeCeiling, testChunkCeiling) | |
| 497 | 655 | require.NoError(t, err) | |
| 498 | 656 | require.Equal(t, message, decoded) | |
| 499 | 657 | }) | |
| 500 | 658 | ||
| 501 | - | t.Run("host part at max refused", func(t *testing.T) { | |
| 502 | - | _, err := EncodeServer(hostPart(ports.BrokerMaxHosts), testEnvelopeCeiling, testChunkCeiling) | |
| 659 | + | t.Run("daemon part at max refused", func(t *testing.T) { | |
| 660 | + | _, err := EncodeServer(daemonPart(ports.BrokerMaxDaemonsPerSnapshot), testEnvelopeCeiling, testChunkCeiling) | |
| 503 | 661 | require.ErrorIs(t, err, ErrTooLarge) | |
| 504 | - | _, err = DecodeServer(mutateHostIndex(t, hostPart(0), ports.BrokerMaxHosts), testEnvelopeCeiling, testChunkCeiling) | |
| 662 | + | _, err = DecodeServer(mutateDaemonIndex(t, daemonPart(0), ports.BrokerMaxDaemonsPerSnapshot), testEnvelopeCeiling, testChunkCeiling) | |
| 505 | 663 | require.ErrorIs(t, err, ErrTooLarge) | |
| 506 | 664 | }) | |
| 507 | 665 | ||
| 508 | - | t.Run("host part at uint32 maximum refused", func(t *testing.T) { | |
| 509 | - | _, err := EncodeServer(hostPart(^uint32(0)), testEnvelopeCeiling, testChunkCeiling) | |
| 666 | + | t.Run("daemon part at uint32 maximum refused", func(t *testing.T) { | |
| 667 | + | _, err := EncodeServer(daemonPart(^uint32(0)), testEnvelopeCeiling, testChunkCeiling) | |
| 510 | 668 | require.ErrorIs(t, err, ErrTooLarge) | |
| 511 | - | _, err = DecodeServer(mutateHostIndex(t, hostPart(0), ^uint32(0)), testEnvelopeCeiling, testChunkCeiling) | |
| 669 | + | _, err = DecodeServer(mutateDaemonIndex(t, daemonPart(0), ^uint32(0)), testEnvelopeCeiling, testChunkCeiling) | |
| 512 | 670 | require.ErrorIs(t, err, ErrTooLarge) | |
| 513 | 671 | }) | |
| 514 | 672 | ||
| 515 | 673 | t.Run("session part at max-1 accepted", func(t *testing.T) { | |
| 516 | - | message := sessionPart(ports.BrokerMaxHosts-1, ports.BrokerMaxSessionsPerHost-1) | |
| 674 | + | message := sessionPart(ports.BrokerMaxDaemonsPerSnapshot-1, ports.BrokerMaxSessionsPerHost-1) | |
| 517 | 675 | raw := mustEncodeServer(t, message) | |
| 518 | 676 | decoded, err := DecodeServer(raw, testEnvelopeCeiling, testChunkCeiling) | |
| 519 | 677 | require.NoError(t, err) | |
| 520 | 678 | require.Equal(t, message, decoded) | |
| 521 | - | decoded, err = DecodeServer(mutateSessionIndexes(t, message, ports.BrokerMaxHosts-1, ports.BrokerMaxSessionsPerHost-1), testEnvelopeCeiling, testChunkCeiling) | |
| 679 | + | decoded, err = DecodeServer(mutateSessionIndexes(t, message, ports.BrokerMaxDaemonsPerSnapshot-1, ports.BrokerMaxSessionsPerHost-1), testEnvelopeCeiling, testChunkCeiling) | |
| 522 | 680 | require.NoError(t, err) | |
| 523 | 681 | require.Equal(t, message, decoded) | |
| 524 | 682 | }) | |
| 525 | 683 | ||
| 526 | 684 | t.Run("session part host index at max refused", func(t *testing.T) { | |
| 527 | - | _, err := EncodeServer(sessionPart(ports.BrokerMaxHosts, 0), testEnvelopeCeiling, testChunkCeiling) | |
| 685 | + | _, err := EncodeServer(sessionPart(ports.BrokerMaxDaemonsPerSnapshot, 0), testEnvelopeCeiling, testChunkCeiling) | |
| 528 | 686 | require.ErrorIs(t, err, ErrTooLarge) | |
| 529 | - | _, err = DecodeServer(mutateSessionIndexes(t, sessionPart(0, 0), ports.BrokerMaxHosts, 0), testEnvelopeCeiling, testChunkCeiling) | |
| 687 | + | _, err = DecodeServer(mutateSessionIndexes(t, sessionPart(0, 0), ports.BrokerMaxDaemonsPerSnapshot, 0), testEnvelopeCeiling, testChunkCeiling) | |
| 530 | 688 | require.ErrorIs(t, err, ErrTooLarge) | |
| 531 | 689 | }) | |
| 532 | 690 | ||
| @@ -591,10 +749,10 @@ func TestBrokerBounds(t *testing.T) { | |||
| 591 | 749 | for i := range many { | |
| 592 | 750 | many[i] = "A=B" | |
| 593 | 751 | } | |
| 594 | - | _, err := EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Env: many, Policy: testPolicy()}, testEnvelopeCeiling, testChunkCeiling) | |
| 752 | + | _, err := EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Env: many, Policy: testPolicy()}, testEnvelopeCeiling, testChunkCeiling) | |
| 595 | 753 | require.ErrorIs(t, err, ErrTooLarge) | |
| 596 | 754 | oversize := []string{"K=" + strings.Repeat("x", ports.BrokerMaxEnvEntryBytes)} | |
| 597 | - | _, err = EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Env: oversize, Policy: testPolicy()}, testEnvelopeCeiling, testChunkCeiling) | |
| 755 | + | _, err = EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Env: oversize, Policy: testPolicy()}, testEnvelopeCeiling, testChunkCeiling) | |
| 598 | 756 | require.ErrorIs(t, err, ErrTooLarge) | |
| 599 | 757 | }) | |
| 600 | 758 | t.Run("error text", func(t *testing.T) { | |
| @@ -604,7 +762,7 @@ func TestBrokerBounds(t *testing.T) { | |||
| 604 | 762 | require.ErrorIs(t, err, ErrTooLarge) | |
| 605 | 763 | }) | |
| 606 | 764 | t.Run("snapshot counts", func(t *testing.T) { | |
| 607 | - | _, err := EncodeServer(SnapshotPart{Epoch: 7, Connection: connection, Generation: 1, Revision: 1, Index: 0, Part: SnapshotBegin{HostCount: ports.BrokerMaxHosts + 1}}, testEnvelopeCeiling, testChunkCeiling) | |
| 765 | + | _, err := EncodeServer(SnapshotPart{Epoch: 7, Connection: connection, Generation: 1, Revision: 1, Index: 0, Part: SnapshotBegin{HostCount: ports.BrokerMaxDaemonsPerSnapshot + 1}}, testEnvelopeCeiling, testChunkCeiling) | |
| 608 | 766 | require.ErrorIs(t, err, ErrTooLarge) | |
| 609 | 767 | }) | |
| 610 | 768 | t.Run("control env carries no attachment state", func(t *testing.T) { | |
| @@ -693,7 +851,7 @@ func TestBrokerValidationNegatives(t *testing.T) { | |||
| 693 | 851 | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 694 | 852 | }) | |
| 695 | 853 | t.Run("taxonomy switches", func(t *testing.T) { | |
| 696 | - | raw := mustEncodeClient(t, OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: testPolicy()}) | |
| 854 | + | raw := mustEncodeClient(t, OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: testPolicy()}) | |
| 697 | 855 | envelope := &wire.BrokerClientEnvelope{} | |
| 698 | 856 | require.NoError(t, wire.ScanEnvelope(envelope, raw)) | |
| 699 | 857 | require.NoError(t, proto.Unmarshal(raw, envelope)) | |
| @@ -725,11 +883,11 @@ func TestBrokerValidationNegatives(t *testing.T) { | |||
| 725 | 883 | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 726 | 884 | }) | |
| 727 | 885 | t.Run("env without equals refused", func(t *testing.T) { | |
| 728 | - | _, err := EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Env: []string{"NOEQUALS"}, Policy: testPolicy()}, testEnvelopeCeiling, testChunkCeiling) | |
| 886 | + | _, err := EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Env: []string{"NOEQUALS"}, Policy: testPolicy()}, testEnvelopeCeiling, testChunkCeiling) | |
| 729 | 887 | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 730 | 888 | }) | |
| 731 | 889 | t.Run("invalid policy refused", func(t *testing.T) { | |
| 732 | - | _, err := EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: ports.BrokerPolicy{}}, testEnvelopeCeiling, testChunkCeiling) | |
| 890 | + | _, err := EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: ports.BrokerPolicy{}}, testEnvelopeCeiling, testChunkCeiling) | |
| 733 | 891 | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 734 | 892 | }) | |
| 735 | 893 | t.Run("snapshot revision zero refused", func(t *testing.T) { | |
| @@ -741,7 +899,7 @@ func TestBrokerValidationNegatives(t *testing.T) { | |||
| 741 | 899 | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 742 | 900 | }) | |
| 743 | 901 | t.Run("local open with registration refused", func(t *testing.T) { | |
| 744 | - | _, err := EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Local: true, Registration: testRegistration(), Target: testTarget(), Policy: testPolicy()}, testEnvelopeCeiling, testChunkCeiling) | |
| 902 | + | _, err := EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Local: true, Registration: testRegistration(), Target: testTarget(), Policy: testPolicy()}, testEnvelopeCeiling, testChunkCeiling) | |
| 745 | 903 | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 746 | 904 | }) | |
| 747 | 905 | t.Run("bad shutdown reason refused", func(t *testing.T) { | |
| @@ -845,7 +1003,7 @@ func fuzzClientSeeds(t testing.TB) [][]byte { | |||
| 845 | 1003 | for _, message := range []ClientMessage{ | |
| 846 | 1004 | Register{}, | |
| 847 | 1005 | Subscribe{Epoch: 7, Connection: testConnectionID(0x11), Generation: 1}, | |
| 848 | - | OpenStream{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3, Purpose: ports.BrokerStreamAttachment, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: testPolicy()}, | |
| 1006 | + | OpenStream{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: testPolicy()}, | |
| 849 | 1007 | ClientStreamData{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3, Data: []byte("frame")}, | |
| 850 | 1008 | CloseStream{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3}, | |
| 851 | 1009 | } { | |
M internal/adapters/brokerwire/convert.go +16 -9
| @@ -435,24 +435,31 @@ func validateCatalogSession(session catalogue.RemoteCatalogSession) error { | |||
| 435 | 435 | return nil | |
| 436 | 436 | } | |
| 437 | 437 | ||
| 438 | - | // validateSnapshotHostBounds enforces the per-host snapshot contract: | |
| 438 | + | // validateSnapshotDaemonFields enforces the per-daemon snapshot contract: | |
| 439 | 439 | // endpoint grammar, presentation origin, rank fit, closed availability and | |
| 440 | - | // failure taxonomies, and time values that survive the wire exactly. | |
| 441 | - | func validateSnapshotHostFields(host ports.RemoteHostSnapshot) error { | |
| 442 | - | if err := validateBrokerEndpoint(host.Endpoint); err != nil { | |
| 440 | + | // failure taxonomies, and a syntactically present policy. The full port | |
| 441 | + | // projection rules (identity/incarnation pairing, observed version, | |
| 442 | + | // inventory, local authority) are re-checked by BrokerDaemonObservation | |
| 443 | + | // validation and, for remotes, the durable projection rule. | |
| 444 | + | func validateSnapshotDaemonFields(daemon ports.BrokerDaemonObservation) error { | |
| 445 | + | if daemon.Local { | |
| 446 | + | if daemon.Endpoint != "" { | |
| 447 | + | return ErrInvalidMessage | |
| 448 | + | } | |
| 449 | + | } else if err := validateBrokerEndpoint(daemon.Endpoint); err != nil { | |
| 443 | 450 | return err | |
| 444 | 451 | } | |
| 445 | - | if err := validateBrokerOrigin(host.DisplayOrigin); err != nil { | |
| 452 | + | if err := validateBrokerOrigin(daemon.DisplayOrigin); err != nil { | |
| 446 | 453 | return err | |
| 447 | 454 | } | |
| 448 | - | if host.Rank < 0 || host.Rank > math.MaxUint32 { | |
| 455 | + | if daemon.Rank < 0 || daemon.Rank > math.MaxUint32 { | |
| 449 | 456 | return errConvertRange | |
| 450 | 457 | } | |
| 451 | - | if host.Availability < domain.RemoteAvailabilityUnknown || | |
| 452 | - | host.Availability > domain.RemoteAvailabilityInvalidResponse { | |
| 458 | + | if daemon.Availability < domain.RemoteAvailabilityUnknown || | |
| 459 | + | daemon.Availability > domain.RemoteAvailabilityInvalidResponse { | |
| 453 | 460 | return ErrInvalidMessage | |
| 454 | 461 | } | |
| 455 | - | if host.LastFailure.Kind > domain.RemoteFailureInvalidResponse { | |
| 462 | + | if daemon.LastFailure.Kind > domain.RemoteFailureInvalidResponse { | |
| 456 | 463 | return ErrInvalidMessage | |
| 457 | 464 | } | |
| 458 | 465 | return nil | |
M internal/adapters/brokerwire/messages.go +17 -8
| @@ -99,12 +99,16 @@ func (Reconcile) brokerClientMessage() {} | |||
| 99 | 99 | ||
| 100 | 100 | // OpenStream asks the broker to open one independently cancellable logical | |
| 101 | 101 | // stream to the owning daemon. Env is the per-request session environment | |
| 102 | - | // and is never inherited from the broker process environment. | |
| 102 | + | // and is never inherited from the broker process environment. Admission | |
| 103 | + | // selects the attachment admission variant; Name is the validated session | |
| 104 | + | // name for create-named and is empty otherwise. | |
| 103 | 105 | type OpenStream struct { | |
| 104 | 106 | Epoch ports.BrokerEpoch | |
| 105 | 107 | Connection ports.BrokerConnectionID | |
| 106 | 108 | Stream ports.BrokerStreamID | |
| 107 | 109 | Purpose ports.BrokerStreamPurpose | |
| 110 | + | Admission ports.BrokerStreamAdmission | |
| 111 | + | Name string | |
| 108 | 112 | Local bool | |
| 109 | 113 | Endpoint string | |
| 110 | 114 | Registration domain.RemoteRegistration | |
| @@ -163,29 +167,34 @@ func (SnapshotPart) brokerServerMessage() {} | |||
| 163 | 167 | type SnapshotPartPayload interface{ snapshotPartPayload() } | |
| 164 | 168 | ||
| 165 | 169 | // SnapshotBegin opens a snapshot publication with its element counts. | |
| 170 | + | // HostCount counts every daemon part in the transfer (the local daemon plus | |
| 171 | + | // every remote host); LocalPresent reports whether daemon index 0 is local. | |
| 166 | 172 | type SnapshotBegin struct { | |
| 167 | 173 | HostCount uint32 | |
| 168 | 174 | SessionCount uint32 | |
| 169 | 175 | TombstoneCount uint32 | |
| 176 | + | LocalPresent bool | |
| 170 | 177 | } | |
| 171 | 178 | ||
| 172 | 179 | func (SnapshotBegin) snapshotPartPayload() {} | |
| 173 | 180 | ||
| 174 | - | // SnapshotHostPart is one host projection. Sessions travel as separate | |
| 175 | - | // SnapshotSessionPart parts bound by host index; SessionCount advertises | |
| 176 | - | // how many follow. The embedded Host must carry no inline sessions. | |
| 177 | - | type SnapshotHostPart struct { | |
| 181 | + | // SnapshotDaemonPart is one daemon projection. Sessions travel as separate | |
| 182 | + | // SnapshotSessionPart parts bound by daemon index; SessionCount advertises | |
| 183 | + | // how many follow. The embedded Daemon must carry no inline sessions. | |
| 184 | + | type SnapshotDaemonPart struct { | |
| 178 | 185 | HostIndex uint32 | |
| 179 | - | Host ports.RemoteHostSnapshot | |
| 186 | + | Daemon ports.BrokerDaemonObservation | |
| 180 | 187 | SessionCount uint32 | |
| 181 | 188 | } | |
| 182 | 189 | ||
| 183 | - | func (SnapshotHostPart) snapshotPartPayload() {} | |
| 190 | + | func (SnapshotDaemonPart) snapshotPartPayload() {} | |
| 184 | 191 | ||
| 185 | - | // SnapshotSessionPart is one catalogue session bound to its host by index. | |
| 192 | + | // SnapshotSessionPart is one catalogue session bound to its daemon by index. | |
| 193 | + | // Local marks a session of the local daemon: HostIndex is then exactly 0. | |
| 186 | 194 | type SnapshotSessionPart struct { | |
| 187 | 195 | HostIndex uint32 | |
| 188 | 196 | SessionIndex uint32 | |
| 197 | + | Local bool | |
| 189 | 198 | Session catalogue.RemoteCatalogSession | |
| 190 | 199 | } | |
| 191 | 200 | ||
M internal/adapters/brokerwire/snapshot.go +132 -108
| @@ -9,36 +9,40 @@ | |||
| 9 | 9 | // | |
| 10 | 10 | // Transfer layout (indexes are exact positions): | |
| 11 | 11 | // | |
| 12 | - | // Begin(0) Host/Session... Tombstone... End(N-1) | |
| 12 | + | // Begin(0) Daemon/Session... Tombstone... End(N-1) | |
| 13 | 13 | // | |
| 14 | - | // Begin carries the total host, session, and tombstone counts. Host parts | |
| 15 | - | // arrive in host_index order; each Host advertises how many Session parts | |
| 16 | - | // follow it immediately, with per-host session_index order. Tombstones | |
| 17 | - | // arrive in tombstone_index order after all hosts and sessions. End closes | |
| 18 | - | // the transfer. Total parts are 1 + hosts + sessions + tombstones + 1, | |
| 19 | - | // bounded by MaxSnapshotParts (64 hosts, 64*256 sessions, 64 tombstones). | |
| 14 | + | // Begin carries the total daemon, session, and tombstone counts plus whether | |
| 15 | + | // the local daemon is present. Daemon parts arrive in host_index order, with | |
| 16 | + | // the local daemon (when present) first at index 0 and remotes following in | |
| 17 | + | // registration order; each Daemon advertises how many Session parts follow it | |
| 18 | + | // immediately, with per-daemon session_index order. A session of the local | |
| 19 | + | // daemon carries Local=true and host_index 0. Tombstones arrive in | |
| 20 | + | // tombstone_index order after all daemons and sessions. End closes the | |
| 21 | + | // transfer. Total parts are 1 + daemons + sessions + tombstones + 1, bounded | |
| 22 | + | // by MaxSnapshotParts (65 daemons, 65*256 sessions, 64 tombstones). | |
| 20 | 23 | // | |
| 21 | 24 | // Bounds: at most MaxSnapshotParts parts and MaxSnapshotStagedBytes staged | |
| 22 | 25 | // estimate per transfer. The staged estimate is a deterministic function of | |
| 23 | - | // endpoint, origin, session, tab, and tombstone text lengths plus fixed | |
| 24 | - | // per-part overhead; it bounds hostile memory without measuring wire bytes. | |
| 26 | + | // endpoint, origin, policy, identity, session, tab, and tombstone text lengths | |
| 27 | + | // plus fixed per-part overhead; it bounds hostile memory without measuring | |
| 28 | + | // wire bytes. | |
| 25 | 29 | // | |
| 26 | 30 | // Publication is atomic: the committed snapshot is replaced only when End | |
| 27 | 31 | // arrives with exact indexes and the assembled BrokerSnapshot passes | |
| 28 | - | // ports.BrokerSnapshot.Validate plus per-host | |
| 29 | - | // ports.ValidateDurableHostProjection. Any malformed index, count, order, | |
| 30 | - | // or validation failure aborts staging and retains the old snapshot. A new | |
| 31 | - | // Begin for the same generation with a newer revision coalesces by | |
| 32 | - | // replacing staging; an older revision is rejected and retains both the | |
| 33 | - | // committed snapshot and any staging in flight. Equal revisions are allowed | |
| 34 | - | // so a resync may republish the committed revision. | |
| 32 | + | // ports.BrokerSnapshot.Validate plus, for every remote daemon, | |
| 33 | + | // ports.ValidateDurableHostProjection. Any malformed index, count, order, or | |
| 34 | + | // validation failure aborts staging and retains the old snapshot. A new Begin | |
| 35 | + | // for the same generation with a newer revision coalesces by replacing | |
| 36 | + | // staging; an older revision is rejected and retains both the committed | |
| 37 | + | // snapshot and any staging in flight. Equal revisions are allowed so a resync | |
| 38 | + | // may republish the committed revision. | |
| 35 | 39 | // | |
| 36 | 40 | // Generation fencing: the assembler belongs to exactly one nonzero | |
| 37 | 41 | // subscription generation, adopted from the first Begin or pinned by | |
| 38 | - | // WithGeneration. Parts with a stale generation are ignored without | |
| 39 | - | // touching staging; parts with a future generation are rejected, whether or | |
| 40 | - | // not a transfer is in flight. Scope fencing is exact: every part must carry | |
| 41 | - | // the assembler's epoch and connection. | |
| 42 | + | // WithGeneration. Parts with a stale generation are ignored without touching | |
| 43 | + | // staging; parts with a future generation are rejected, whether or not a | |
| 44 | + | // transfer is in flight. Scope fencing is exact: every part must carry the | |
| 45 | + | // assembler's epoch and connection. | |
| 42 | 46 | // | |
| 43 | 47 | // There is no I/O, socket, pump, or P3.2 transport here. The owning | |
| 44 | 48 | // Connection discards staging on generation advance, unsubscribe, or close. | |
| @@ -53,9 +57,9 @@ import ( | |||
| 53 | 57 | ) | |
| 54 | 58 | ||
| 55 | 59 | const ( | |
| 56 | - | // MaxSnapshotParts bounds one multipart publication: 1 Begin + 64 | |
| 57 | - | // hosts + 64*256 sessions + 64 tombstones + 1 End. | |
| 58 | - | MaxSnapshotParts = 1 + ports.BrokerMaxHosts + ports.BrokerMaxHosts*ports.BrokerMaxSessionsPerHost + ports.BrokerMaxTombstones + 1 | |
| 60 | + | // MaxSnapshotParts bounds one multipart publication: 1 Begin + 65 daemons | |
| 61 | + | // + 65*256 sessions + 64 tombstones + 1 End. | |
| 62 | + | MaxSnapshotParts = 1 + ports.BrokerMaxDaemonsPerSnapshot + ports.BrokerMaxDaemonsPerSnapshot*ports.BrokerMaxSessionsPerHost + ports.BrokerMaxTombstones + 1 | |
| 59 | 63 | // MaxSnapshotStagedBytes bounds the deterministic staged estimate of | |
| 60 | 64 | // one transfer (80 MiB). | |
| 61 | 65 | MaxSnapshotStagedBytes = uint64(80 << 20) | |
| @@ -121,18 +125,38 @@ type snapshotStaging struct { | |||
| 121 | 125 | hostCount uint32 | |
| 122 | 126 | sessionTotal uint32 | |
| 123 | 127 | tombstoneCount uint32 | |
| 128 | + | localPresent bool | |
| 124 | 129 | totalParts uint32 | |
| 125 | 130 | nextIndex uint32 | |
| 126 | - | hosts []ports.RemoteHostSnapshot | |
| 127 | - | hostExpected []uint32 | |
| 128 | - | hostSeen []uint32 | |
| 129 | - | hostsSeen uint32 | |
| 131 | + | daemons []ports.BrokerDaemonObservation | |
| 132 | + | daemonExpected []uint32 | |
| 133 | + | daemonSeen []uint32 | |
| 134 | + | daemonsSeen uint32 | |
| 130 | 135 | sessionsSeen uint32 | |
| 131 | 136 | tombstones []ports.BrokerHostTombstone | |
| 132 | 137 | tombstonesSeen uint32 | |
| 133 | 138 | stagedBytes uint64 | |
| 134 | 139 | } | |
| 135 | 140 | ||
| 141 | + | // newSnapshotStaging allocates one transfer's staging from a validated Begin. | |
| 142 | + | func newSnapshotStaging(generation SubscriptionGeneration, revision ports.BrokerRevision, begin SnapshotBegin) *snapshotStaging { | |
| 143 | + | total := 1 + uint64(begin.HostCount) + uint64(begin.SessionCount) + uint64(begin.TombstoneCount) + 1 | |
| 144 | + | return &snapshotStaging{ | |
| 145 | + | generation: generation, | |
| 146 | + | revision: revision, | |
| 147 | + | hostCount: begin.HostCount, | |
| 148 | + | sessionTotal: begin.SessionCount, | |
| 149 | + | tombstoneCount: begin.TombstoneCount, | |
| 150 | + | localPresent: begin.LocalPresent, | |
| 151 | + | totalParts: uint32(total), | |
| 152 | + | nextIndex: 1, | |
| 153 | + | daemons: make([]ports.BrokerDaemonObservation, begin.HostCount), | |
| 154 | + | daemonExpected: make([]uint32, begin.HostCount), | |
| 155 | + | daemonSeen: make([]uint32, begin.HostCount), | |
| 156 | + | tombstones: make([]ports.BrokerHostTombstone, begin.TombstoneCount), | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | ||
| 136 | 160 | // NewSnapshotAssembler binds an assembler to one exact scope assigned by | |
| 137 | 161 | // Registered. Every part must carry that scope. | |
| 138 | 162 | func NewSnapshotAssembler(epoch ports.BrokerEpoch, connection ports.BrokerConnectionID, opts ...SnapshotOption) *SnapshotAssembler { | |
| @@ -242,23 +266,7 @@ func (a *SnapshotAssembler) Add(part SnapshotPart) (bool, ports.BrokerSnapshot, | |||
| 242 | 266 | if a.hasCommitted && part.Revision < a.committed.Revision { | |
| 243 | 267 | return false, ports.BrokerSnapshot{}, ErrSnapshotStale | |
| 244 | 268 | } | |
| 245 | - | total := 1 + uint64(begin.HostCount) + uint64(begin.SessionCount) + uint64(begin.TombstoneCount) + 1 | |
| 246 | - | if total > uint64(MaxSnapshotParts) { | |
| 247 | - | return false, ports.BrokerSnapshot{}, ErrTooLarge | |
| 248 | - | } | |
| 249 | - | staging := &snapshotStaging{ | |
| 250 | - | generation: part.Generation, | |
| 251 | - | revision: part.Revision, | |
| 252 | - | hostCount: begin.HostCount, | |
| 253 | - | sessionTotal: begin.SessionCount, | |
| 254 | - | tombstoneCount: begin.TombstoneCount, | |
| 255 | - | totalParts: uint32(total), | |
| 256 | - | nextIndex: 1, | |
| 257 | - | hosts: make([]ports.RemoteHostSnapshot, begin.HostCount), | |
| 258 | - | hostExpected: make([]uint32, begin.HostCount), | |
| 259 | - | hostSeen: make([]uint32, begin.HostCount), | |
| 260 | - | tombstones: make([]ports.BrokerHostTombstone, begin.TombstoneCount), | |
| 261 | - | } | |
| 269 | + | staging := newSnapshotStaging(part.Generation, part.Revision, begin) | |
| 262 | 270 | staged := estimatePartBytes(part) | |
| 263 | 271 | if staged > a.maxStagedBytes { | |
| 264 | 272 | return false, ports.BrokerSnapshot{}, ErrTooLarge | |
| @@ -284,11 +292,6 @@ func (a *SnapshotAssembler) Add(part SnapshotPart) (bool, ports.BrokerSnapshot, | |||
| 284 | 292 | a.staging = nil | |
| 285 | 293 | return false, ports.BrokerSnapshot{}, err | |
| 286 | 294 | } | |
| 287 | - | total := 1 + uint64(begin.HostCount) + uint64(begin.SessionCount) + uint64(begin.TombstoneCount) + 1 | |
| 288 | - | if total > uint64(MaxSnapshotParts) { | |
| 289 | - | a.staging = nil | |
| 290 | - | return false, ports.BrokerSnapshot{}, ErrTooLarge | |
| 291 | - | } | |
| 292 | 295 | switch { | |
| 293 | 296 | case part.Revision == staging.revision: | |
| 294 | 297 | // Restart of the same transfer: discard progress. | |
| @@ -299,19 +302,7 @@ func (a *SnapshotAssembler) Add(part SnapshotPart) (bool, ports.BrokerSnapshot, | |||
| 299 | 302 | default: | |
| 300 | 303 | return false, ports.BrokerSnapshot{}, ErrSnapshotStale | |
| 301 | 304 | } | |
| 302 | - | fresh := &snapshotStaging{ | |
| 303 | - | generation: part.Generation, | |
| 304 | - | revision: part.Revision, | |
| 305 | - | hostCount: begin.HostCount, | |
| 306 | - | sessionTotal: begin.SessionCount, | |
| 307 | - | tombstoneCount: begin.TombstoneCount, | |
| 308 | - | totalParts: uint32(total), | |
| 309 | - | nextIndex: 1, | |
| 310 | - | hosts: make([]ports.RemoteHostSnapshot, begin.HostCount), | |
| 311 | - | hostExpected: make([]uint32, begin.HostCount), | |
| 312 | - | hostSeen: make([]uint32, begin.HostCount), | |
| 313 | - | tombstones: make([]ports.BrokerHostTombstone, begin.TombstoneCount), | |
| 314 | - | } | |
| 305 | + | fresh := newSnapshotStaging(part.Generation, part.Revision, begin) | |
| 315 | 306 | staged := estimatePartBytes(part) | |
| 316 | 307 | if staged > a.maxStagedBytes { | |
| 317 | 308 | a.staging = nil | |
| @@ -342,9 +333,9 @@ func (a *SnapshotAssembler) Add(part SnapshotPart) (bool, ports.BrokerSnapshot, | |||
| 342 | 333 | return false, ports.BrokerSnapshot{}, ErrTooLarge | |
| 343 | 334 | } | |
| 344 | 335 | switch payload := part.Part.(type) { | |
| 345 | - | case SnapshotHostPart, *SnapshotHostPart: | |
| 346 | - | hostPart := normalizeHostPart(payload) | |
| 347 | - | if err := a.stageHost(staging, hostPart); err != nil { | |
| 336 | + | case SnapshotDaemonPart, *SnapshotDaemonPart: | |
| 337 | + | daemonPart := normalizeDaemonPart(payload) | |
| 338 | + | if err := a.stageDaemon(staging, daemonPart); err != nil { | |
| 348 | 339 | a.staging = nil | |
| 349 | 340 | return false, ports.BrokerSnapshot{}, err | |
| 350 | 341 | } | |
| @@ -394,17 +385,17 @@ func normalizeBegin(payload SnapshotPartPayload) (SnapshotBegin, bool) { | |||
| 394 | 385 | } | |
| 395 | 386 | } | |
| 396 | 387 | ||
| 397 | - | func normalizeHostPart(payload SnapshotPartPayload) SnapshotHostPart { | |
| 388 | + | func normalizeDaemonPart(payload SnapshotPartPayload) SnapshotDaemonPart { | |
| 398 | 389 | switch p := payload.(type) { | |
| 399 | - | case SnapshotHostPart: | |
| 390 | + | case SnapshotDaemonPart: | |
| 400 | 391 | return p | |
| 401 | - | case *SnapshotHostPart: | |
| 392 | + | case *SnapshotDaemonPart: | |
| 402 | 393 | if p == nil { | |
| 403 | - | return SnapshotHostPart{HostIndex: ^uint32(0)} | |
| 394 | + | return SnapshotDaemonPart{HostIndex: ^uint32(0)} | |
| 404 | 395 | } | |
| 405 | 396 | return *p | |
| 406 | 397 | default: | |
| 407 | - | return SnapshotHostPart{HostIndex: ^uint32(0)} | |
| 398 | + | return SnapshotDaemonPart{HostIndex: ^uint32(0)} | |
| 408 | 399 | } | |
| 409 | 400 | } | |
| 410 | 401 | ||
| @@ -437,19 +428,24 @@ func normalizeTombstonePart(payload SnapshotPartPayload) SnapshotTombstonePart { | |||
| 437 | 428 | } | |
| 438 | 429 | ||
| 439 | 430 | func checkBeginCounts(begin SnapshotBegin) error { | |
| 440 | - | if begin.HostCount > uint32(ports.BrokerMaxHosts) || | |
| 441 | - | begin.SessionCount > uint32(ports.BrokerMaxHosts*ports.BrokerMaxSessionsPerHost) || | |
| 431 | + | if begin.HostCount > uint32(ports.BrokerMaxDaemonsPerSnapshot) || | |
| 432 | + | begin.SessionCount > uint32(ports.BrokerMaxDaemonsPerSnapshot*ports.BrokerMaxSessionsPerHost) || | |
| 442 | 433 | begin.TombstoneCount > uint32(ports.BrokerMaxTombstones) { | |
| 443 | 434 | return ErrTooLarge | |
| 444 | 435 | } | |
| 436 | + | // A local daemon occupies daemon index 0, so it can only be present when | |
| 437 | + | // at least one daemon part follows. | |
| 438 | + | if begin.LocalPresent && begin.HostCount == 0 { | |
| 439 | + | return ErrSnapshotInvalid | |
| 440 | + | } | |
| 445 | 441 | return nil | |
| 446 | 442 | } | |
| 447 | 443 | ||
| 448 | - | func (a *SnapshotAssembler) stageHost(staging *snapshotStaging, part SnapshotHostPart) error { | |
| 449 | - | if staging.hostsSeen >= staging.hostCount { | |
| 444 | + | func (a *SnapshotAssembler) stageDaemon(staging *snapshotStaging, part SnapshotDaemonPart) error { | |
| 445 | + | if staging.daemonsSeen >= staging.hostCount { | |
| 450 | 446 | return ErrSnapshotInvalid | |
| 451 | 447 | } | |
| 452 | - | if part.HostIndex != staging.hostsSeen { | |
| 448 | + | if part.HostIndex != staging.daemonsSeen { | |
| 453 | 449 | return ErrSnapshotInvalid | |
| 454 | 450 | } | |
| 455 | 451 | if part.HostIndex >= staging.hostCount { | |
| @@ -458,46 +454,60 @@ func (a *SnapshotAssembler) stageHost(staging *snapshotStaging, part SnapshotHos | |||
| 458 | 454 | if part.SessionCount > uint32(ports.BrokerMaxSessionsPerHost) { | |
| 459 | 455 | return ErrTooLarge | |
| 460 | 456 | } | |
| 461 | - | if len(part.Host.Sessions) != 0 { | |
| 457 | + | if len(part.Daemon.Sessions) != 0 { | |
| 458 | + | return ErrSnapshotInvalid | |
| 459 | + | } | |
| 460 | + | // The local daemon is always first: daemon index 0 is local exactly when | |
| 461 | + | // Begin advertised it, and no later daemon may be local. | |
| 462 | + | if part.HostIndex == 0 { | |
| 463 | + | if part.Daemon.Local != staging.localPresent { | |
| 464 | + | return ErrSnapshotInvalid | |
| 465 | + | } | |
| 466 | + | } else if part.Daemon.Local { | |
| 462 | 467 | return ErrSnapshotInvalid | |
| 463 | 468 | } | |
| 464 | 469 | var seen uint64 | |
| 465 | - | for _, n := range staging.hostExpected[:staging.hostsSeen] { | |
| 470 | + | for _, n := range staging.daemonExpected[:staging.daemonsSeen] { | |
| 466 | 471 | seen += uint64(n) | |
| 467 | 472 | } | |
| 468 | 473 | if seen+uint64(part.SessionCount) > uint64(staging.sessionTotal) { | |
| 469 | 474 | return ErrSnapshotInvalid | |
| 470 | 475 | } | |
| 471 | - | // Remaining hosts must still be able to fill the advertised total: | |
| 472 | - | // even if every remaining host carried the per-host maximum, the | |
| 476 | + | // Remaining daemons must still be able to fill the advertised total: | |
| 477 | + | // even if every remaining daemon carried the per-daemon maximum, the | |
| 473 | 478 | // total must stay reachable. This is checked exactly at End; here we | |
| 474 | 479 | // only refuse an already-exceeded sum. | |
| 475 | - | staging.hosts[part.HostIndex] = part.Host.Clone() | |
| 476 | - | staging.hostExpected[part.HostIndex] = part.SessionCount | |
| 477 | - | staging.hostSeen[part.HostIndex] = 0 | |
| 478 | - | staging.hostsSeen++ | |
| 480 | + | staging.daemons[part.HostIndex] = part.Daemon.Clone() | |
| 481 | + | staging.daemonExpected[part.HostIndex] = part.SessionCount | |
| 482 | + | staging.daemonSeen[part.HostIndex] = 0 | |
| 483 | + | staging.daemonsSeen++ | |
| 479 | 484 | return nil | |
| 480 | 485 | } | |
| 481 | 486 | ||
| 482 | 487 | func (a *SnapshotAssembler) stageSession(staging *snapshotStaging, part SnapshotSessionPart) error { | |
| 483 | - | if staging.hostsSeen == 0 || staging.hostsSeen > staging.hostCount { | |
| 488 | + | if staging.daemonsSeen == 0 || staging.daemonsSeen > staging.hostCount { | |
| 484 | 489 | return ErrSnapshotInvalid | |
| 485 | 490 | } | |
| 486 | - | current := staging.hostsSeen - 1 | |
| 491 | + | current := staging.daemonsSeen - 1 | |
| 487 | 492 | if part.HostIndex != current { | |
| 488 | 493 | return ErrSnapshotInvalid | |
| 489 | 494 | } | |
| 490 | 495 | if current >= staging.hostCount { | |
| 491 | 496 | return ErrSnapshotInvalid | |
| 492 | 497 | } | |
| 493 | - | expected := staging.hostExpected[current] | |
| 494 | - | if staging.hostSeen[current] >= expected { | |
| 498 | + | expected := staging.daemonExpected[current] | |
| 499 | + | if staging.daemonSeen[current] >= expected { | |
| 495 | 500 | return ErrSnapshotInvalid | |
| 496 | 501 | } | |
| 497 | - | if part.SessionIndex != staging.hostSeen[current] { | |
| 502 | + | if part.SessionIndex != staging.daemonSeen[current] { | |
| 498 | 503 | return ErrSnapshotInvalid | |
| 499 | 504 | } | |
| 500 | - | if part.HostIndex >= uint32(ports.BrokerMaxHosts) || part.SessionIndex >= uint32(ports.BrokerMaxSessionsPerHost) { | |
| 505 | + | // A session belongs to the daemon it follows: a local session binds to | |
| 506 | + | // the local daemon at index 0, and a remote session to a remote daemon. | |
| 507 | + | if part.Local != staging.daemons[current].Local { | |
| 508 | + | return ErrSnapshotInvalid | |
| 509 | + | } | |
| 510 | + | if part.HostIndex >= uint32(ports.BrokerMaxDaemonsPerSnapshot) || part.SessionIndex >= uint32(ports.BrokerMaxSessionsPerHost) { | |
| 501 | 511 | return ErrTooLarge | |
| 502 | 512 | } | |
| 503 | 513 | if staging.sessionsSeen >= staging.sessionTotal { | |
| @@ -510,16 +520,16 @@ func (a *SnapshotAssembler) stageSession(staging *snapshotStaging, part Snapshot | |||
| 510 | 520 | tabs := make([]catalogue.RemoteCatalogTab, len(session.Tabs)) | |
| 511 | 521 | copy(tabs, session.Tabs) | |
| 512 | 522 | session.Tabs = tabs | |
| 513 | - | host := staging.hosts[current] | |
| 514 | - | host.Sessions = append(host.Sessions, session) | |
| 515 | - | staging.hosts[current] = host | |
| 516 | - | staging.hostSeen[current]++ | |
| 523 | + | daemon := staging.daemons[current] | |
| 524 | + | daemon.Sessions = append(daemon.Sessions, session) | |
| 525 | + | staging.daemons[current] = daemon | |
| 526 | + | staging.daemonSeen[current]++ | |
| 517 | 527 | staging.sessionsSeen++ | |
| 518 | 528 | return nil | |
| 519 | 529 | } | |
| 520 | 530 | ||
| 521 | 531 | func (a *SnapshotAssembler) stageTombstone(staging *snapshotStaging, part SnapshotTombstonePart) error { | |
| 522 | - | if staging.hostsSeen != staging.hostCount || staging.sessionsSeen != staging.sessionTotal { | |
| 532 | + | if staging.daemonsSeen != staging.hostCount || staging.sessionsSeen != staging.sessionTotal { | |
| 523 | 533 | return ErrSnapshotInvalid | |
| 524 | 534 | } | |
| 525 | 535 | if staging.tombstonesSeen >= staging.tombstoneCount { | |
| @@ -541,33 +551,37 @@ func (a *SnapshotAssembler) stageTombstone(staging *snapshotStaging, part Snapsh | |||
| 541 | 551 | } | |
| 542 | 552 | ||
| 543 | 553 | func (a *SnapshotAssembler) commitStaging(staging *snapshotStaging) (ports.BrokerSnapshot, error) { | |
| 544 | - | if staging.hostsSeen != staging.hostCount || staging.sessionsSeen != staging.sessionTotal || staging.tombstonesSeen != staging.tombstoneCount { | |
| 554 | + | if staging.daemonsSeen != staging.hostCount || staging.sessionsSeen != staging.sessionTotal || staging.tombstonesSeen != staging.tombstoneCount { | |
| 545 | 555 | return ports.BrokerSnapshot{}, ErrSnapshotInvalid | |
| 546 | 556 | } | |
| 547 | 557 | if staging.nextIndex != staging.totalParts-1 { | |
| 548 | 558 | return ports.BrokerSnapshot{}, ErrSnapshotInvalid | |
| 549 | 559 | } | |
| 550 | 560 | var sum uint64 | |
| 551 | - | for _, n := range staging.hostExpected { | |
| 561 | + | for _, n := range staging.daemonExpected { | |
| 552 | 562 | sum += uint64(n) | |
| 553 | 563 | } | |
| 554 | 564 | if sum != uint64(staging.sessionTotal) { | |
| 555 | 565 | return ports.BrokerSnapshot{}, ErrSnapshotInvalid | |
| 556 | 566 | } | |
| 557 | - | hosts := make([]ports.RemoteHostSnapshot, len(staging.hosts)) | |
| 558 | - | for i, host := range staging.hosts { | |
| 559 | - | hosts[i] = host.Clone() | |
| 560 | - | if hosts[i].Sessions == nil { | |
| 561 | - | hosts[i].Sessions = []catalogue.RemoteCatalogSession{} | |
| 567 | + | daemons := make([]ports.BrokerDaemonObservation, len(staging.daemons)) | |
| 568 | + | for i, daemon := range staging.daemons { | |
| 569 | + | daemons[i] = daemon.Clone() | |
| 570 | + | if daemons[i].Sessions == nil { | |
| 571 | + | daemons[i].Sessions = []catalogue.RemoteCatalogSession{} | |
| 562 | 572 | } | |
| 563 | 573 | } | |
| 564 | 574 | removed := append([]ports.BrokerHostTombstone(nil), staging.tombstones...) | |
| 565 | - | snapshot := ports.BrokerSnapshot{Epoch: a.epoch, Revision: staging.revision, Hosts: hosts, Removed: removed} | |
| 575 | + | snapshot := ports.BrokerSnapshot{Epoch: a.epoch, Revision: staging.revision, Daemons: daemons, Removed: removed} | |
| 566 | 576 | if err := snapshot.Validate(); err != nil { | |
| 567 | 577 | return ports.BrokerSnapshot{}, ErrSnapshotInvalid | |
| 568 | 578 | } | |
| 569 | - | for _, host := range hosts { | |
| 570 | - | if err := ports.ValidateDurableHostProjection(host); err != nil { | |
| 579 | + | for _, daemon := range daemons { | |
| 580 | + | // The local daemon is process-local state and is never durable. | |
| 581 | + | if daemon.Local { | |
| 582 | + | continue | |
| 583 | + | } | |
| 584 | + | if err := ports.ValidateDurableHostProjection(daemon); err != nil { | |
| 571 | 585 | return ports.BrokerSnapshot{}, ErrSnapshotInvalid | |
| 572 | 586 | } | |
| 573 | 587 | } | |
| @@ -579,13 +593,13 @@ func estimatePartBytes(part SnapshotPart) uint64 { | |||
| 579 | 593 | switch payload := part.Part.(type) { | |
| 580 | 594 | case SnapshotBegin, *SnapshotBegin: | |
| 581 | 595 | return overhead | |
| 582 | - | case SnapshotHostPart: | |
| 583 | - | return uint64(len(payload.Host.Endpoint)+len(payload.Host.DisplayOrigin)+len(payload.Host.Registration.Endpoint)+64) + overhead | |
| 584 | - | case *SnapshotHostPart: | |
| 596 | + | case SnapshotDaemonPart: | |
| 597 | + | return estimateDaemonBytes(payload.Daemon) + overhead | |
| 598 | + | case *SnapshotDaemonPart: | |
| 585 | 599 | if payload == nil { | |
| 586 | 600 | return overhead | |
| 587 | 601 | } | |
| 588 | - | return uint64(len(payload.Host.Endpoint)+len(payload.Host.DisplayOrigin)+len(payload.Host.Registration.Endpoint)+64) + overhead | |
| 602 | + | return estimateDaemonBytes(payload.Daemon) + overhead | |
| 589 | 603 | case SnapshotSessionPart: | |
| 590 | 604 | return estimateCatalogSessionBytes(payload.Session) + overhead | |
| 591 | 605 | case *SnapshotSessionPart: | |
| @@ -607,6 +621,16 @@ func estimatePartBytes(part SnapshotPart) uint64 { | |||
| 607 | 621 | } | |
| 608 | 622 | } | |
| 609 | 623 | ||
| 624 | + | // estimateDaemonBytes bounds one daemon projection's staged memory: every | |
| 625 | + | // configured-authority and observed-identity string plus a fixed per-daemon | |
| 626 | + | // charge. | |
| 627 | + | func estimateDaemonBytes(daemon ports.BrokerDaemonObservation) uint64 { | |
| 628 | + | text := len(daemon.Endpoint) + len(daemon.DisplayOrigin) + len(daemon.Registration.Endpoint) + | |
| 629 | + | len(daemon.Policy.Transport) + len(daemon.Policy.Trust) + len(daemon.Policy.Launch) + len(daemon.Policy.Isolation) + | |
| 630 | + | len(daemon.Identity) | |
| 631 | + | return uint64(text + 64) | |
| 632 | + | } | |
| 633 | + | ||
| 610 | 634 | // estimateCatalogSessionBytes bounds one session's staged memory. It charges | |
| 611 | 635 | // every tab at least estimateTabBytes (covering the tab struct, its string | |
| 612 | 636 | // headers, and its slice slot) plus the variable-length text, and adds one | |
M internal/adapters/brokerwire/snapshot_test.go +202 -92
| @@ -25,13 +25,16 @@ func testSnapshotAssembler(t *testing.T, opts ...SnapshotOption) *SnapshotAssemb | |||
| 25 | 25 | return NewSnapshotAssembler(epoch, connection, opts...) | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | - | // testHostAt builds one catalogue-valid host projection for host index i. | |
| 29 | - | func testHostAt(i int) ports.RemoteHostSnapshot { | |
| 28 | + | // testDaemonAt builds one catalogue-valid remote daemon projection for daemon | |
| 29 | + | // index i. Sessions are attached by the sample helpers; the emitter strips | |
| 30 | + | // them before a daemon part travels. | |
| 31 | + | func testDaemonAt(i int) ports.BrokerDaemonObservation { | |
| 30 | 32 | endpoint := fmt.Sprintf("user%d@host%d:22", i, i) | |
| 31 | - | return ports.RemoteHostSnapshot{ | |
| 33 | + | return ports.BrokerDaemonObservation{ | |
| 32 | 34 | Endpoint: endpoint, | |
| 33 | 35 | DisplayOrigin: endpoint, | |
| 34 | 36 | Registration: domain.RemoteRegistration{Endpoint: endpoint, Incarnation: [16]byte{0x10, byte(i + 1), 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xf0, 0x11}, Generation: domain.RemoteGeneration(i + 1)}, | |
| 37 | + | Policy: testPolicy(), | |
| 35 | 38 | Availability: domain.RemoteAvailabilityReachable, | |
| 36 | 39 | LastSuccess: time.Unix(1700000000+int64(i), 0).UTC(), | |
| 37 | 40 | InventoryKnown: true, | |
| @@ -39,6 +42,18 @@ func testHostAt(i int) ports.RemoteHostSnapshot { | |||
| 39 | 42 | } | |
| 40 | 43 | } | |
| 41 | 44 | ||
| 45 | + | // testLocalDaemonAt builds the local daemon projection. | |
| 46 | + | func testLocalDaemonAt() ports.BrokerDaemonObservation { | |
| 47 | + | return ports.BrokerDaemonObservation{ | |
| 48 | + | Local: true, | |
| 49 | + | DisplayOrigin: "local", | |
| 50 | + | Policy: testPolicy(), | |
| 51 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 52 | + | InventoryKnown: true, | |
| 53 | + | Sessions: []catalogue.RemoteCatalogSession{}, | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 42 | 57 | // testSessionAt builds one catalogue-valid session for a host and index. | |
| 43 | 58 | func testSessionAt(host, index int) catalogue.RemoteCatalogSession { | |
| 44 | 59 | return catalogue.RemoteCatalogSession{ | |
| @@ -59,29 +74,39 @@ func testTombstoneAt(i int) ports.BrokerHostTombstone { | |||
| 59 | 74 | } | |
| 60 | 75 | } | |
| 61 | 76 | ||
| 62 | - | func sampleSnapshotHosts() []ports.RemoteHostSnapshot { | |
| 63 | - | host0 := testHostAt(0) | |
| 64 | - | host0.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(0, 0), testSessionAt(0, 1)} | |
| 65 | - | host1 := testHostAt(1) | |
| 66 | - | host1.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(1, 0)} | |
| 67 | - | return []ports.RemoteHostSnapshot{host0, host1} | |
| 77 | + | func sampleSnapshotDaemons() []ports.BrokerDaemonObservation { | |
| 78 | + | daemon0 := testDaemonAt(0) | |
| 79 | + | daemon0.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(0, 0), testSessionAt(0, 1)} | |
| 80 | + | daemon1 := testDaemonAt(1) | |
| 81 | + | daemon1.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(1, 0)} | |
| 82 | + | return []ports.BrokerDaemonObservation{daemon0, daemon1} | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // sampleLocalSnapshotDaemons builds a local-first publication: the local daemon | |
| 86 | + | // at index 0 with two sessions, then one remote daemon with one session. | |
| 87 | + | func sampleLocalSnapshotDaemons() []ports.BrokerDaemonObservation { | |
| 88 | + | local := testLocalDaemonAt() | |
| 89 | + | local.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(0, 0), testSessionAt(0, 1)} | |
| 90 | + | remote := testDaemonAt(0) | |
| 91 | + | remote.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(1, 0)} | |
| 92 | + | return []ports.BrokerDaemonObservation{local, remote} | |
| 68 | 93 | } | |
| 69 | 94 | ||
| 70 | 95 | func sampleSnapshotTombstones() []ports.BrokerHostTombstone { | |
| 71 | 96 | return []ports.BrokerHostTombstone{testTombstoneAt(0)} | |
| 72 | 97 | } | |
| 73 | 98 | ||
| 74 | - | func snapshotSessionTotal(hosts []ports.RemoteHostSnapshot) int { | |
| 99 | + | func snapshotSessionTotal(daemons []ports.BrokerDaemonObservation) int { | |
| 75 | 100 | total := 0 | |
| 76 | - | for _, host := range hosts { | |
| 77 | - | total += len(host.Sessions) | |
| 101 | + | for _, daemon := range daemons { | |
| 102 | + | total += len(daemon.Sessions) | |
| 78 | 103 | } | |
| 79 | 104 | return total | |
| 80 | 105 | } | |
| 81 | 106 | ||
| 82 | - | // snapshotPartsFor lays out one exact transfer: Begin(0), hosts with their | |
| 107 | + | // snapshotPartsFor lays out one exact transfer: Begin(0), daemons with their | |
| 83 | 108 | // sessions, tombstones, End. Indexes are exact sequential positions. | |
| 84 | - | func snapshotPartsFor(gen uint64, rev ports.BrokerRevision, hosts []ports.RemoteHostSnapshot, tombstones []ports.BrokerHostTombstone) []SnapshotPart { | |
| 109 | + | func snapshotPartsFor(gen uint64, rev ports.BrokerRevision, daemons []ports.BrokerDaemonObservation, tombstones []ports.BrokerHostTombstone) []SnapshotPart { | |
| 85 | 110 | epoch, connection := testSnapshotScope() | |
| 86 | 111 | next := uint32(0) | |
| 87 | 112 | part := func(payload SnapshotPartPayload) SnapshotPart { | |
| @@ -89,19 +114,21 @@ func snapshotPartsFor(gen uint64, rev ports.BrokerRevision, hosts []ports.Remote | |||
| 89 | 114 | next++ | |
| 90 | 115 | return out | |
| 91 | 116 | } | |
| 117 | + | localPresent := len(daemons) > 0 && daemons[0].Local | |
| 92 | 118 | parts := []SnapshotPart{part(SnapshotBegin{ | |
| 93 | - | HostCount: uint32(len(hosts)), | |
| 94 | - | SessionCount: uint32(snapshotSessionTotal(hosts)), | |
| 119 | + | HostCount: uint32(len(daemons)), | |
| 120 | + | SessionCount: uint32(snapshotSessionTotal(daemons)), | |
| 95 | 121 | TombstoneCount: uint32(len(tombstones)), | |
| 122 | + | LocalPresent: localPresent, | |
| 96 | 123 | })} | |
| 97 | - | for hostIndex, host := range hosts { | |
| 98 | - | // Host parts carry no inline sessions: the advertised count is | |
| 124 | + | for daemonIndex, daemon := range daemons { | |
| 125 | + | // Daemon parts carry no inline sessions: the advertised count is | |
| 99 | 126 | // followed by that many session parts. | |
| 100 | - | projection := host.Clone() | |
| 127 | + | projection := daemon.Clone() | |
| 101 | 128 | projection.Sessions = []catalogue.RemoteCatalogSession{} | |
| 102 | - | parts = append(parts, part(SnapshotHostPart{HostIndex: uint32(hostIndex), Host: projection, SessionCount: uint32(len(host.Sessions))})) | |
| 103 | - | for sessionIndex, session := range host.Sessions { | |
| 104 | - | parts = append(parts, part(SnapshotSessionPart{HostIndex: uint32(hostIndex), SessionIndex: uint32(sessionIndex), Session: session})) | |
| 129 | + | parts = append(parts, part(SnapshotDaemonPart{HostIndex: uint32(daemonIndex), Daemon: projection, SessionCount: uint32(len(daemon.Sessions))})) | |
| 130 | + | for sessionIndex, session := range daemon.Sessions { | |
| 131 | + | parts = append(parts, part(SnapshotSessionPart{HostIndex: uint32(daemonIndex), SessionIndex: uint32(sessionIndex), Local: daemon.Local, Session: session})) | |
| 105 | 132 | } | |
| 106 | 133 | } | |
| 107 | 134 | for tombstoneIndex, tombstone := range tombstones { | |
| @@ -136,15 +163,15 @@ func runSnapshotParts(a *SnapshotAssembler, parts []SnapshotPart) error { | |||
| 136 | 163 | } | |
| 137 | 164 | ||
| 138 | 165 | func TestSnapshotAssemblerBoundsConstants(t *testing.T) { | |
| 139 | - | require.Equal(t, 16514, MaxSnapshotParts) | |
| 140 | - | require.Equal(t, 1+64+64*256+64+1, MaxSnapshotParts) | |
| 166 | + | require.Equal(t, 16771, MaxSnapshotParts) | |
| 167 | + | require.Equal(t, 1+ports.BrokerMaxDaemonsPerSnapshot+ports.BrokerMaxDaemonsPerSnapshot*ports.BrokerMaxSessionsPerHost+ports.BrokerMaxTombstones+1, MaxSnapshotParts) | |
| 141 | 168 | require.Equal(t, uint64(80<<20), MaxSnapshotStagedBytes) | |
| 142 | 169 | } | |
| 143 | 170 | ||
| 144 | 171 | // TestSnapshotAssemblerExactTransfer proves exact order, count, and index | |
| 145 | 172 | // acceptance, and that committed order matches the staged order. | |
| 146 | 173 | func TestSnapshotAssemblerExactTransfer(t *testing.T) { | |
| 147 | - | hosts := sampleSnapshotHosts() | |
| 174 | + | hosts := sampleSnapshotDaemons() | |
| 148 | 175 | tombstones := sampleSnapshotTombstones() | |
| 149 | 176 | parts := snapshotPartsFor(5, 3, hosts, tombstones) | |
| 150 | 177 | require.Len(t, parts, 1+2+3+1+1) | |
| @@ -169,13 +196,13 @@ func TestSnapshotAssemblerExactTransfer(t *testing.T) { | |||
| 169 | 196 | epoch, connection := testSnapshotScope() | |
| 170 | 197 | require.Equal(t, epoch, committed.Epoch) | |
| 171 | 198 | require.Equal(t, ports.BrokerRevision(3), committed.Revision) | |
| 172 | - | require.Len(t, committed.Hosts, 2) | |
| 173 | - | require.Equal(t, "user0@host0:22", committed.Hosts[0].Endpoint) | |
| 174 | - | require.Equal(t, "user1@host1:22", committed.Hosts[1].Endpoint) | |
| 175 | - | require.Equal(t, 2, len(committed.Hosts[0].Sessions)) | |
| 176 | - | require.Equal(t, "s00-000", committed.Hosts[0].Sessions[0].Name) | |
| 177 | - | require.Equal(t, "s00-001", committed.Hosts[0].Sessions[1].Name) | |
| 178 | - | require.Equal(t, "s01-000", committed.Hosts[1].Sessions[0].Name) | |
| 199 | + | require.Len(t, committed.Daemons, 2) | |
| 200 | + | require.Equal(t, "user0@host0:22", committed.Daemons[0].Endpoint) | |
| 201 | + | require.Equal(t, "user1@host1:22", committed.Daemons[1].Endpoint) | |
| 202 | + | require.Equal(t, 2, len(committed.Daemons[0].Sessions)) | |
| 203 | + | require.Equal(t, "s00-000", committed.Daemons[0].Sessions[0].Name) | |
| 204 | + | require.Equal(t, "s00-001", committed.Daemons[0].Sessions[1].Name) | |
| 205 | + | require.Equal(t, "s01-000", committed.Daemons[1].Sessions[0].Name) | |
| 179 | 206 | require.Len(t, committed.Removed, 1) | |
| 180 | 207 | require.Equal(t, "retired0@old0:22", committed.Removed[0].Endpoint) | |
| 181 | 208 | _ = connection | |
| @@ -192,16 +219,95 @@ func TestSnapshotAssemblerEmptyTransfer(t *testing.T) { | |||
| 192 | 219 | completed, snapshot, err := a.Add(parts[1]) | |
| 193 | 220 | require.NoError(t, err) | |
| 194 | 221 | require.True(t, completed) | |
| 195 | - | require.Empty(t, snapshot.Hosts) | |
| 222 | + | require.Empty(t, snapshot.Daemons) | |
| 196 | 223 | require.Empty(t, snapshot.Removed) | |
| 197 | 224 | } | |
| 198 | 225 | ||
| 226 | + | // TestSnapshotAssemblerLocalDaemonTransfer proves a local-first transfer with a | |
| 227 | + | // local daemon and its sessions commits with the local entry first, and that | |
| 228 | + | // local sessions bind to index 0 while a mismatched local flag is refused. | |
| 229 | + | func TestSnapshotAssemblerLocalDaemonTransfer(t *testing.T) { | |
| 230 | + | daemons := sampleLocalSnapshotDaemons() | |
| 231 | + | parts := snapshotPartsFor(5, 1, daemons, nil) | |
| 232 | + | require.Len(t, parts, 1+2+3+1) | |
| 233 | + | begin, ok := parts[0].Part.(SnapshotBegin) | |
| 234 | + | require.True(t, ok) | |
| 235 | + | require.True(t, begin.LocalPresent) | |
| 236 | + | require.Equal(t, uint32(2), begin.HostCount) | |
| 237 | + | require.Equal(t, uint32(3), begin.SessionCount) | |
| 238 | + | ||
| 239 | + | a := testSnapshotAssembler(t) | |
| 240 | + | completed, snapshot, err := addAll(a, parts) | |
| 241 | + | require.NoError(t, err) | |
| 242 | + | require.True(t, completed) | |
| 243 | + | require.Len(t, snapshot.Daemons, 2) | |
| 244 | + | require.True(t, snapshot.Daemons[0].Local) | |
| 245 | + | require.Empty(t, snapshot.Daemons[0].Endpoint) | |
| 246 | + | require.Equal(t, "user0@host0:22", snapshot.Daemons[1].Endpoint) | |
| 247 | + | require.Len(t, snapshot.Daemons[0].Sessions, 2) | |
| 248 | + | require.Equal(t, "s00-000", snapshot.Daemons[0].Sessions[0].Name) | |
| 249 | + | require.Len(t, snapshot.Daemons[1].Sessions, 1) | |
| 250 | + | requireValidBrokerSnapshot(t, snapshot) | |
| 251 | + | ||
| 252 | + | t.Run("local session marked remote refused", func(t *testing.T) { | |
| 253 | + | a := testSnapshotAssembler(t) | |
| 254 | + | require.NoError(t, runSnapshotParts(a, parts[:2])) | |
| 255 | + | require.True(t, a.StagingActive()) | |
| 256 | + | mismatched := parts[2] | |
| 257 | + | mismatched.Part = SnapshotSessionPart{HostIndex: 0, SessionIndex: 0, Local: false, Session: testSessionAt(0, 0)} | |
| 258 | + | _, _, err := a.Add(mismatched) | |
| 259 | + | require.ErrorIs(t, err, ErrSnapshotInvalid) | |
| 260 | + | require.False(t, a.StagingActive()) | |
| 261 | + | }) | |
| 262 | + | ||
| 263 | + | t.Run("non-local daemon under local_present refused", func(t *testing.T) { | |
| 264 | + | a := testSnapshotAssembler(t) | |
| 265 | + | begin := parts[0] | |
| 266 | + | begin.Part = SnapshotBegin{HostCount: 1, LocalPresent: true} | |
| 267 | + | require.NoError(t, runSnapshotParts(a, []SnapshotPart{begin})) | |
| 268 | + | daemonPart := parts[1] | |
| 269 | + | daemonPart.Part = SnapshotDaemonPart{HostIndex: 0, Daemon: testDaemonAt(0)} | |
| 270 | + | _, _, err := a.Add(daemonPart) | |
| 271 | + | require.ErrorIs(t, err, ErrSnapshotInvalid) | |
| 272 | + | require.False(t, a.StagingActive()) | |
| 273 | + | }) | |
| 274 | + | ||
| 275 | + | t.Run("local daemon without local_present refused", func(t *testing.T) { | |
| 276 | + | a := testSnapshotAssembler(t) | |
| 277 | + | begin := parts[0] | |
| 278 | + | begin.Part = SnapshotBegin{HostCount: 1} | |
| 279 | + | require.NoError(t, runSnapshotParts(a, []SnapshotPart{begin})) | |
| 280 | + | daemonPart := parts[1] | |
| 281 | + | daemonPart.Part = SnapshotDaemonPart{HostIndex: 0, Daemon: testLocalDaemonAt()} | |
| 282 | + | _, _, err := a.Add(daemonPart) | |
| 283 | + | require.ErrorIs(t, err, ErrSnapshotInvalid) | |
| 284 | + | require.False(t, a.StagingActive()) | |
| 285 | + | }) | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | // addAll stages every part in order and returns the final commit result. | |
| 289 | + | func addAll(a *SnapshotAssembler, parts []SnapshotPart) (bool, ports.BrokerSnapshot, error) { | |
| 290 | + | completed := false | |
| 291 | + | var snapshot ports.BrokerSnapshot | |
| 292 | + | for _, part := range parts { | |
| 293 | + | published, committed, err := a.Add(part) | |
| 294 | + | if err != nil { | |
| 295 | + | return false, ports.BrokerSnapshot{}, err | |
| 296 | + | } | |
| 297 | + | if published { | |
| 298 | + | completed = true | |
| 299 | + | snapshot = committed | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | return completed, snapshot, nil | |
| 303 | + | } | |
| 304 | + | ||
| 199 | 305 | // TestSnapshotAssemblerTransferRejections proves malformed indexes, counts, | |
| 200 | 306 | // order, payloads, and assembled validation failures abort staging and | |
| 201 | 307 | // retain the committed snapshot. | |
| 202 | 308 | func TestSnapshotAssemblerTransferRejections(t *testing.T) { | |
| 203 | 309 | base := func() []SnapshotPart { | |
| 204 | - | return snapshotPartsFor(5, 3, sampleSnapshotHosts(), sampleSnapshotTombstones()) | |
| 310 | + | return snapshotPartsFor(5, 3, sampleSnapshotDaemons(), sampleSnapshotTombstones()) | |
| 205 | 311 | } | |
| 206 | 312 | mutate := func(parts []SnapshotPart, index int, apply func(*SnapshotPart)) []SnapshotPart { | |
| 207 | 313 | out := cloneSnapshotParts(parts) | |
| @@ -217,10 +323,10 @@ func TestSnapshotAssemblerTransferRejections(t *testing.T) { | |||
| 217 | 323 | {"non-begin first", base()[1:], ErrSnapshotInvalid}, | |
| 218 | 324 | {"missing session part", append(cloneSnapshotParts(base()[:3]), base()[4:]...), ErrSnapshotInvalid}, | |
| 219 | 325 | {"duplicate host index", mutate(base(), 4, func(p *SnapshotPart) { | |
| 220 | - | p.Part = SnapshotHostPart{HostIndex: 0, Host: testHostAt(1), SessionCount: 1} | |
| 326 | + | p.Part = SnapshotDaemonPart{HostIndex: 0, Daemon: testDaemonAt(1), SessionCount: 1} | |
| 221 | 327 | }), ErrSnapshotInvalid}, | |
| 222 | 328 | {"host index skips ahead", mutate(base(), 4, func(p *SnapshotPart) { | |
| 223 | - | p.Part = SnapshotHostPart{HostIndex: 5, Host: testHostAt(1), SessionCount: 1} | |
| 329 | + | p.Part = SnapshotDaemonPart{HostIndex: 5, Daemon: testDaemonAt(1), SessionCount: 1} | |
| 224 | 330 | }), ErrSnapshotInvalid}, | |
| 225 | 331 | {"session host index mismatch", mutate(base(), 3, func(p *SnapshotPart) { | |
| 226 | 332 | p.Part = SnapshotSessionPart{HostIndex: 1, SessionIndex: 1, Session: testSessionAt(0, 1)} | |
| @@ -234,15 +340,15 @@ func TestSnapshotAssemblerTransferRejections(t *testing.T) { | |||
| 234 | 340 | p.Part = SnapshotSessionPart{HostIndex: 0, SessionIndex: 0, Session: session} | |
| 235 | 341 | }), ErrSnapshotInvalid}, | |
| 236 | 342 | {"host carries inline sessions", mutate(base(), 1, func(p *SnapshotPart) { | |
| 237 | - | host := testHostAt(0) | |
| 343 | + | host := testDaemonAt(0) | |
| 238 | 344 | host.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(0, 0)} | |
| 239 | - | p.Part = SnapshotHostPart{HostIndex: 0, Host: host, SessionCount: 1} | |
| 345 | + | p.Part = SnapshotDaemonPart{HostIndex: 0, Daemon: host, SessionCount: 1} | |
| 240 | 346 | }), ErrSnapshotInvalid}, | |
| 241 | 347 | {"host session count over maximum", mutate(base(), 1, func(p *SnapshotPart) { | |
| 242 | - | p.Part = SnapshotHostPart{HostIndex: 0, Host: testHostAt(0), SessionCount: uint32(ports.BrokerMaxSessionsPerHost) + 1} | |
| 348 | + | p.Part = SnapshotDaemonPart{HostIndex: 0, Daemon: testDaemonAt(0), SessionCount: uint32(ports.BrokerMaxSessionsPerHost) + 1} | |
| 243 | 349 | }), ErrTooLarge}, | |
| 244 | 350 | {"host session count exceeds advertised total", mutate(base(), 1, func(p *SnapshotPart) { | |
| 245 | - | p.Part = SnapshotHostPart{HostIndex: 0, Host: testHostAt(0), SessionCount: 3} | |
| 351 | + | p.Part = SnapshotDaemonPart{HostIndex: 0, Daemon: testDaemonAt(0), SessionCount: 3} | |
| 246 | 352 | }), ErrSnapshotInvalid}, | |
| 247 | 353 | {"tombstone before all sessions", func() []SnapshotPart { | |
| 248 | 354 | parts := base() | |
| @@ -265,37 +371,37 @@ func TestSnapshotAssemblerTransferRejections(t *testing.T) { | |||
| 265 | 371 | p.Part = SnapshotBegin{HostCount: 2, SessionCount: 4, TombstoneCount: 1} | |
| 266 | 372 | }), ErrSnapshotInvalid}, | |
| 267 | 373 | {"begin host count over maximum", mutate(base(), 0, func(p *SnapshotPart) { | |
| 268 | - | p.Part = SnapshotBegin{HostCount: uint32(ports.BrokerMaxHosts) + 1} | |
| 374 | + | p.Part = SnapshotBegin{HostCount: uint32(ports.BrokerMaxDaemonsPerSnapshot) + 1} | |
| 269 | 375 | }), ErrTooLarge}, | |
| 270 | 376 | {"begin session count over maximum", mutate(base(), 0, func(p *SnapshotPart) { | |
| 271 | - | p.Part = SnapshotBegin{HostCount: 1, SessionCount: uint32(ports.BrokerMaxHosts*ports.BrokerMaxSessionsPerHost) + 1} | |
| 377 | + | p.Part = SnapshotBegin{HostCount: 1, SessionCount: uint32(ports.BrokerMaxDaemonsPerSnapshot*ports.BrokerMaxSessionsPerHost) + 1} | |
| 272 | 378 | }), ErrTooLarge}, | |
| 273 | 379 | {"begin tombstone count over maximum", mutate(base(), 0, func(p *SnapshotPart) { | |
| 274 | 380 | p.Part = SnapshotBegin{HostCount: 1, TombstoneCount: uint32(ports.BrokerMaxTombstones) + 1} | |
| 275 | 381 | }), ErrTooLarge}, | |
| 276 | 382 | {"commit duplicate host endpoint", func() []SnapshotPart { | |
| 277 | - | hosts := sampleSnapshotHosts() | |
| 383 | + | hosts := sampleSnapshotDaemons() | |
| 278 | 384 | hosts[1].Endpoint = hosts[0].Endpoint | |
| 279 | 385 | hosts[1].Registration.Endpoint = hosts[0].Endpoint | |
| 280 | 386 | return snapshotPartsFor(5, 3, hosts, nil) | |
| 281 | 387 | }(), ErrSnapshotInvalid}, | |
| 282 | 388 | {"commit host endpoint registration mismatch", func() []SnapshotPart { | |
| 283 | - | hosts := sampleSnapshotHosts() | |
| 389 | + | hosts := sampleSnapshotDaemons() | |
| 284 | 390 | hosts[0].Endpoint = "other@elsewhere:22" | |
| 285 | 391 | return snapshotPartsFor(5, 3, hosts, nil) | |
| 286 | 392 | }(), ErrSnapshotInvalid}, | |
| 287 | 393 | {"commit invalid projection", func() []SnapshotPart { | |
| 288 | - | hosts := sampleSnapshotHosts() | |
| 394 | + | hosts := sampleSnapshotDaemons() | |
| 289 | 395 | hosts[0].Availability = 0 | |
| 290 | 396 | return snapshotPartsFor(5, 3, hosts, nil) | |
| 291 | 397 | }(), ErrSnapshotInvalid}, | |
| 292 | 398 | {"commit duplicate session name", func() []SnapshotPart { | |
| 293 | - | hosts := sampleSnapshotHosts() | |
| 399 | + | hosts := sampleSnapshotDaemons() | |
| 294 | 400 | hosts[0].Sessions[1].Name = hosts[0].Sessions[0].Name | |
| 295 | 401 | return snapshotPartsFor(5, 3, hosts, nil) | |
| 296 | 402 | }(), ErrSnapshotInvalid}, | |
| 297 | 403 | {"commit tombstone for live host", func() []SnapshotPart { | |
| 298 | - | hosts := sampleSnapshotHosts() | |
| 404 | + | hosts := sampleSnapshotDaemons() | |
| 299 | 405 | tombstone := testTombstoneAt(0) | |
| 300 | 406 | tombstone.Endpoint = hosts[0].Endpoint | |
| 301 | 407 | tombstone.Registration.Endpoint = hosts[0].Endpoint | |
| @@ -319,7 +425,7 @@ func TestSnapshotAssemblerTransferRejections(t *testing.T) { | |||
| 319 | 425 | // survives. | |
| 320 | 426 | func TestSnapshotAssemblerLatePartAfterCommit(t *testing.T) { | |
| 321 | 427 | a := testSnapshotAssembler(t) | |
| 322 | - | parts := snapshotPartsFor(5, 3, sampleSnapshotHosts(), sampleSnapshotTombstones()) | |
| 428 | + | parts := snapshotPartsFor(5, 3, sampleSnapshotDaemons(), sampleSnapshotTombstones()) | |
| 323 | 429 | require.NoError(t, runSnapshotParts(a, parts)) | |
| 324 | 430 | late := parts[len(parts)-1] | |
| 325 | 431 | late.Index = uint32(len(parts)) | |
| @@ -331,13 +437,13 @@ func TestSnapshotAssemblerLatePartAfterCommit(t *testing.T) { | |||
| 331 | 437 | require.Equal(t, ports.BrokerRevision(3), committed.Revision) | |
| 332 | 438 | } | |
| 333 | 439 | ||
| 334 | - | // TestSnapshotAssemblerBounds proves the 16,514-part and 80 MiB staged | |
| 440 | + | // TestSnapshotAssemblerBounds proves the 16,771-part and 80 MiB staged | |
| 335 | 441 | // ceilings: maximum counts commit, one over is refused. | |
| 336 | 442 | func TestSnapshotAssemblerBounds(t *testing.T) { | |
| 337 | 443 | t.Run("maximum counts commit", func(t *testing.T) { | |
| 338 | - | hosts := make([]ports.RemoteHostSnapshot, ports.BrokerMaxHosts) | |
| 444 | + | hosts := make([]ports.BrokerDaemonObservation, ports.BrokerMaxDaemonsPerSnapshot) | |
| 339 | 445 | for i := range hosts { | |
| 340 | - | host := testHostAt(i) | |
| 446 | + | host := testDaemonAt(i) | |
| 341 | 447 | sessions := make([]catalogue.RemoteCatalogSession, ports.BrokerMaxSessionsPerHost) | |
| 342 | 448 | for j := range sessions { | |
| 343 | 449 | sessions[j] = testSessionAt(i, j) | |
| @@ -363,9 +469,9 @@ func TestSnapshotAssemblerBounds(t *testing.T) { | |||
| 363 | 469 | } | |
| 364 | 470 | } | |
| 365 | 471 | require.True(t, completed) | |
| 366 | - | require.Len(t, snapshot.Hosts, ports.BrokerMaxHosts) | |
| 472 | + | require.Len(t, snapshot.Daemons, ports.BrokerMaxDaemonsPerSnapshot) | |
| 367 | 473 | require.Len(t, snapshot.Removed, ports.BrokerMaxTombstones) | |
| 368 | - | for _, host := range snapshot.Hosts { | |
| 474 | + | for _, host := range snapshot.Daemons { | |
| 369 | 475 | require.Len(t, host.Sessions, ports.BrokerMaxSessionsPerHost) | |
| 370 | 476 | } | |
| 371 | 477 | require.Equal(t, ports.BrokerRevision(12), snapshot.Revision) | |
| @@ -375,7 +481,7 @@ func TestSnapshotAssemblerBounds(t *testing.T) { | |||
| 375 | 481 | a := testSnapshotAssembler(t) | |
| 376 | 482 | _, _, err := a.Add(SnapshotPart{ | |
| 377 | 483 | Epoch: 7, Connection: testConnectionID(0x21), Generation: 1, Revision: 1, Index: 0, | |
| 378 | - | Part: SnapshotBegin{HostCount: 1, SessionCount: uint32(ports.BrokerMaxHosts*ports.BrokerMaxSessionsPerHost) + 1}, | |
| 484 | + | Part: SnapshotBegin{HostCount: 1, SessionCount: uint32(ports.BrokerMaxDaemonsPerSnapshot*ports.BrokerMaxSessionsPerHost) + 1}, | |
| 379 | 485 | }) | |
| 380 | 486 | require.ErrorIs(t, err, ErrTooLarge) | |
| 381 | 487 | require.False(t, a.StagingActive()) | |
| @@ -383,7 +489,7 @@ func TestSnapshotAssemblerBounds(t *testing.T) { | |||
| 383 | 489 | ||
| 384 | 490 | t.Run("staged byte ceiling aborts and retains", func(t *testing.T) { | |
| 385 | 491 | a := testSnapshotAssembler(t, WithMaxStagedBytes(200)) | |
| 386 | - | parts := snapshotPartsFor(5, 3, sampleSnapshotHosts(), nil) | |
| 492 | + | parts := snapshotPartsFor(5, 3, sampleSnapshotDaemons(), nil) | |
| 387 | 493 | // Begin (32) + host part stay within the cap; the first session part | |
| 388 | 494 | // crosses it. | |
| 389 | 495 | require.NoError(t, runSnapshotParts(a, parts[:2])) | |
| @@ -441,12 +547,12 @@ func TestSnapshotHostileMaxTabsBound(t *testing.T) { | |||
| 441 | 547 | session := testSessionAt(0, 0) | |
| 442 | 548 | session.Tabs = maxTabs | |
| 443 | 549 | perSession := estimateCatalogSessionBytes(session) | |
| 444 | - | worst := perSession * uint64(ports.BrokerMaxHosts*ports.BrokerMaxSessionsPerHost) | |
| 550 | + | worst := perSession * uint64(ports.BrokerMaxDaemonsPerSnapshot*ports.BrokerMaxSessionsPerHost) | |
| 445 | 551 | require.Greater(t, worst, MaxSnapshotStagedBytes, "hostile max tabs must cross the 80 MiB ceiling") | |
| 446 | 552 | ||
| 447 | - | host := testHostAt(0) | |
| 553 | + | host := testDaemonAt(0) | |
| 448 | 554 | host.Sessions = []catalogue.RemoteCatalogSession{session} | |
| 449 | - | parts := snapshotPartsFor(9, 1, []ports.RemoteHostSnapshot{host}, nil) | |
| 555 | + | parts := snapshotPartsFor(9, 1, []ports.BrokerDaemonObservation{host}, nil) | |
| 450 | 556 | ||
| 451 | 557 | tight := testSnapshotAssembler(t, WithMaxStagedBytes(perSession)) | |
| 452 | 558 | require.NoError(t, runSnapshotParts(tight, parts[:2])) | |
| @@ -459,22 +565,22 @@ func TestSnapshotHostileMaxTabsBound(t *testing.T) { | |||
| 459 | 565 | require.NoError(t, runSnapshotParts(generous, parts)) | |
| 460 | 566 | committed, ok := generous.Snapshot() | |
| 461 | 567 | require.True(t, ok) | |
| 462 | - | require.Len(t, committed.Hosts, 1) | |
| 463 | - | require.Len(t, committed.Hosts[0].Sessions, 1) | |
| 464 | - | require.Len(t, committed.Hosts[0].Sessions[0].Tabs, catalogue.RemoteCatalogMaxTabsPerSess) | |
| 568 | + | require.Len(t, committed.Daemons, 1) | |
| 569 | + | require.Len(t, committed.Daemons[0].Sessions, 1) | |
| 570 | + | require.Len(t, committed.Daemons[0].Sessions[0].Tabs, catalogue.RemoteCatalogMaxTabsPerSess) | |
| 465 | 571 | } | |
| 466 | 572 | ||
| 467 | 573 | // TestSnapshotAssemblerAtomicCommit proves publication happens only at End | |
| 468 | 574 | // and that the returned snapshot is a defensive clone. | |
| 469 | 575 | func TestSnapshotAssemblerAtomicCommit(t *testing.T) { | |
| 470 | 576 | a := testSnapshotAssembler(t) | |
| 471 | - | first := snapshotPartsFor(5, 1, sampleSnapshotHosts(), sampleSnapshotTombstones()) | |
| 577 | + | first := snapshotPartsFor(5, 1, sampleSnapshotDaemons(), sampleSnapshotTombstones()) | |
| 472 | 578 | require.NoError(t, runSnapshotParts(a, first)) | |
| 473 | 579 | committed, ok := a.Snapshot() | |
| 474 | 580 | require.True(t, ok) | |
| 475 | 581 | require.Equal(t, ports.BrokerRevision(1), committed.Revision) | |
| 476 | 582 | ||
| 477 | - | second := snapshotPartsFor(5, 2, sampleSnapshotHosts(), nil) | |
| 583 | + | second := snapshotPartsFor(5, 2, sampleSnapshotDaemons(), nil) | |
| 478 | 584 | for _, part := range second[:len(second)-1] { | |
| 479 | 585 | completed, _, err := a.Add(part) | |
| 480 | 586 | require.NoError(t, err) | |
| @@ -494,18 +600,18 @@ func TestSnapshotAssemblerAtomicCommit(t *testing.T) { | |||
| 494 | 600 | ||
| 495 | 601 | // The committed snapshot is immutable to the caller: mutating a returned | |
| 496 | 602 | // value never reaches the assembler. | |
| 497 | - | published.Hosts[0].Endpoint = "mutated@nowhere:22" | |
| 498 | - | published.Hosts[0].Sessions[0].Tabs = []catalogue.RemoteCatalogTab{{ID: "injected", Index: 0, Name: "injected"}} | |
| 499 | - | published.Hosts[0].Sessions[0].Name = "mutated" | |
| 603 | + | published.Daemons[0].Endpoint = "mutated@nowhere:22" | |
| 604 | + | published.Daemons[0].Sessions[0].Tabs = []catalogue.RemoteCatalogTab{{ID: "injected", Index: 0, Name: "injected"}} | |
| 605 | + | published.Daemons[0].Sessions[0].Name = "mutated" | |
| 500 | 606 | again, ok := a.Snapshot() | |
| 501 | 607 | require.True(t, ok) | |
| 502 | - | require.Equal(t, "user0@host0:22", again.Hosts[0].Endpoint) | |
| 503 | - | require.Equal(t, "s00-000", again.Hosts[0].Sessions[0].Name) | |
| 504 | - | require.Empty(t, again.Hosts[0].Sessions[0].Tabs) | |
| 608 | + | require.Equal(t, "user0@host0:22", again.Daemons[0].Endpoint) | |
| 609 | + | require.Equal(t, "s00-000", again.Daemons[0].Sessions[0].Name) | |
| 610 | + | require.Empty(t, again.Daemons[0].Sessions[0].Tabs) | |
| 505 | 611 | ||
| 506 | 612 | // An aborted transfer retains the committed snapshot. | |
| 507 | - | require.NoError(t, runSnapshotParts(a, snapshotPartsFor(5, 3, sampleSnapshotHosts(), nil)[:2])) | |
| 508 | - | _, _, err = a.Add(snapshotPartsFor(5, 3, sampleSnapshotHosts(), nil)[4]) | |
| 613 | + | require.NoError(t, runSnapshotParts(a, snapshotPartsFor(5, 3, sampleSnapshotDaemons(), nil)[:2])) | |
| 614 | + | _, _, err = a.Add(snapshotPartsFor(5, 3, sampleSnapshotDaemons(), nil)[4]) | |
| 509 | 615 | require.ErrorIs(t, err, ErrSnapshotInvalid) | |
| 510 | 616 | again, ok = a.Snapshot() | |
| 511 | 617 | require.True(t, ok) | |
| @@ -517,13 +623,13 @@ func TestSnapshotAssemblerAtomicCommit(t *testing.T) { | |||
| 517 | 623 | // transfer starts from index 0. | |
| 518 | 624 | func TestSnapshotAssemblerInterruptedRetention(t *testing.T) { | |
| 519 | 625 | a := testSnapshotAssembler(t) | |
| 520 | - | parts := snapshotPartsFor(5, 1, sampleSnapshotHosts(), sampleSnapshotTombstones()) | |
| 626 | + | parts := snapshotPartsFor(5, 1, sampleSnapshotDaemons(), sampleSnapshotTombstones()) | |
| 521 | 627 | require.NoError(t, runSnapshotParts(a, parts)) | |
| 522 | 628 | committed, ok := a.Snapshot() | |
| 523 | 629 | require.True(t, ok) | |
| 524 | 630 | require.Equal(t, ports.BrokerRevision(1), committed.Revision) | |
| 525 | 631 | ||
| 526 | - | restart := snapshotPartsFor(5, 2, sampleSnapshotHosts(), sampleSnapshotTombstones()) | |
| 632 | + | restart := snapshotPartsFor(5, 2, sampleSnapshotDaemons(), sampleSnapshotTombstones()) | |
| 527 | 633 | require.NoError(t, runSnapshotParts(a, restart[:3])) | |
| 528 | 634 | require.True(t, a.StagingActive()) | |
| 529 | 635 | a.DiscardStaging() | |
| @@ -555,7 +661,7 @@ func TestSnapshotAssemblerInterruptedRetention(t *testing.T) { | |||
| 555 | 661 | // staging. | |
| 556 | 662 | func TestSnapshotAssemblerRevisionFencing(t *testing.T) { | |
| 557 | 663 | a := testSnapshotAssembler(t) | |
| 558 | - | hosts := sampleSnapshotHosts() | |
| 664 | + | hosts := sampleSnapshotDaemons() | |
| 559 | 665 | require.NoError(t, runSnapshotParts(a, snapshotPartsFor(5, 5, hosts, nil))) | |
| 560 | 666 | ||
| 561 | 667 | t.Run("older revision refused", func(t *testing.T) { | |
| @@ -607,7 +713,7 @@ func TestSnapshotAssemblerRevisionFencing(t *testing.T) { | |||
| 607 | 713 | // replaces staging: equal revisions restart progress, newer revisions | |
| 608 | 714 | // coalesce. | |
| 609 | 715 | func TestSnapshotAssemblerCoalescing(t *testing.T) { | |
| 610 | - | hosts := sampleSnapshotHosts() | |
| 716 | + | hosts := sampleSnapshotDaemons() | |
| 611 | 717 | base := snapshotPartsFor(5, 3, hosts, nil) | |
| 612 | 718 | ||
| 613 | 719 | a := testSnapshotAssembler(t) | |
| @@ -644,7 +750,7 @@ func TestSnapshotAssemblerCoalescing(t *testing.T) { | |||
| 644 | 750 | // one generation, ignores stale parts, rejects future parts, and refuses a | |
| 645 | 751 | // zero generation. | |
| 646 | 752 | func TestSnapshotAssemblerGenerationFencing(t *testing.T) { | |
| 647 | - | hosts := sampleSnapshotHosts() | |
| 753 | + | hosts := sampleSnapshotDaemons() | |
| 648 | 754 | base := snapshotPartsFor(5, 3, hosts, nil) | |
| 649 | 755 | ||
| 650 | 756 | t.Run("zero generation refused", func(t *testing.T) { | |
| @@ -703,7 +809,7 @@ func TestSnapshotAssemblerGenerationFencing(t *testing.T) { | |||
| 703 | 809 | // TestSnapshotAssemblerScopeFencing proves every part must carry the exact | |
| 704 | 810 | // assigned epoch and connection, whether or not a transfer is in flight. | |
| 705 | 811 | func TestSnapshotAssemblerScopeFencing(t *testing.T) { | |
| 706 | - | base := snapshotPartsFor(5, 3, sampleSnapshotHosts(), nil) | |
| 812 | + | base := snapshotPartsFor(5, 3, sampleSnapshotDaemons(), nil) | |
| 707 | 813 | cases := []struct { | |
| 708 | 814 | name string | |
| 709 | 815 | apply func(*SnapshotPart) | |
| @@ -737,7 +843,7 @@ func TestSnapshotAssemblerScopeFencing(t *testing.T) { | |||
| 737 | 843 | // TestSnapshotAssemblerZeroRevision proves a zero revision is refused. | |
| 738 | 844 | func TestSnapshotAssemblerZeroRevision(t *testing.T) { | |
| 739 | 845 | a := testSnapshotAssembler(t) | |
| 740 | - | part := snapshotPartsFor(5, 3, sampleSnapshotHosts(), nil)[0] | |
| 846 | + | part := snapshotPartsFor(5, 3, sampleSnapshotDaemons(), nil)[0] | |
| 741 | 847 | part.Revision = 0 | |
| 742 | 848 | _, _, err := a.Add(part) | |
| 743 | 849 | require.ErrorIs(t, err, ErrSnapshotInvalid) | |
| @@ -769,13 +875,13 @@ func TestSnapshotAssemblerNilAndEmptySafety(t *testing.T) { | |||
| 769 | 875 | // part from the first one. | |
| 770 | 876 | func TestSnapshotAssemblerPinnedGeneration(t *testing.T) { | |
| 771 | 877 | a := testSnapshotAssembler(t, WithGeneration(5)) | |
| 772 | - | stale := snapshotPartsFor(4, 1, sampleSnapshotHosts(), nil)[0] | |
| 878 | + | stale := snapshotPartsFor(4, 1, sampleSnapshotDaemons(), nil)[0] | |
| 773 | 879 | _, _, err := a.Add(stale) | |
| 774 | 880 | require.ErrorIs(t, err, ErrStaleGeneration) | |
| 775 | - | future := snapshotPartsFor(6, 1, sampleSnapshotHosts(), nil)[0] | |
| 881 | + | future := snapshotPartsFor(6, 1, sampleSnapshotDaemons(), nil)[0] | |
| 776 | 882 | _, _, err = a.Add(future) | |
| 777 | 883 | require.ErrorIs(t, err, ErrFutureGeneration) | |
| 778 | - | require.NoError(t, runSnapshotParts(a, snapshotPartsFor(5, 1, sampleSnapshotHosts(), nil))) | |
| 884 | + | require.NoError(t, runSnapshotParts(a, snapshotPartsFor(5, 1, sampleSnapshotDaemons(), nil))) | |
| 779 | 885 | committed, ok := a.Snapshot() | |
| 780 | 886 | require.True(t, ok) | |
| 781 | 887 | require.Equal(t, ports.BrokerRevision(1), committed.Revision) | |
| @@ -786,7 +892,7 @@ func TestSnapshotAssemblerPinnedGeneration(t *testing.T) { | |||
| 786 | 892 | // keeps publishing it until a newer transfer commits. | |
| 787 | 893 | func TestSnapshotAssemblerAdoptCommitted(t *testing.T) { | |
| 788 | 894 | source := testSnapshotAssembler(t) | |
| 789 | - | require.NoError(t, runSnapshotParts(source, snapshotPartsFor(5, 3, sampleSnapshotHosts(), sampleSnapshotTombstones()))) | |
| 895 | + | require.NoError(t, runSnapshotParts(source, snapshotPartsFor(5, 3, sampleSnapshotDaemons(), sampleSnapshotTombstones()))) | |
| 790 | 896 | committed, ok := source.Snapshot() | |
| 791 | 897 | require.True(t, ok) | |
| 792 | 898 | ||
| @@ -801,24 +907,24 @@ func TestSnapshotAssemblerAdoptCommitted(t *testing.T) { | |||
| 801 | 907 | require.Len(t, adopted.Removed, 1) | |
| 802 | 908 | ||
| 803 | 909 | // An older revision and a foreign epoch are ignored. | |
| 804 | - | target.adoptCommitted(ports.BrokerSnapshot{Epoch: committed.Epoch, Revision: 2, Hosts: committed.Hosts}) | |
| 805 | - | target.adoptCommitted(ports.BrokerSnapshot{Epoch: committed.Epoch + 1, Revision: 9, Hosts: committed.Hosts}) | |
| 910 | + | target.adoptCommitted(ports.BrokerSnapshot{Epoch: committed.Epoch, Revision: 2, Daemons: committed.Daemons}) | |
| 911 | + | target.adoptCommitted(ports.BrokerSnapshot{Epoch: committed.Epoch + 1, Revision: 9, Daemons: committed.Daemons}) | |
| 806 | 912 | adopted, ok = target.Snapshot() | |
| 807 | 913 | require.True(t, ok) | |
| 808 | 914 | require.Equal(t, ports.BrokerRevision(3), adopted.Revision) | |
| 809 | 915 | ||
| 810 | 916 | // A newer transfer commits over the adopted snapshot. | |
| 811 | - | require.NoError(t, runSnapshotParts(target, snapshotPartsFor(6, 4, sampleSnapshotHosts(), nil))) | |
| 917 | + | require.NoError(t, runSnapshotParts(target, snapshotPartsFor(6, 4, sampleSnapshotDaemons(), nil))) | |
| 812 | 918 | adopted, ok = target.Snapshot() | |
| 813 | 919 | require.True(t, ok) | |
| 814 | 920 | require.Equal(t, ports.BrokerRevision(4), adopted.Revision) | |
| 815 | 921 | require.Empty(t, adopted.Removed) | |
| 816 | 922 | ||
| 817 | 923 | // The adopted value is independent of the source. | |
| 818 | - | adopted.Hosts[0].Endpoint = "mutated@nowhere:22" | |
| 924 | + | adopted.Daemons[0].Endpoint = "mutated@nowhere:22" | |
| 819 | 925 | again, ok := source.Snapshot() | |
| 820 | 926 | require.True(t, ok) | |
| 821 | - | require.Equal(t, "user0@host0:22", again.Hosts[0].Endpoint) | |
| 927 | + | require.Equal(t, "user0@host0:22", again.Daemons[0].Endpoint) | |
| 822 | 928 | } | |
| 823 | 929 | ||
| 824 | 930 | // TestSnapshotAssemblerConcurrentReaders is a deterministic race test: one | |
| @@ -849,7 +955,7 @@ func TestSnapshotAssemblerConcurrentReaders(t *testing.T) { | |||
| 849 | 955 | }() | |
| 850 | 956 | } | |
| 851 | 957 | for revision := 1; revision <= revisions; revision++ { | |
| 852 | - | parts := snapshotPartsFor(5, ports.BrokerRevision(revision), sampleSnapshotHosts(), nil) | |
| 958 | + | parts := snapshotPartsFor(5, ports.BrokerRevision(revision), sampleSnapshotDaemons(), nil) | |
| 853 | 959 | for _, part := range parts { | |
| 854 | 960 | _, _, err := a.Add(part) | |
| 855 | 961 | require.NoError(t, err) | |
| @@ -886,7 +992,7 @@ func fuzzSnapshotAssemblerSeeds(tb testing.TB) [][]byte { | |||
| 886 | 992 | require.NoError(tb, err) | |
| 887 | 993 | return raw | |
| 888 | 994 | } | |
| 889 | - | parts := snapshotPartsFor(4, 2, sampleSnapshotHosts(), sampleSnapshotTombstones()) | |
| 995 | + | parts := snapshotPartsFor(4, 2, sampleSnapshotDaemons(), sampleSnapshotTombstones()) | |
| 890 | 996 | frames := make([][]byte, 0, len(parts)) | |
| 891 | 997 | for _, part := range parts { | |
| 892 | 998 | frames = append(frames, encode(part)) | |
| @@ -946,11 +1052,15 @@ func FuzzSnapshotAssembler(f *testing.F) { | |||
| 946 | 1052 | } | |
| 947 | 1053 | ||
| 948 | 1054 | // requireValidBrokerSnapshot asserts one published snapshot is exactly what the | |
| 949 | - | // assembler promises: a validated snapshot with durable host projections. | |
| 1055 | + | // assembler promises: a validated snapshot with durable remote daemon | |
| 1056 | + | // projections (the local daemon is process-local and never durable). | |
| 950 | 1057 | func requireValidBrokerSnapshot(t *testing.T, snapshot ports.BrokerSnapshot) { | |
| 951 | 1058 | t.Helper() | |
| 952 | 1059 | require.NoError(t, snapshot.Validate()) | |
| 953 | - | for _, host := range snapshot.Hosts { | |
| 954 | - | require.NoError(t, ports.ValidateDurableHostProjection(host)) | |
| 1060 | + | for _, daemon := range snapshot.Daemons { | |
| 1061 | + | if daemon.Local { | |
| 1062 | + | continue | |
| 1063 | + | } | |
| 1064 | + | require.NoError(t, ports.ValidateDurableHostProjection(daemon)) | |
| 955 | 1065 | } | |
| 956 | 1066 | } | |
M internal/adapters/brokerwire/state_test.go +11 -11
| @@ -123,7 +123,7 @@ func TestConnStateString(t *testing.T) { | |||
| 123 | 123 | // Register -> Closing -> Closed. | |
| 124 | 124 | func TestConnectionLifecycle(t *testing.T) { | |
| 125 | 125 | op := operationIDAt(1) | |
| 126 | - | part := snapshotPartsFor(1, 1, sampleSnapshotHosts(), nil)[0] | |
| 126 | + | part := snapshotPartsFor(1, 1, sampleSnapshotDaemons(), nil)[0] | |
| 127 | 127 | cases := []struct { | |
| 128 | 128 | name string | |
| 129 | 129 | setup func(*testing.T) *Connection | |
| @@ -285,7 +285,7 @@ func stageConnectionPart(t *testing.T, conn *Connection, part SnapshotPart) { | |||
| 285 | 285 | func TestConnectionSubscriptionStagingRetention(t *testing.T) { | |
| 286 | 286 | conn := readyTestConnection(t) | |
| 287 | 287 | require.NoError(t, conn.Subscribe(1)) | |
| 288 | - | for _, part := range snapshotPartsFor(1, 4, sampleSnapshotHosts(), sampleSnapshotTombstones()) { | |
| 288 | + | for _, part := range snapshotPartsFor(1, 4, sampleSnapshotDaemons(), sampleSnapshotTombstones()) { | |
| 289 | 289 | stageConnectionPart(t, conn, part) | |
| 290 | 290 | } | |
| 291 | 291 | committed, ok := conn.Snapshot() | |
| @@ -293,7 +293,7 @@ func TestConnectionSubscriptionStagingRetention(t *testing.T) { | |||
| 293 | 293 | require.Equal(t, ports.BrokerRevision(4), committed.Revision) | |
| 294 | 294 | ||
| 295 | 295 | // In-flight staging of the active generation is discarded on advance. | |
| 296 | - | inFlight := snapshotPartsFor(1, 6, sampleSnapshotHosts(), nil) | |
| 296 | + | inFlight := snapshotPartsFor(1, 6, sampleSnapshotDaemons(), nil) | |
| 297 | 297 | stageConnectionPart(t, conn, inFlight[0]) | |
| 298 | 298 | require.True(t, conn.StagingActive()) | |
| 299 | 299 | require.NoError(t, conn.Subscribe(2)) | |
| @@ -312,7 +312,7 @@ func TestConnectionSubscriptionStagingRetention(t *testing.T) { | |||
| 312 | 312 | future.Generation = 3 | |
| 313 | 313 | _, _, err = conn.AddSnapshotPart(future) | |
| 314 | 314 | require.ErrorIs(t, err, ErrFutureGeneration) | |
| 315 | - | next := snapshotPartsFor(2, 5, sampleSnapshotHosts(), nil) | |
| 315 | + | next := snapshotPartsFor(2, 5, sampleSnapshotDaemons(), nil) | |
| 316 | 316 | _, _, err = conn.AddSnapshotPart(next[1]) | |
| 317 | 317 | require.ErrorIs(t, err, ErrSnapshotInvalid) | |
| 318 | 318 | require.False(t, conn.StagingActive()) | |
| @@ -352,7 +352,7 @@ func TestConnectionSnapshotParts(t *testing.T) { | |||
| 352 | 352 | t.Run("future generation rejected", func(t *testing.T) { | |
| 353 | 353 | conn := readyTestConnection(t) | |
| 354 | 354 | require.NoError(t, conn.Subscribe(2)) | |
| 355 | - | part := snapshotPartsFor(1, 1, sampleSnapshotHosts(), nil)[0] | |
| 355 | + | part := snapshotPartsFor(1, 1, sampleSnapshotDaemons(), nil)[0] | |
| 356 | 356 | part.Generation = 3 | |
| 357 | 357 | _, _, err := conn.AddSnapshotPart(part) | |
| 358 | 358 | require.ErrorIs(t, err, ErrFutureGeneration) | |
| @@ -364,11 +364,11 @@ func TestConnectionSnapshotParts(t *testing.T) { | |||
| 364 | 364 | t.Run("foreign scope rejected", func(t *testing.T) { | |
| 365 | 365 | conn := readyTestConnection(t) | |
| 366 | 366 | require.NoError(t, conn.Subscribe(2)) | |
| 367 | - | part := snapshotPartsFor(2, 1, sampleSnapshotHosts(), nil)[0] | |
| 367 | + | part := snapshotPartsFor(2, 1, sampleSnapshotDaemons(), nil)[0] | |
| 368 | 368 | part.Epoch = 8 | |
| 369 | 369 | _, _, err := conn.AddSnapshotPart(part) | |
| 370 | 370 | require.ErrorIs(t, err, ErrScopeMismatch) | |
| 371 | - | part = snapshotPartsFor(2, 1, sampleSnapshotHosts(), nil)[0] | |
| 371 | + | part = snapshotPartsFor(2, 1, sampleSnapshotDaemons(), nil)[0] | |
| 372 | 372 | part.Connection = testConnectionID(0x77) | |
| 373 | 373 | _, _, err = conn.AddSnapshotPart(part) | |
| 374 | 374 | require.ErrorIs(t, err, ErrScopeMismatch) | |
| @@ -377,7 +377,7 @@ func TestConnectionSnapshotParts(t *testing.T) { | |||
| 377 | 377 | t.Run("complete transfer through the connection", func(t *testing.T) { | |
| 378 | 378 | conn := readyTestConnection(t) | |
| 379 | 379 | require.NoError(t, conn.Subscribe(9)) | |
| 380 | - | parts := snapshotPartsFor(9, 2, sampleSnapshotHosts(), sampleSnapshotTombstones()) | |
| 380 | + | parts := snapshotPartsFor(9, 2, sampleSnapshotDaemons(), sampleSnapshotTombstones()) | |
| 381 | 381 | var published bool | |
| 382 | 382 | for _, part := range parts { | |
| 383 | 383 | completed, _, err := conn.AddSnapshotPart(part) | |
| @@ -410,7 +410,7 @@ func TestConnectionExactScopeRegistration(t *testing.T) { | |||
| 410 | 410 | _, err = connectionStreams(conn).Close(other, 1) | |
| 411 | 411 | require.ErrorIs(t, err, ErrScopeMismatch) | |
| 412 | 412 | ||
| 413 | - | part := snapshotPartsFor(1, 1, sampleSnapshotHosts(), nil)[0] | |
| 413 | + | part := snapshotPartsFor(1, 1, sampleSnapshotDaemons(), nil)[0] | |
| 414 | 414 | part.Connection = other.Connection | |
| 415 | 415 | part.Epoch = other.Epoch | |
| 416 | 416 | require.NoError(t, conn.Subscribe(1)) | |
| @@ -914,7 +914,7 @@ func TestStreamTrackerConcurrentFrames(t *testing.T) { | |||
| 914 | 914 | func TestConnectionConcurrentWork(t *testing.T) { | |
| 915 | 915 | conn := readyTestConnection(t) | |
| 916 | 916 | require.NoError(t, conn.Subscribe(1)) | |
| 917 | - | hosts := sampleSnapshotHosts() | |
| 917 | + | hosts := sampleSnapshotDaemons() | |
| 918 | 918 | const revisions = 20 | |
| 919 | 919 | const operations = 32 | |
| 920 | 920 | for i := 0; i < operations; i++ { | |
| @@ -1069,7 +1069,7 @@ func TestConnectionCloseRaceNoPostCloseSnapshotCommit(t *testing.T) { | |||
| 1069 | 1069 | for i := 0; i < attempts; i++ { | |
| 1070 | 1070 | conn := readyTestConnection(t) | |
| 1071 | 1071 | require.NoError(t, conn.Subscribe(2)) | |
| 1072 | - | parts := snapshotPartsFor(2, revision, sampleSnapshotHosts(), nil) | |
| 1072 | + | parts := snapshotPartsFor(2, revision, sampleSnapshotDaemons(), nil) | |
| 1073 | 1073 | end := parts[len(parts)-1] | |
| 1074 | 1074 | for _, part := range parts[:len(parts)-1] { | |
| 1075 | 1075 | stageConnectionPart(t, conn, part) | |
M internal/adapters/daemonmux/codec.go +24 -6
| @@ -270,7 +270,7 @@ func openToWire(m Open) (*wire.MuxOpen, error) { | |||
| 270 | 270 | return nil, ErrInvalidMessage | |
| 271 | 271 | } | |
| 272 | 272 | request := ports.BrokerOpenStreamRequest{ | |
| 273 | - | Epoch: m.Ref.Epoch, Purpose: m.Purpose, Local: m.Local, | |
| 273 | + | Epoch: m.Ref.Epoch, Purpose: m.Purpose, Admission: m.Admission, Name: m.Name, Local: m.Local, | |
| 274 | 274 | Connection: m.Ref.Connection, Stream: m.Ref.Client, | |
| 275 | 275 | Endpoint: m.Endpoint, Registration: m.Registration, | |
| 276 | 276 | Target: m.Target, Env: m.Env, Policy: m.Policy, | |
| @@ -304,7 +304,11 @@ func openToWire(m Open) (*wire.MuxOpen, error) { | |||
| 304 | 304 | out.Registration = registrationToWire(m.Registration) | |
| 305 | 305 | } | |
| 306 | 306 | if m.Purpose == ports.BrokerStreamAttachment { | |
| 307 | - | out.Target = exactTargetToWire(m.Target) | |
| 307 | + | out.Admission = uint32(m.Admission) | |
| 308 | + | out.Name = m.Name | |
| 309 | + | if m.Admission == ports.BrokerAdmissionExact { | |
| 310 | + | out.Target = exactTargetToWire(m.Target) | |
| 311 | + | } | |
| 308 | 312 | } | |
| 309 | 313 | return out, nil | |
| 310 | 314 | } | |
| @@ -322,6 +326,16 @@ func openFromWire(message *wire.MuxOpen) (Open, error) { | |||
| 322 | 326 | if err != nil { | |
| 323 | 327 | return Open{}, err | |
| 324 | 328 | } | |
| 329 | + | // Narrow the wire uint32 before the cast: a value whose high bits | |
| 330 | + | // truncate onto a valid admission code would otherwise alias silently. | |
| 331 | + | admission, err := muxEnum8[ports.BrokerStreamAdmission](message.GetAdmission()) | |
| 332 | + | if err != nil { | |
| 333 | + | return Open{}, ErrInvalidMessage | |
| 334 | + | } | |
| 335 | + | name := message.GetName() | |
| 336 | + | if purpose != ports.BrokerStreamAttachment && (admission != 0 || name != "") { | |
| 337 | + | return Open{}, ErrInvalidMessage | |
| 338 | + | } | |
| 325 | 339 | local := message.GetLocal() | |
| 326 | 340 | var registration domain.RemoteRegistration | |
| 327 | 341 | if !local { | |
| @@ -334,8 +348,12 @@ func openFromWire(message *wire.MuxOpen) (Open, error) { | |||
| 334 | 348 | } | |
| 335 | 349 | var target protocol.ExactSessionTarget | |
| 336 | 350 | if purpose == ports.BrokerStreamAttachment { | |
| 337 | - | target, err = exactTargetFromWire(message.GetTarget()) | |
| 338 | - | if err != nil { | |
| 351 | + | if admission == ports.BrokerAdmissionExact { | |
| 352 | + | target, err = exactTargetFromWire(message.GetTarget()) | |
| 353 | + | if err != nil { | |
| 354 | + | return Open{}, ErrInvalidMessage | |
| 355 | + | } | |
| 356 | + | } else if message.GetTarget() != nil { | |
| 339 | 357 | return Open{}, ErrInvalidMessage | |
| 340 | 358 | } | |
| 341 | 359 | } else if message.GetTarget() != nil { | |
| @@ -354,11 +372,11 @@ func openFromWire(message *wire.MuxOpen) (Open, error) { | |||
| 354 | 372 | } | |
| 355 | 373 | endpoint := message.GetEndpoint() | |
| 356 | 374 | candidate := Open{ | |
| 357 | - | Ref: ref, Purpose: purpose, Local: local, Endpoint: endpoint, | |
| 375 | + | Ref: ref, Purpose: purpose, Admission: admission, Name: name, Local: local, Endpoint: endpoint, | |
| 358 | 376 | Registration: registration, Target: target, Env: env, Policy: policy, | |
| 359 | 377 | } | |
| 360 | 378 | request := ports.BrokerOpenStreamRequest{ | |
| 361 | - | Epoch: candidate.Ref.Epoch, Purpose: candidate.Purpose, Local: candidate.Local, | |
| 379 | + | Epoch: candidate.Ref.Epoch, Purpose: candidate.Purpose, Admission: candidate.Admission, Name: candidate.Name, Local: candidate.Local, | |
| 362 | 380 | Connection: candidate.Ref.Connection, Stream: candidate.Ref.Client, | |
| 363 | 381 | Endpoint: candidate.Endpoint, Registration: candidate.Registration, | |
| 364 | 382 | Target: candidate.Target, Env: candidate.Env, Policy: candidate.Policy, | |
M internal/adapters/daemonmux/codec_test.go +119 -1
| @@ -94,7 +94,7 @@ func mustEncodeServer(t *testing.T, message ServerMessage) []byte { | |||
| 94 | 94 | ||
| 95 | 95 | func testOpen() Open { | |
| 96 | 96 | return Open{ | |
| 97 | - | Ref: testRef(5), Purpose: ports.BrokerStreamAttachment, Local: false, | |
| 97 | + | Ref: testRef(5), Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Local: false, | |
| 98 | 98 | Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), | |
| 99 | 99 | Env: []string{"TERM=xterm-256color"}, Policy: testPolicy(), | |
| 100 | 100 | } | |
| @@ -662,3 +662,121 @@ func FuzzDecodeServer(f *testing.F) { | |||
| 662 | 662 | _, _ = DecodeServer(payload, testEnvelopeCeiling, testChunkCeiling) | |
| 663 | 663 | }) | |
| 664 | 664 | } | |
| 665 | + | ||
| 666 | + | // TestMuxAdmissionVariants proves the attachment-admission contract travels | |
| 667 | + | // the mux wire losslessly and rejects every incoherent combination at the | |
| 668 | + | // same boundary the ports contract rejects it. | |
| 669 | + | func TestMuxAdmissionVariants(t *testing.T) { | |
| 670 | + | roundTrip := func(t *testing.T, open Open) Open { | |
| 671 | + | t.Helper() | |
| 672 | + | raw, err := EncodeClient(open, testEnvelopeCeiling, testChunkCeiling) | |
| 673 | + | require.NoError(t, err) | |
| 674 | + | decoded, err := DecodeClient(raw, testEnvelopeCeiling, testChunkCeiling) | |
| 675 | + | require.NoError(t, err) | |
| 676 | + | open, ok := decoded.(Open) | |
| 677 | + | require.True(t, ok) | |
| 678 | + | return open | |
| 679 | + | } | |
| 680 | + | t.Run("exact attach round-trips", func(t *testing.T) { | |
| 681 | + | require.Equal(t, testOpen(), roundTrip(t, testOpen())) | |
| 682 | + | }) | |
| 683 | + | t.Run("named creation round-trips", func(t *testing.T) { | |
| 684 | + | named := testOpen() | |
| 685 | + | named.Admission = ports.BrokerAdmissionCreateNamed | |
| 686 | + | named.Name = "work" | |
| 687 | + | named.Target = protocol.ExactSessionTarget{} | |
| 688 | + | require.Equal(t, named, roundTrip(t, named)) | |
| 689 | + | }) | |
| 690 | + | t.Run("ephemeral creation round-trips", func(t *testing.T) { | |
| 691 | + | ephemeral := testOpen() | |
| 692 | + | ephemeral.Admission = ports.BrokerAdmissionCreateEphemeral | |
| 693 | + | ephemeral.Target = protocol.ExactSessionTarget{} | |
| 694 | + | require.Equal(t, ephemeral, roundTrip(t, ephemeral)) | |
| 695 | + | }) | |
| 696 | + | t.Run("control never carries admission", func(t *testing.T) { | |
| 697 | + | control := Open{Ref: testRef(6), Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()} | |
| 698 | + | require.Equal(t, control, roundTrip(t, control)) | |
| 699 | + | mutated := mustEncodeClient(t, control) | |
| 700 | + | envelope := &wire.MuxClientEnvelope{} | |
| 701 | + | require.NoError(t, proto.Unmarshal(mutated, envelope)) | |
| 702 | + | envelope.GetOpen().Admission = 2 | |
| 703 | + | envelope.GetOpen().Name = "work" | |
| 704 | + | mutated, err := proto.Marshal(envelope) | |
| 705 | + | require.NoError(t, err) | |
| 706 | + | _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling) | |
| 707 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 708 | + | }) | |
| 709 | + | t.Run("exact with a creation name is refused", func(t *testing.T) { | |
| 710 | + | open := testOpen() | |
| 711 | + | open.Name = "work" | |
| 712 | + | _, err := EncodeClient(open, testEnvelopeCeiling, testChunkCeiling) | |
| 713 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 714 | + | }) | |
| 715 | + | t.Run("named creation with a target is refused", func(t *testing.T) { | |
| 716 | + | named := testOpen() | |
| 717 | + | named.Admission = ports.BrokerAdmissionCreateNamed | |
| 718 | + | named.Name = "work" | |
| 719 | + | _, err := EncodeClient(named, testEnvelopeCeiling, testChunkCeiling) | |
| 720 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 721 | + | }) | |
| 722 | + | t.Run("unknown admission code is refused", func(t *testing.T) { | |
| 723 | + | raw := mustEncodeClient(t, testOpen()) | |
| 724 | + | envelope := &wire.MuxClientEnvelope{} | |
| 725 | + | require.NoError(t, proto.Unmarshal(raw, envelope)) | |
| 726 | + | envelope.GetOpen().Admission = 9 | |
| 727 | + | mutated, err := proto.Marshal(envelope) | |
| 728 | + | require.NoError(t, err) | |
| 729 | + | _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling) | |
| 730 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 731 | + | }) | |
| 732 | + | } | |
| 733 | + | ||
| 734 | + | // TestMuxAdmissionWireRange proves the MuxOpen decoder narrows the wire | |
| 735 | + | // admission field before any cast: the accepted semantic set (no admission, or | |
| 736 | + | // one of the three closed admission codes) is unchanged, while every wire value | |
| 737 | + | // whose high bits would alias a valid code is refused instead of silently | |
| 738 | + | // truncating onto that code. | |
| 739 | + | func TestMuxAdmissionWireRange(t *testing.T) { | |
| 740 | + | control := Open{Ref: testRef(6), Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()} | |
| 741 | + | named := testOpen() | |
| 742 | + | named.Admission = ports.BrokerAdmissionCreateNamed | |
| 743 | + | named.Name = "work" | |
| 744 | + | named.Target = protocol.ExactSessionTarget{} | |
| 745 | + | ephemeral := testOpen() | |
| 746 | + | ephemeral.Admission = ports.BrokerAdmissionCreateEphemeral | |
| 747 | + | ephemeral.Target = protocol.ExactSessionTarget{} | |
| 748 | + | ||
| 749 | + | tests := []struct { | |
| 750 | + | name string | |
| 751 | + | base Open | |
| 752 | + | admission uint32 | |
| 753 | + | wantErr bool | |
| 754 | + | }{ | |
| 755 | + | {"no admission on a control stream", control, 0, false}, | |
| 756 | + | {"exact admission", testOpen(), uint32(ports.BrokerAdmissionExact), false}, | |
| 757 | + | {"create-named admission", named, uint32(ports.BrokerAdmissionCreateNamed), false}, | |
| 758 | + | {"create-ephemeral admission", ephemeral, uint32(ports.BrokerAdmissionCreateEphemeral), false}, | |
| 759 | + | {"wire value 257 aliasing exact", testOpen(), 257, true}, | |
| 760 | + | {"wire value 258 aliasing create-named", named, 258, true}, | |
| 761 | + | {"wire value 1024 aliasing no admission", control, 1024, true}, | |
| 762 | + | {"wire value max uint32", control, 1<<32 - 1, true}, | |
| 763 | + | } | |
| 764 | + | for _, tc := range tests { | |
| 765 | + | t.Run(tc.name, func(t *testing.T) { | |
| 766 | + | raw := mustEncodeClient(t, tc.base) | |
| 767 | + | envelope := &wire.MuxClientEnvelope{} | |
| 768 | + | require.NoError(t, proto.Unmarshal(raw, envelope)) | |
| 769 | + | envelope.GetOpen().Admission = tc.admission | |
| 770 | + | mutated, err := proto.Marshal(envelope) | |
| 771 | + | require.NoError(t, err) | |
| 772 | + | decoded, err := DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling) | |
| 773 | + | if tc.wantErr { | |
| 774 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 775 | + | require.Zero(t, decoded, "an out-of-range wire value is never accepted as an alias") | |
| 776 | + | return | |
| 777 | + | } | |
| 778 | + | require.NoError(t, err) | |
| 779 | + | require.Equal(t, tc.base, decoded) | |
| 780 | + | }) | |
| 781 | + | } | |
| 782 | + | } | |
M internal/adapters/daemonmux/messages.go +2 -0
| @@ -80,6 +80,8 @@ func (r StreamRef) Validate() error { | |||
| 80 | 80 | type Open struct { | |
| 81 | 81 | Ref StreamRef | |
| 82 | 82 | Purpose ports.BrokerStreamPurpose | |
| 83 | + | Admission ports.BrokerStreamAdmission | |
| 84 | + | Name string | |
| 83 | 85 | Local bool | |
| 84 | 86 | Endpoint string | |
| 85 | 87 | Registration domain.RemoteRegistration | |
M internal/app/broker_offline_status.go +9 -1
| @@ -627,13 +627,21 @@ func probeBrokerStatus(ctx context.Context, socketPath string) (brokerStatusRepo | |||
| 627 | 627 | } | |
| 628 | 628 | ||
| 629 | 629 | // readyBrokerStatus projects one committed snapshot into the ready report. | |
| 630 | + | // HostCount counts configured remote hosts; the local daemon observation is | |
| 631 | + | // not a configured host. | |
| 630 | 632 | func readyBrokerStatus(socketPath string, snapshot ports.BrokerSnapshot) brokerStatusReport { | |
| 633 | + | hosts := 0 | |
| 634 | + | for _, daemon := range snapshot.Daemons { | |
| 635 | + | if !daemon.Local { | |
| 636 | + | hosts++ | |
| 637 | + | } | |
| 638 | + | } | |
| 631 | 639 | return brokerStatusReport{ | |
| 632 | 640 | Status: "ready", | |
| 633 | 641 | Endpoint: socketPath, | |
| 634 | 642 | Epoch: snapshot.Epoch, | |
| 635 | 643 | Revision: snapshot.Revision, | |
| 636 | - | HostCount: len(snapshot.Hosts), | |
| 644 | + | HostCount: hosts, | |
| 637 | 645 | } | |
| 638 | 646 | } | |
| 639 | 647 | ||
M internal/ports/broker.go +167 -57
| @@ -233,16 +233,18 @@ func (t BrokerHostTombstone) Fences(candidate domain.RemoteRegistration) bool { | |||
| 233 | 233 | t.Registration.Generation >= candidate.Generation | |
| 234 | 234 | } | |
| 235 | 235 | ||
| 236 | - | // BrokerSnapshot is an immutable, fully defensive broker publication. Hosts | |
| 237 | - | // reuse the ordered RemoteHostSnapshot projection; Removed carries the | |
| 238 | - | // bounded tombstone set that fences stale authority. Nested slices are | |
| 239 | - | // never mutated after publication. Removed is process-local fencing state and | |
| 240 | - | // is never durable: a BrokerSnapshotStore persists and reloads hosts without | |
| 241 | - | // tombstones. | |
| 236 | + | // BrokerSnapshot is an immutable, fully defensive broker publication. | |
| 237 | + | // Daemons carries the broker-native daemon observations (Plan 001 P5.2a): | |
| 238 | + | // the local daemon entry first when present, then remote hosts in | |
| 239 | + | // registration order. Removed carries the bounded tombstone set that fences | |
| 240 | + | // stale authority. Nested slices are never mutated after publication. | |
| 241 | + | // Tombstones and the local daemon observation are process-local state and | |
| 242 | + | // are never durable: a BrokerSnapshotStore persists and reloads remote | |
| 243 | + | // daemon observations only. | |
| 242 | 244 | type BrokerSnapshot struct { | |
| 243 | 245 | Epoch BrokerEpoch | |
| 244 | 246 | Revision BrokerRevision | |
| 245 | - | Hosts []RemoteHostSnapshot | |
| 247 | + | Daemons []BrokerDaemonObservation | |
| 246 | 248 | Removed []BrokerHostTombstone | |
| 247 | 249 | } | |
| 248 | 250 | ||
| @@ -253,30 +255,35 @@ func (s BrokerSnapshot) Validate() error { | |||
| 253 | 255 | if s.Revision == 0 { | |
| 254 | 256 | return errors.New("ports: broker snapshot has no revision") | |
| 255 | 257 | } | |
| 256 | - | if len(s.Hosts) > BrokerMaxHosts { | |
| 257 | - | return errors.New("ports: broker snapshot has too many hosts") | |
| 258 | + | if len(s.Daemons) > BrokerMaxDaemonsPerSnapshot { | |
| 259 | + | return errors.New("ports: broker snapshot has too many daemons") | |
| 258 | 260 | } | |
| 259 | - | if len(s.Removed) > BrokerMaxTombstones { | |
| 260 | - | return errors.New("ports: broker snapshot has too many tombstones") | |
| 261 | - | } | |
| 262 | - | seen := make(map[string]struct{}, len(s.Hosts)) | |
| 263 | - | for _, host := range s.Hosts { | |
| 264 | - | if err := domain.ValidateRemoteHostTarget(host.Endpoint); err != nil { | |
| 265 | - | return fmt.Errorf("ports: broker snapshot: %w", err) | |
| 266 | - | } | |
| 267 | - | if err := host.Registration.Validate(); err != nil { | |
| 268 | - | return fmt.Errorf("ports: broker snapshot: %w", err) | |
| 261 | + | local := false | |
| 262 | + | seen := make(map[string]struct{}, len(s.Daemons)) | |
| 263 | + | for i, daemon := range s.Daemons { | |
| 264 | + | if err := daemon.Validate(); err != nil { | |
| 265 | + | return fmt.Errorf("ports: broker snapshot daemon %d: %w", i, err) | |
| 269 | 266 | } | |
| 270 | - | if host.Endpoint != host.Registration.Endpoint { | |
| 271 | - | return errors.New("ports: broker snapshot host endpoint does not match registration") | |
| 267 | + | if daemon.Local { | |
| 268 | + | if local { | |
| 269 | + | return errors.New("ports: broker snapshot has more than one local daemon") | |
| 270 | + | } | |
| 271 | + | // The wire layout carries the local daemon at index zero only, so a | |
| 272 | + | // local observation anywhere else is a publication the peer's | |
| 273 | + | // assembler refuses. Refuse it at the contract gate instead. | |
| 274 | + | if i != 0 { | |
| 275 | + | return fmt.Errorf("ports: broker snapshot carries its local daemon at index %d", i) | |
| 276 | + | } | |
| 277 | + | local = true | |
| 278 | + | continue | |
| 272 | 279 | } | |
| 273 | - | if len(host.Sessions) > BrokerMaxSessionsPerHost { | |
| 274 | - | return errors.New("ports: broker snapshot host has too many sessions") | |
| 280 | + | if _, dup := seen[daemon.Endpoint]; dup { | |
| 281 | + | return fmt.Errorf("ports: broker snapshot has duplicate host %q", daemon.Endpoint) | |
| 275 | 282 | } | |
| 276 | - | if _, dup := seen[host.Endpoint]; dup { | |
| 277 | - | return fmt.Errorf("ports: broker snapshot has duplicate host %q", host.Endpoint) | |
| 278 | - | } | |
| 279 | - | seen[host.Endpoint] = struct{}{} | |
| 283 | + | seen[daemon.Endpoint] = struct{}{} | |
| 284 | + | } | |
| 285 | + | if len(s.Removed) > BrokerMaxTombstones { | |
| 286 | + | return errors.New("ports: broker snapshot has too many tombstones") | |
| 280 | 287 | } | |
| 281 | 288 | for _, tombstone := range s.Removed { | |
| 282 | 289 | if err := tombstone.Validate(); err != nil { | |
| @@ -289,26 +296,27 @@ func (s BrokerSnapshot) Validate() error { | |||
| 289 | 296 | return nil | |
| 290 | 297 | } | |
| 291 | 298 | ||
| 292 | - | // Clone returns a defensive copy with independent host, session, tab, and | |
| 299 | + | // Clone returns a defensive copy with independent daemon, session, and | |
| 293 | 300 | // tombstone slices. | |
| 294 | 301 | func (s BrokerSnapshot) Clone() BrokerSnapshot { | |
| 295 | 302 | out := s | |
| 296 | - | out.Hosts = make([]RemoteHostSnapshot, len(s.Hosts)) | |
| 297 | - | for i, host := range s.Hosts { | |
| 298 | - | out.Hosts[i] = host.Clone() | |
| 303 | + | out.Daemons = make([]BrokerDaemonObservation, len(s.Daemons)) | |
| 304 | + | for i, daemon := range s.Daemons { | |
| 305 | + | out.Daemons[i] = daemon.Clone() | |
| 299 | 306 | } | |
| 300 | 307 | out.Removed = append([]BrokerHostTombstone(nil), s.Removed...) | |
| 301 | 308 | return out | |
| 302 | 309 | } | |
| 303 | 310 | ||
| 304 | - | // Find returns the snapshot for an exact endpoint identity. | |
| 305 | - | func (s BrokerSnapshot) Find(endpoint string) (RemoteHostSnapshot, bool) { | |
| 306 | - | for _, host := range s.Hosts { | |
| 307 | - | if host.Endpoint == endpoint { | |
| 308 | - | return host, true | |
| 311 | + | // Find returns the observation for an exact remote endpoint identity. The | |
| 312 | + | // local daemon has no endpoint and is located by its Local flag. | |
| 313 | + | func (s BrokerSnapshot) Find(endpoint string) (BrokerDaemonObservation, bool) { | |
| 314 | + | for _, daemon := range s.Daemons { | |
| 315 | + | if !daemon.Local && daemon.Endpoint == endpoint { | |
| 316 | + | return daemon, true | |
| 309 | 317 | } | |
| 310 | 318 | } | |
| 311 | - | return RemoteHostSnapshot{}, false | |
| 319 | + | return BrokerDaemonObservation{}, false | |
| 312 | 320 | } | |
| 313 | 321 | ||
| 314 | 322 | // Supersedes reports whether s is newer than other. Any epoch change | |
| @@ -342,14 +350,58 @@ func BrokerCompletionIsStale(currentRegistration domain.RemoteRegistration, curr | |||
| 342 | 350 | return !currentRegistration.Equal(completionRegistration) | |
| 343 | 351 | } | |
| 344 | 352 | ||
| 353 | + | // BrokerStreamAdmission is the closed attachment-admission taxonomy | |
| 354 | + | // (Plan 001 P5.3a, contract-only until the admission slice wires it): | |
| 355 | + | // an attachment stream names exactly how the daemon must admit it. | |
| 356 | + | // Control and observation streams carry no admission. | |
| 357 | + | type BrokerStreamAdmission uint8 | |
| 358 | + | ||
| 359 | + | const ( | |
| 360 | + | // BrokerAdmissionExact attaches to one exact session lifecycle identity; | |
| 361 | + | // the daemon revalidates it before attachment. | |
| 362 | + | BrokerAdmissionExact BrokerStreamAdmission = iota + 1 | |
| 363 | + | // BrokerAdmissionCreateNamed creates one named session when the stream | |
| 364 | + | // attaches; the name is validated session-name authority. | |
| 365 | + | BrokerAdmissionCreateNamed | |
| 366 | + | // BrokerAdmissionCreateEphemeral creates one ephemeral session when the | |
| 367 | + | // stream attaches. | |
| 368 | + | BrokerAdmissionCreateEphemeral | |
| 369 | + | ) | |
| 370 | + | ||
| 371 | + | func (a BrokerStreamAdmission) String() string { | |
| 372 | + | switch a { | |
| 373 | + | case BrokerAdmissionExact: | |
| 374 | + | return "exact" | |
| 375 | + | case BrokerAdmissionCreateNamed: | |
| 376 | + | return "create_named" | |
| 377 | + | case BrokerAdmissionCreateEphemeral: | |
| 378 | + | return "create_ephemeral" | |
| 379 | + | default: | |
| 380 | + | return fmt.Sprintf("invalid(%d)", uint8(a)) | |
| 381 | + | } | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | func (a BrokerStreamAdmission) Validate() error { | |
| 385 | + | switch a { | |
| 386 | + | case BrokerAdmissionExact, BrokerAdmissionCreateNamed, BrokerAdmissionCreateEphemeral: | |
| 387 | + | return nil | |
| 388 | + | default: | |
| 389 | + | return errors.New("ports: invalid broker stream admission") | |
| 390 | + | } | |
| 391 | + | } | |
| 392 | + | ||
| 345 | 393 | // BrokerOpenStreamRequest asks the broker to open one independently | |
| 346 | 394 | // cancellable logical stream to the owning daemon. The daemon revalidates | |
| 347 | - | // the exact session identity before attachment. Env is the per-request | |
| 348 | - | // session environment; it is never inherited from the broker process | |
| 349 | - | // environment. | |
| 395 | + | // the exact session identity before attachment. Admission selects the | |
| 396 | + | // attachment admission variant; Name is the validated session name for | |
| 397 | + | // BrokerAdmissionCreateNamed and is empty for every other variant. Env is | |
| 398 | + | // the per-request session environment; it is never inherited from the | |
| 399 | + | // broker process environment. | |
| 350 | 400 | type BrokerOpenStreamRequest struct { | |
| 351 | 401 | Epoch BrokerEpoch | |
| 352 | 402 | Purpose BrokerStreamPurpose | |
| 403 | + | Admission BrokerStreamAdmission | |
| 404 | + | Name string | |
| 353 | 405 | Local bool | |
| 354 | 406 | Connection BrokerConnectionID | |
| 355 | 407 | Stream BrokerStreamID | |
| @@ -384,11 +436,34 @@ func (r BrokerOpenStreamRequest) Validate() error { | |||
| 384 | 436 | } | |
| 385 | 437 | switch r.Purpose { | |
| 386 | 438 | case BrokerStreamAttachment: | |
| 387 | - | if err := r.Target.Validate(); err != nil { | |
| 439 | + | if err := r.Admission.Validate(); err != nil { | |
| 388 | 440 | return err | |
| 389 | 441 | } | |
| 442 | + | switch r.Admission { | |
| 443 | + | case BrokerAdmissionExact: | |
| 444 | + | if err := r.Target.Validate(); err != nil { | |
| 445 | + | return err | |
| 446 | + | } | |
| 447 | + | if r.Name != "" { | |
| 448 | + | return errors.New("ports: exact admission carries a creation name") | |
| 449 | + | } | |
| 450 | + | case BrokerAdmissionCreateNamed: | |
| 451 | + | if r.Target != (protocol.ExactSessionTarget{}) { | |
| 452 | + | return errors.New("ports: named creation carries an exact target") | |
| 453 | + | } | |
| 454 | + | if err := domain.ValidateSessionName(r.Name); err != nil { | |
| 455 | + | return fmt.Errorf("ports: invalid creation session name: %w", err) | |
| 456 | + | } | |
| 457 | + | case BrokerAdmissionCreateEphemeral: | |
| 458 | + | if r.Target != (protocol.ExactSessionTarget{}) { | |
| 459 | + | return errors.New("ports: ephemeral creation carries an exact target") | |
| 460 | + | } | |
| 461 | + | if r.Name != "" { | |
| 462 | + | return errors.New("ports: ephemeral creation carries a session name") | |
| 463 | + | } | |
| 464 | + | } | |
| 390 | 465 | case BrokerStreamControl, BrokerStreamObservation: | |
| 391 | - | if r.Target != (protocol.ExactSessionTarget{}) || len(r.Env) != 0 { | |
| 466 | + | if r.Admission != 0 || r.Name != "" || r.Target != (protocol.ExactSessionTarget{}) || len(r.Env) != 0 { | |
| 392 | 467 | return errors.New("ports: control/observation carries attachment state") | |
| 393 | 468 | } | |
| 394 | 469 | default: | |
| @@ -644,21 +719,27 @@ type BrokerSnapshotStore interface { | |||
| 644 | 719 | } | |
| 645 | 720 | ||
| 646 | 721 | // BrokerHostProbe observes one exact host registration without creating an | |
| 647 | - | // attachment. The returned projection must describe the same registration and | |
| 648 | - | // must satisfy the durable projection rules (see | |
| 649 | - | // ValidateDurableHostProjection): the registry re-checks exactly those rules, | |
| 650 | - | // so an over-bound or catalogue-invalid session inventory is classified as an | |
| 651 | - | // invalid response and never published. Implementations should therefore | |
| 652 | - | // validate the projection with catalogue.ValidateRemoteCatalog against the | |
| 653 | - | // RemoteCatalogMax* bounds before returning it rather than relying on that | |
| 654 | - | // rejection. Probe must return promptly once ctx is cancelled: the registry | |
| 655 | - | // cancels the attempt context when a registration is replaced or removed, and | |
| 656 | - | // when the run settles. A probe that | |
| 657 | - | // ignores cancellation delays that retirement and can overlap a replacement | |
| 658 | - | // attempt for the same endpoint. Stale completions that arrive anyway are | |
| 659 | - | // fenced by epoch and registration identity before publication. | |
| 722 | + | // attachment. The probe reports only observed state in a | |
| 723 | + | // BrokerDaemonObservation: the observed daemon identity, incarnation, | |
| 724 | + | // protocol version, capabilities, availability, failure, freshness, and | |
| 725 | + | // session inventory. Configured authority (endpoint, registration, policy, | |
| 726 | + | // display origin, rank) is stamped by the registry from its own records and | |
| 727 | + | // never trusted from the probe; unknown identity, version, or inventory is | |
| 728 | + | // zero and is never invented. The returned observation must satisfy the | |
| 729 | + | // durable projection rules (see ValidateDurableHostProjection): the registry | |
| 730 | + | // re-checks exactly those rules, so an over-bound or catalogue-invalid session | |
| 731 | + | // inventory is classified as an invalid response and never published. | |
| 732 | + | // Implementations should therefore validate the observation with | |
| 733 | + | // catalogue.ValidateRemoteCatalog against the RemoteCatalogMax* bounds | |
| 734 | + | // before returning it rather than relying on that rejection. Probe must | |
| 735 | + | // return promptly once ctx is cancelled: the registry cancels the attempt | |
| 736 | + | // context when a registration is replaced or removed, and when the run | |
| 737 | + | // settles. A probe that ignores cancellation delays that retirement and can | |
| 738 | + | // overlap a replacement attempt for the same endpoint. Stale completions | |
| 739 | + | // that arrive anyway are fenced by epoch and registration identity before | |
| 740 | + | // publication. | |
| 660 | 741 | type BrokerHostProbe interface { | |
| 661 | - | Probe(ctx context.Context, registration domain.RemoteRegistration) (RemoteHostSnapshot, error) | |
| 742 | + | Probe(ctx context.Context, registration domain.RemoteRegistration) (BrokerDaemonObservation, error) | |
| 662 | 743 | } | |
| 663 | 744 | ||
| 664 | 745 | // BrokerPhysicalConnection is one pooled physical transport to an | |
| @@ -773,9 +854,38 @@ func validateBrokerDisplayText(value string, maxBytes int, label string) error { | |||
| 773 | 854 | return fmt.Errorf("ports: broker %s is not valid UTF-8", label) | |
| 774 | 855 | } | |
| 775 | 856 | for _, r := range value { | |
| 776 | - | if unicode.IsControl(r) || unicode.Is(unicode.Bidi_Control, r) || r == '\u2028' || r == '\u2029' { | |
| 857 | + | if !brokerDisplayRuneAllowed(r) { | |
| 777 | 858 | return fmt.Errorf("ports: broker %s contains disallowed characters", label) | |
| 778 | 859 | } | |
| 779 | 860 | } | |
| 780 | 861 | return nil | |
| 781 | 862 | } | |
| 863 | + | ||
| 864 | + | // brokerDisplayRuneAllowed reports whether one rune may appear in broker | |
| 865 | + | // display text. Controls, bidi controls, and the Unicode line separators are | |
| 866 | + | // refused: none of them carries presentation value, and each lets a value | |
| 867 | + | // reorder or split the text rendered around it. | |
| 868 | + | func brokerDisplayRuneAllowed(r rune) bool { | |
| 869 | + | return !unicode.IsControl(r) && !unicode.Is(unicode.Bidi_Control, r) && r != '\u2028' && r != '\u2029' | |
| 870 | + | } | |
| 871 | + | ||
| 872 | + | // SanitizeBrokerDisplayText returns value with every rune broker display text | |
| 873 | + | // refuses removed, and always as valid UTF-8. Producers apply it to the | |
| 874 | + | // presentation hints they derive rather than receive: a hint derived from | |
| 875 | + | // routing authority is not itself authority, so a configured target the | |
| 876 | + | // routing validator accepts still yields text the display rule accepts instead | |
| 877 | + | // of a publication the wire refuses. The result is not length-checked; the | |
| 878 | + | // caller applies the bound for its own field. | |
| 879 | + | func SanitizeBrokerDisplayText(value string) string { | |
| 880 | + | if utf8.ValidString(value) && strings.IndexFunc(value, func(r rune) bool { return !brokerDisplayRuneAllowed(r) }) < 0 { | |
| 881 | + | return value | |
| 882 | + | } | |
| 883 | + | var safe strings.Builder | |
| 884 | + | safe.Grow(len(value)) | |
| 885 | + | for _, r := range value { | |
| 886 | + | if brokerDisplayRuneAllowed(r) { | |
| 887 | + | safe.WriteRune(r) | |
| 888 | + | } | |
| 889 | + | } | |
| 890 | + | return safe.String() | |
| 891 | + | } | |
A internal/ports/broker_observation.go +167 -0
| @@ -0,0 +1,167 @@ | |||
| 1 | + | package ports | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "fmt" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // Broker-native daemon observation (Plan 001 P5.2a). | |
| 13 | + | // | |
| 14 | + | // BrokerDaemonObservation is the broker's own projection of one daemon, local | |
| 15 | + | // or remote. It deliberately does not reuse RemoteHostSnapshot: that type | |
| 16 | + | // belongs to the legacy remote monitor and carries no daemon identity, | |
| 17 | + | // incarnation, version, capability, or policy authority. The broker | |
| 18 | + | // projection keeps configured authority separate from observed state: | |
| 19 | + | // | |
| 20 | + | // - configured authority: Local flag, Endpoint, Registration, Policy, and | |
| 21 | + | // the presentation hints DisplayOrigin and Rank. The registry stamps | |
| 22 | + | // them from its own host records (or the local composition) and never | |
| 23 | + | // trusts them from a probe. | |
| 24 | + | // - observed identity: Identity and Incarnation are zero until a probe | |
| 25 | + | // actually authenticated the daemon. Unknown identity is never invented. | |
| 26 | + | // - observed capability: ProtocolVersion and Capabilities mirror what the | |
| 27 | + | // daemon reported. ProtocolVersion is the daemon's own version and may | |
| 28 | + | // differ from Policy.ProtocolVersion; that difference is exactly how an | |
| 29 | + | // incompatible daemon is observable. Zero means unobserved. | |
| 30 | + | // - observation state: Availability, Checking, timestamps, failure | |
| 31 | + | // counters, and the exact session catalogue. Availability zero is | |
| 32 | + | // invalid: an unobserved daemon carries RemoteAvailabilityUnknown. | |
| 33 | + | // | |
| 34 | + | // Observing a daemon never attaches to it and never starts a stopped one. | |
| 35 | + | ||
| 36 | + | const ( | |
| 37 | + | // BrokerMaxDaemonsPerSnapshot bounds one snapshot publication: the local | |
| 38 | + | // daemon plus every configured remote host. | |
| 39 | + | BrokerMaxDaemonsPerSnapshot = BrokerMaxHosts + 1 | |
| 40 | + | // BrokerMaxDisplayOriginBytes bounds the presentation origin hint. | |
| 41 | + | BrokerMaxDisplayOriginBytes = 128 | |
| 42 | + | ) | |
| 43 | + | ||
| 44 | + | // BrokerDaemonObservation is one immutable daemon projection in one broker | |
| 45 | + | // snapshot. Nested slices are never mutated after publication. | |
| 46 | + | type BrokerDaemonObservation struct { | |
| 47 | + | // Local marks the broker's own machine daemon. A local observation has | |
| 48 | + | // no endpoint and no registration: the local daemon is not a configured | |
| 49 | + | // host. At most one local observation exists per snapshot. | |
| 50 | + | Local bool | |
| 51 | + | ||
| 52 | + | // Configured authority (registry-stamped, never probe-supplied). | |
| 53 | + | Endpoint string // remote only; matches Registration.Endpoint | |
| 54 | + | DisplayOrigin string | |
| 55 | + | Rank int | |
| 56 | + | Registration domain.RemoteRegistration | |
| 57 | + | Policy BrokerPolicy | |
| 58 | + | ||
| 59 | + | // Observed identity/incarnation (zero until observed). | |
| 60 | + | Identity BrokerDaemonIdentity | |
| 61 | + | Incarnation BrokerDaemonIncarnation | |
| 62 | + | ||
| 63 | + | // Observed version/capability (zero until observed). | |
| 64 | + | ProtocolVersion uint16 | |
| 65 | + | Capabilities uint32 | |
| 66 | + | ||
| 67 | + | // Availability, checking, failure, and freshness state. | |
| 68 | + | Availability domain.RemoteAvailability | |
| 69 | + | Checking bool | |
| 70 | + | LastAttempt time.Time | |
| 71 | + | LastSuccess time.Time | |
| 72 | + | NextDue time.Time | |
| 73 | + | ConsecutiveFailures uint | |
| 74 | + | FailureEpisode uint64 | |
| 75 | + | LastFailure domain.RemoteFailure | |
| 76 | + | InventoryKnown bool | |
| 77 | + | ||
| 78 | + | // Exact session catalogue observed from the daemon. | |
| 79 | + | Sessions []catalogue.RemoteCatalogSession | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // Validate enforces the closed projection rules. | |
| 83 | + | func (o BrokerDaemonObservation) Validate() error { | |
| 84 | + | // Policy is required authority for every daemon, observed or not. | |
| 85 | + | if err := o.Policy.Validate(); err != nil { | |
| 86 | + | return fmt.Errorf("ports: broker daemon observation: %w", err) | |
| 87 | + | } | |
| 88 | + | if err := validateBrokerDisplayText(o.DisplayOrigin, BrokerMaxDisplayOriginBytes, "display origin"); err != nil { | |
| 89 | + | return fmt.Errorf("ports: broker daemon observation: %w", err) | |
| 90 | + | } | |
| 91 | + | if o.DisplayOrigin == "" { | |
| 92 | + | return errors.New("ports: broker daemon observation has no display origin") | |
| 93 | + | } | |
| 94 | + | if o.Local { | |
| 95 | + | if o.Endpoint != "" || o.Registration != (domain.RemoteRegistration{}) { | |
| 96 | + | return errors.New("ports: local daemon observation carries remote authority") | |
| 97 | + | } | |
| 98 | + | } else { | |
| 99 | + | if err := domain.ValidateRemoteHostTarget(o.Endpoint); err != nil { | |
| 100 | + | return fmt.Errorf("ports: broker daemon observation: %w", err) | |
| 101 | + | } | |
| 102 | + | if err := o.Registration.Validate(); err != nil { | |
| 103 | + | return fmt.Errorf("ports: broker daemon observation: %w", err) | |
| 104 | + | } | |
| 105 | + | if o.Endpoint != o.Registration.Endpoint { | |
| 106 | + | return errors.New("ports: broker daemon observation endpoint does not match registration") | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | if err := validateObservedDaemonState(o); err != nil { | |
| 110 | + | return err | |
| 111 | + | } | |
| 112 | + | if o.Availability < domain.RemoteAvailabilityUnknown || o.Availability > domain.RemoteAvailabilityInvalidResponse { | |
| 113 | + | return errors.New("ports: broker daemon observation availability is out of range") | |
| 114 | + | } | |
| 115 | + | if o.LastFailure.Kind > domain.RemoteFailureInvalidResponse { | |
| 116 | + | return errors.New("ports: broker daemon observation failure kind is out of range") | |
| 117 | + | } | |
| 118 | + | if len(o.Sessions) > BrokerMaxSessionsPerHost { | |
| 119 | + | return errors.New("ports: broker daemon observation has too many sessions") | |
| 120 | + | } | |
| 121 | + | if len(o.Sessions) > 0 && !o.InventoryKnown { | |
| 122 | + | return errors.New("ports: broker daemon observation carries sessions without known inventory") | |
| 123 | + | } | |
| 124 | + | return nil | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // validateObservedDaemonState applies the observed-state rules shared by a live | |
| 128 | + | // publication and a durable projection. Observed identity is all-or-nothing and | |
| 129 | + | // never invented: identity and incarnation appear together, both validate, and | |
| 130 | + | // an observed daemon always carries the protocol version its identity was | |
| 131 | + | // authenticated with. The offline store, a restored durable record, and the | |
| 132 | + | // registry all apply this one rule, so a projection either layer accepts is | |
| 133 | + | // never published as an observation the wire refuses. | |
| 134 | + | func validateObservedDaemonState(observation BrokerDaemonObservation) error { | |
| 135 | + | observed := observation.Identity != "" | |
| 136 | + | if observed != !observation.Incarnation.IsZero() { | |
| 137 | + | return errors.New("ports: broker daemon observation has partial identity") | |
| 138 | + | } | |
| 139 | + | if !observed { | |
| 140 | + | return nil | |
| 141 | + | } | |
| 142 | + | if err := observation.Identity.Validate(); err != nil { | |
| 143 | + | return err | |
| 144 | + | } | |
| 145 | + | if err := observation.Incarnation.Validate(); err != nil { | |
| 146 | + | return err | |
| 147 | + | } | |
| 148 | + | if observation.ProtocolVersion == 0 { | |
| 149 | + | return errors.New("ports: observed daemon identity carries no protocol version") | |
| 150 | + | } | |
| 151 | + | return nil | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // Clone returns a defensive copy with an independent session slice. Each | |
| 155 | + | // session keeps the exact nil-ness of its tab list: an empty but present list | |
| 156 | + | // is catalogue-valid and must not become absent through a copy. | |
| 157 | + | func (o BrokerDaemonObservation) Clone() BrokerDaemonObservation { | |
| 158 | + | out := o | |
| 159 | + | out.Sessions = append([]catalogue.RemoteCatalogSession(nil), o.Sessions...) | |
| 160 | + | for i := range out.Sessions { | |
| 161 | + | if o.Sessions[i].Tabs == nil { | |
| 162 | + | continue | |
| 163 | + | } | |
| 164 | + | out.Sessions[i].Tabs = append(make([]catalogue.RemoteCatalogTab, 0, len(o.Sessions[i].Tabs)), o.Sessions[i].Tabs...) | |
| 165 | + | } | |
| 166 | + | return out | |
| 167 | + | } | |
A internal/ports/broker_observation_test.go +170 -0
| @@ -0,0 +1,170 @@ | |||
| 1 | + | package ports | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/bnema/vev/internal/protocol" | |
| 9 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestBrokerDaemonObservationValidate(t *testing.T) { | |
| 14 | + | observed := func(o BrokerDaemonObservation) BrokerDaemonObservation { | |
| 15 | + | o.Identity = BrokerDaemonIdentity("authed-daemon") | |
| 16 | + | o.Incarnation = BrokerDaemonIncarnation{9} | |
| 17 | + | o.ProtocolVersion = protocol.Version | |
| 18 | + | o.Capabilities = protocol.CapabilityResume | protocol.CapabilityUDP | |
| 19 | + | return o | |
| 20 | + | } | |
| 21 | + | tests := []struct { | |
| 22 | + | name string | |
| 23 | + | mutate func(*BrokerDaemonObservation) | |
| 24 | + | valid bool | |
| 25 | + | }{ | |
| 26 | + | {name: "unobserved remote daemon", mutate: func(*BrokerDaemonObservation) {}, valid: true}, | |
| 27 | + | {name: "unknown availability", mutate: func(o *BrokerDaemonObservation) { | |
| 28 | + | o.Availability = domain.RemoteAvailabilityUnknown | |
| 29 | + | }, valid: true}, | |
| 30 | + | {name: "observed daemon", mutate: func(o *BrokerDaemonObservation) { *o = observed(*o) }, valid: true}, | |
| 31 | + | {name: "observed daemon without capabilities", mutate: func(o *BrokerDaemonObservation) { | |
| 32 | + | *o = observed(*o) | |
| 33 | + | o.Capabilities = 0 | |
| 34 | + | }, valid: true}, | |
| 35 | + | {name: "observed daemon reports a different protocol version", mutate: func(o *BrokerDaemonObservation) { | |
| 36 | + | *o = observed(*o) | |
| 37 | + | o.ProtocolVersion = protocol.Version + 1 | |
| 38 | + | o.Availability = domain.RemoteAvailabilityIncompatible | |
| 39 | + | }, valid: true}, | |
| 40 | + | {name: "observed identity without protocol version", mutate: func(o *BrokerDaemonObservation) { | |
| 41 | + | *o = observed(*o) | |
| 42 | + | o.ProtocolVersion = 0 | |
| 43 | + | }, valid: false}, | |
| 44 | + | {name: "identity without incarnation", mutate: func(o *BrokerDaemonObservation) { | |
| 45 | + | o.Identity = BrokerDaemonIdentity("authed-daemon") | |
| 46 | + | }, valid: false}, | |
| 47 | + | {name: "incarnation without identity", mutate: func(o *BrokerDaemonObservation) { | |
| 48 | + | o.Incarnation = BrokerDaemonIncarnation{9} | |
| 49 | + | }, valid: false}, | |
| 50 | + | {name: "empty display origin", mutate: func(o *BrokerDaemonObservation) { | |
| 51 | + | o.DisplayOrigin = "" | |
| 52 | + | }, valid: false}, | |
| 53 | + | {name: "control characters in display origin", mutate: func(o *BrokerDaemonObservation) { | |
| 54 | + | o.DisplayOrigin = "bad\x1border" | |
| 55 | + | }, valid: false}, | |
| 56 | + | {name: "oversized display origin", mutate: func(o *BrokerDaemonObservation) { | |
| 57 | + | o.DisplayOrigin = strings.Repeat("a", BrokerMaxDisplayOriginBytes+1) | |
| 58 | + | }, valid: false}, | |
| 59 | + | {name: "missing policy", mutate: func(o *BrokerDaemonObservation) { | |
| 60 | + | o.Policy = BrokerPolicy{} | |
| 61 | + | }, valid: false}, | |
| 62 | + | {name: "zero availability is never invented", mutate: func(o *BrokerDaemonObservation) { | |
| 63 | + | o.Availability = 0 | |
| 64 | + | }, valid: false}, | |
| 65 | + | {name: "failure kind past the closed range", mutate: func(o *BrokerDaemonObservation) { | |
| 66 | + | o.LastFailure.Kind = domain.RemoteFailureInvalidResponse + 1 | |
| 67 | + | }, valid: false}, | |
| 68 | + | {name: "empty inventory is known inventory", mutate: func(o *BrokerDaemonObservation) { | |
| 69 | + | o.InventoryKnown = true | |
| 70 | + | }, valid: true}, | |
| 71 | + | {name: "sessions without known inventory", mutate: func(o *BrokerDaemonObservation) { | |
| 72 | + | o.Sessions = []catalogue.RemoteCatalogSession{{ | |
| 73 | + | LifecycleID: testBrokerLifecycle(), | |
| 74 | + | Name: "work", | |
| 75 | + | State: catalogue.RemoteCatalogSessionUp, | |
| 76 | + | Tabs: []catalogue.RemoteCatalogTab{}, | |
| 77 | + | }} | |
| 78 | + | }, valid: false}, | |
| 79 | + | {name: "local daemon without remote authority", mutate: func(o *BrokerDaemonObservation) { | |
| 80 | + | *o = testLocalBrokerObservation() | |
| 81 | + | }, valid: true}, | |
| 82 | + | {name: "local daemon carrying an endpoint", mutate: func(o *BrokerDaemonObservation) { | |
| 83 | + | *o = testLocalBrokerObservation() | |
| 84 | + | o.Endpoint = "user@arch" | |
| 85 | + | }, valid: false}, | |
| 86 | + | {name: "local daemon carrying a registration", mutate: func(o *BrokerDaemonObservation) { | |
| 87 | + | *o = testLocalBrokerObservation() | |
| 88 | + | o.Registration = testBrokerRegistration("user@arch", 1, 1) | |
| 89 | + | }, valid: false}, | |
| 90 | + | {name: "remote daemon missing registration", mutate: func(o *BrokerDaemonObservation) { | |
| 91 | + | o.Registration = domain.RemoteRegistration{} | |
| 92 | + | }, valid: false}, | |
| 93 | + | {name: "endpoint registration mismatch", mutate: func(o *BrokerDaemonObservation) { | |
| 94 | + | o.Registration = testBrokerRegistration("user@other", 1, 1) | |
| 95 | + | }, valid: false}, | |
| 96 | + | } | |
| 97 | + | for _, tc := range tests { | |
| 98 | + | t.Run(tc.name, func(t *testing.T) { | |
| 99 | + | obs := testBrokerObservation("user@arch") | |
| 100 | + | tc.mutate(&obs) | |
| 101 | + | if (obs.Validate() == nil) != tc.valid { | |
| 102 | + | t.Fatalf("Validate() = %v, want valid %t", obs.Validate(), tc.valid) | |
| 103 | + | } | |
| 104 | + | }) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // TestBrokerDaemonObservationValidateDisplayOrigin pins the display-origin | |
| 109 | + | // bound and character rules with exact refusals: a bidi control or a Unicode | |
| 110 | + | // line separator is refused as disallowed text, an origin at the byte bound is | |
| 111 | + | // accepted, and one byte over is refused. | |
| 112 | + | func TestBrokerDaemonObservationValidateDisplayOrigin(t *testing.T) { | |
| 113 | + | tests := []struct { | |
| 114 | + | name string | |
| 115 | + | origin string | |
| 116 | + | wantErrContains string | |
| 117 | + | }{ | |
| 118 | + | { | |
| 119 | + | name: "bidi control is refused", | |
| 120 | + | origin: "user\u202earch", | |
| 121 | + | wantErrContains: "display origin contains disallowed characters", | |
| 122 | + | }, | |
| 123 | + | { | |
| 124 | + | name: "line separator is refused", | |
| 125 | + | origin: "user@arch\u2028", | |
| 126 | + | wantErrContains: "display origin contains disallowed characters", | |
| 127 | + | }, | |
| 128 | + | { | |
| 129 | + | name: "origin at the byte bound is accepted", | |
| 130 | + | origin: strings.Repeat("a", BrokerMaxDisplayOriginBytes), | |
| 131 | + | }, | |
| 132 | + | { | |
| 133 | + | name: "origin one byte over the bound is refused", | |
| 134 | + | origin: strings.Repeat("a", BrokerMaxDisplayOriginBytes+1), | |
| 135 | + | wantErrContains: "display origin too long", | |
| 136 | + | }, | |
| 137 | + | } | |
| 138 | + | for _, tc := range tests { | |
| 139 | + | t.Run(tc.name, func(t *testing.T) { | |
| 140 | + | obs := testBrokerObservation("user@arch") | |
| 141 | + | obs.DisplayOrigin = tc.origin | |
| 142 | + | err := obs.Validate() | |
| 143 | + | if tc.wantErrContains != "" { | |
| 144 | + | require.ErrorContains(t, err, tc.wantErrContains) | |
| 145 | + | return | |
| 146 | + | } | |
| 147 | + | require.NoError(t, err) | |
| 148 | + | }) | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func TestBrokerDaemonObservationCloneIsDefensive(t *testing.T) { | |
| 153 | + | original := testBrokerObservation("user@arch") | |
| 154 | + | original.InventoryKnown = true | |
| 155 | + | original.Sessions = []catalogue.RemoteCatalogSession{{ | |
| 156 | + | LifecycleID: testBrokerLifecycle(), | |
| 157 | + | Name: "work", | |
| 158 | + | State: catalogue.RemoteCatalogSessionUp, | |
| 159 | + | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "shell"}}, | |
| 160 | + | }} | |
| 161 | + | cloned := original.Clone() | |
| 162 | + | cloned.Sessions[0].Name = "mutated" | |
| 163 | + | cloned.Sessions[0].Tabs[0].ID = "mutated" | |
| 164 | + | if original.Sessions[0].Name != "work" || original.Sessions[0].Tabs[0].ID != "tab-1" { | |
| 165 | + | t.Fatal("Clone() shares memory with the original") | |
| 166 | + | } | |
| 167 | + | if (BrokerDaemonObservation{}).Clone().Sessions != nil { | |
| 168 | + | t.Fatal("Clone() of an empty observation must keep a nil session slice") | |
| 169 | + | } | |
| 170 | + | } | |
M internal/ports/broker_store.go +28 -16
| @@ -94,28 +94,40 @@ type BrokerHostStore interface { | |||
| 94 | 94 | // one seam. This guard keeps that composition compile-checked. | |
| 95 | 95 | var _ BrokerSnapshotStore = (BrokerHostStore)(nil) | |
| 96 | 96 | ||
| 97 | - | // ValidateDurableHostProjection reports whether one host projection satisfies | |
| 98 | - | // the semantic rules of durable broker state: a published availability and | |
| 99 | - | // failure kind inside their closed ranges, and a session inventory the | |
| 100 | - | // catalogue accepts for the host's exact incarnation and success time. The | |
| 101 | - | // offline store and the registry both apply this rule, so a projection the | |
| 102 | - | // registry publishes is never rejected (and therefore silently never | |
| 103 | - | // persisted) by the store. Transient state (Checking and the live failure | |
| 104 | - | // cause) is deliberately not part of the durable rule. | |
| 105 | - | func ValidateDurableHostProjection(host RemoteHostSnapshot) error { | |
| 106 | - | if host.Availability < domain.RemoteAvailabilityUnknown || host.Availability > domain.RemoteAvailabilityInvalidResponse { | |
| 97 | + | // ValidateDurableHostProjection reports whether one daemon observation | |
| 98 | + | // satisfies the rules the offline store and wire accept: a local observation | |
| 99 | + | // is never durable, availability and failure kinds stay in their closed | |
| 100 | + | // ranges, and a present session inventory is a valid catalogue cache entry | |
| 101 | + | // against the exact registration incarnation. The offline store and the | |
| 102 | + | // registry both apply this rule, so a projection the registry publishes is | |
| 103 | + | // never rejected (and therefore silently never persisted) by the store. | |
| 104 | + | // Transient state (Checking and the live failure cause) is deliberately not | |
| 105 | + | // part of the durable rule. | |
| 106 | + | func ValidateDurableHostProjection(obs BrokerDaemonObservation) error { | |
| 107 | + | if obs.Local { | |
| 108 | + | return errors.New("ports: local daemon observation is never durable") | |
| 109 | + | } | |
| 110 | + | if obs.Availability < domain.RemoteAvailabilityUnknown || obs.Availability > domain.RemoteAvailabilityInvalidResponse { | |
| 107 | 111 | return errors.New("ports: broker host availability is out of range") | |
| 108 | 112 | } | |
| 109 | - | if host.LastFailure.Kind > domain.RemoteFailureInvalidResponse { | |
| 113 | + | if obs.LastFailure.Kind > domain.RemoteFailureInvalidResponse { | |
| 110 | 114 | return errors.New("ports: broker host failure kind is out of range") | |
| 111 | 115 | } | |
| 112 | - | if len(host.Sessions) == 0 { | |
| 116 | + | // Observed identity is part of the durable rule, not only of the live one: a | |
| 117 | + | // restored record with partial identity or without the protocol version its | |
| 118 | + | // identity was authenticated with would otherwise reach a publication the | |
| 119 | + | // wire refuses, taking down every client connection instead of failing | |
| 120 | + | // closed here. | |
| 121 | + | if err := validateObservedDaemonState(obs); err != nil { | |
| 122 | + | return err | |
| 123 | + | } | |
| 124 | + | if len(obs.Sessions) == 0 { | |
| 113 | 125 | return nil | |
| 114 | 126 | } | |
| 115 | 127 | return catalogue.ValidateRemoteCatalogCacheEntries([]catalogue.RemoteCatalogCacheEntry{{ | |
| 116 | - | Host: host.Endpoint, | |
| 117 | - | Incarnation: host.Registration.Incarnation, | |
| 118 | - | FetchedAt: host.LastSuccess, | |
| 119 | - | Sessions: host.Sessions, | |
| 128 | + | Host: obs.Endpoint, | |
| 129 | + | Incarnation: obs.Registration.Incarnation, | |
| 130 | + | FetchedAt: obs.LastSuccess, | |
| 131 | + | Sessions: obs.Sessions, | |
| 120 | 132 | }}) | |
| 121 | 133 | } | |
A internal/ports/broker_store_test.go +70 -0
| @@ -0,0 +1,70 @@ | |||
| 1 | + | package ports | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/protocol" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // TestValidateDurableHostProjectionIdentity pins the durable rule to the live | |
| 11 | + | // observation rule: an identity and incarnation must appear together, and an | |
| 12 | + | // observed identity must carry the protocol version it was authenticated with. | |
| 13 | + | // A restored durable record that violates either rule would otherwise reach a | |
| 14 | + | // publication the wire refuses. | |
| 15 | + | func TestValidateDurableHostProjectionIdentity(t *testing.T) { | |
| 16 | + | observed := func(o BrokerDaemonObservation) BrokerDaemonObservation { | |
| 17 | + | o.Identity = BrokerDaemonIdentity("authed-daemon") | |
| 18 | + | o.Incarnation = BrokerDaemonIncarnation{9} | |
| 19 | + | o.ProtocolVersion = protocol.Version | |
| 20 | + | return o | |
| 21 | + | } | |
| 22 | + | tests := []struct { | |
| 23 | + | name string | |
| 24 | + | mutate func(*BrokerDaemonObservation) | |
| 25 | + | wantErrContains string | |
| 26 | + | }{ | |
| 27 | + | { | |
| 28 | + | name: "no identity is accepted", | |
| 29 | + | mutate: func(*BrokerDaemonObservation) {}, | |
| 30 | + | }, | |
| 31 | + | { | |
| 32 | + | name: "identity and incarnation with a protocol version are accepted", | |
| 33 | + | mutate: func(o *BrokerDaemonObservation) { *o = observed(*o) }, | |
| 34 | + | }, | |
| 35 | + | { | |
| 36 | + | name: "identity with zero protocol version is refused", | |
| 37 | + | mutate: func(o *BrokerDaemonObservation) { | |
| 38 | + | *o = observed(*o) | |
| 39 | + | o.ProtocolVersion = 0 | |
| 40 | + | }, | |
| 41 | + | wantErrContains: "carries no protocol version", | |
| 42 | + | }, | |
| 43 | + | { | |
| 44 | + | name: "identity without incarnation is refused", | |
| 45 | + | mutate: func(o *BrokerDaemonObservation) { | |
| 46 | + | o.Identity = BrokerDaemonIdentity("authed-daemon") | |
| 47 | + | }, | |
| 48 | + | wantErrContains: "partial identity", | |
| 49 | + | }, | |
| 50 | + | { | |
| 51 | + | name: "incarnation without identity is refused", | |
| 52 | + | mutate: func(o *BrokerDaemonObservation) { | |
| 53 | + | o.Incarnation = BrokerDaemonIncarnation{9} | |
| 54 | + | }, | |
| 55 | + | wantErrContains: "partial identity", | |
| 56 | + | }, | |
| 57 | + | } | |
| 58 | + | for _, tc := range tests { | |
| 59 | + | t.Run(tc.name, func(t *testing.T) { | |
| 60 | + | obs := testBrokerObservation("user@arch") | |
| 61 | + | tc.mutate(&obs) | |
| 62 | + | err := ValidateDurableHostProjection(obs) | |
| 63 | + | if tc.wantErrContains != "" { | |
| 64 | + | require.ErrorContains(t, err, tc.wantErrContains) | |
| 65 | + | return | |
| 66 | + | } | |
| 67 | + | require.NoError(t, err) | |
| 68 | + | }) | |
| 69 | + | } | |
| 70 | + | } | |
M internal/ports/broker_test.go +189 -47
| @@ -5,10 +5,12 @@ import ( | |||
| 5 | 5 | "strings" | |
| 6 | 6 | "testing" | |
| 7 | 7 | "time" | |
| 8 | + | "unicode/utf8" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/vev/internal/domain" | |
| 10 | 11 | "github.com/bnema/vev/internal/protocol" | |
| 11 | 12 | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 12 | 14 | ) | |
| 13 | 15 | ||
| 14 | 16 | func testBrokerRegistration(endpoint string, incarnation byte, generation domain.RemoteGeneration) domain.RemoteRegistration { | |
| @@ -37,11 +39,21 @@ func testBrokerPolicy() BrokerPolicy { | |||
| 37 | 39 | } | |
| 38 | 40 | } | |
| 39 | 41 | ||
| 40 | - | func testBrokerHost(endpoint string) RemoteHostSnapshot { | |
| 41 | - | return RemoteHostSnapshot{ | |
| 42 | + | func testBrokerObservation(endpoint string) BrokerDaemonObservation { | |
| 43 | + | return BrokerDaemonObservation{ | |
| 42 | 44 | Endpoint: endpoint, | |
| 43 | 45 | DisplayOrigin: endpoint, | |
| 44 | 46 | Registration: testBrokerRegistration(endpoint, 1, 1), | |
| 47 | + | Policy: testBrokerPolicy(), | |
| 48 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func testLocalBrokerObservation() BrokerDaemonObservation { | |
| 53 | + | return BrokerDaemonObservation{ | |
| 54 | + | Local: true, | |
| 55 | + | DisplayOrigin: "local", | |
| 56 | + | Policy: testBrokerPolicy(), | |
| 45 | 57 | Availability: domain.RemoteAvailabilityReachable, | |
| 46 | 58 | } | |
| 47 | 59 | } | |
| @@ -50,7 +62,7 @@ func validBrokerSnapshot() BrokerSnapshot { | |||
| 50 | 62 | return BrokerSnapshot{ | |
| 51 | 63 | Epoch: 3, | |
| 52 | 64 | Revision: 9, | |
| 53 | - | Hosts: []RemoteHostSnapshot{testBrokerHost("user@arch")}, | |
| 65 | + | Daemons: []BrokerDaemonObservation{testBrokerObservation("user@arch")}, | |
| 54 | 66 | } | |
| 55 | 67 | } | |
| 56 | 68 | ||
| @@ -76,36 +88,45 @@ func TestValidateDurableHostProjection(t *testing.T) { | |||
| 76 | 88 | } | |
| 77 | 89 | tests := []struct { | |
| 78 | 90 | name string | |
| 79 | - | mutate func(*RemoteHostSnapshot) | |
| 91 | + | mutate func(*BrokerDaemonObservation) | |
| 80 | 92 | wantErr bool | |
| 81 | 93 | }{ | |
| 82 | - | {name: "empty inventory", mutate: func(*RemoteHostSnapshot) {}}, | |
| 83 | - | {name: "valid inventory", mutate: func(h *RemoteHostSnapshot) { h.Sessions = []catalogue.RemoteCatalogSession{session} }}, | |
| 84 | - | {name: "zero availability", mutate: func(h *RemoteHostSnapshot) { h.Availability = 0 }, wantErr: true}, | |
| 85 | - | {name: "unknown availability", mutate: func(h *RemoteHostSnapshot) { h.Availability = domain.RemoteAvailabilityUnknown }}, | |
| 86 | - | {name: "availability past the closed range", mutate: func(h *RemoteHostSnapshot) { h.Availability = domain.RemoteAvailabilityInvalidResponse + 1 }, wantErr: true}, | |
| 87 | - | {name: "failure kind past the closed range", mutate: func(h *RemoteHostSnapshot) { h.LastFailure.Kind = domain.RemoteFailureInvalidResponse + 1 }, wantErr: true}, | |
| 88 | - | {name: "missing lifecycle identity", mutate: func(h *RemoteHostSnapshot) { | |
| 89 | - | h.Sessions = []catalogue.RemoteCatalogSession{{Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: []catalogue.RemoteCatalogTab{}}} | |
| 94 | + | {name: "empty inventory", mutate: func(*BrokerDaemonObservation) {}}, | |
| 95 | + | {name: "valid inventory", mutate: func(o *BrokerDaemonObservation) { | |
| 96 | + | o.InventoryKnown = true | |
| 97 | + | o.Sessions = []catalogue.RemoteCatalogSession{session} | |
| 98 | + | }}, | |
| 99 | + | {name: "local observation", mutate: func(o *BrokerDaemonObservation) { | |
| 100 | + | *o = testLocalBrokerObservation() | |
| 101 | + | }, wantErr: true}, | |
| 102 | + | {name: "zero availability", mutate: func(o *BrokerDaemonObservation) { o.Availability = 0 }, wantErr: true}, | |
| 103 | + | {name: "unknown availability", mutate: func(o *BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityUnknown }}, | |
| 104 | + | {name: "availability past the closed range", mutate: func(o *BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityInvalidResponse + 1 }, wantErr: true}, | |
| 105 | + | {name: "failure kind past the closed range", mutate: func(o *BrokerDaemonObservation) { o.LastFailure.Kind = domain.RemoteFailureInvalidResponse + 1 }, wantErr: true}, | |
| 106 | + | {name: "missing lifecycle identity", mutate: func(o *BrokerDaemonObservation) { | |
| 107 | + | o.InventoryKnown = true | |
| 108 | + | o.Sessions = []catalogue.RemoteCatalogSession{{Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: []catalogue.RemoteCatalogTab{}}} | |
| 90 | 109 | }, wantErr: true}, | |
| 91 | - | {name: "absent tab list", mutate: func(h *RemoteHostSnapshot) { | |
| 110 | + | {name: "absent tab list", mutate: func(o *BrokerDaemonObservation) { | |
| 92 | 111 | broken := session | |
| 93 | 112 | broken.Tabs = nil | |
| 94 | - | h.Sessions = []catalogue.RemoteCatalogSession{broken} | |
| 113 | + | o.InventoryKnown = true | |
| 114 | + | o.Sessions = []catalogue.RemoteCatalogSession{broken} | |
| 95 | 115 | }, wantErr: true}, | |
| 96 | - | {name: "sessions past the catalogue bound", mutate: func(h *RemoteHostSnapshot) { | |
| 97 | - | h.Sessions = make([]catalogue.RemoteCatalogSession, catalogue.RemoteCatalogMaxSessions+1) | |
| 98 | - | for i := range h.Sessions { | |
| 99 | - | h.Sessions[i] = session | |
| 116 | + | {name: "sessions past the catalogue bound", mutate: func(o *BrokerDaemonObservation) { | |
| 117 | + | o.InventoryKnown = true | |
| 118 | + | o.Sessions = make([]catalogue.RemoteCatalogSession, catalogue.RemoteCatalogMaxSessions+1) | |
| 119 | + | for i := range o.Sessions { | |
| 120 | + | o.Sessions[i] = session | |
| 100 | 121 | } | |
| 101 | 122 | }, wantErr: true}, | |
| 102 | 123 | } | |
| 103 | 124 | for _, tc := range tests { | |
| 104 | 125 | t.Run(tc.name, func(t *testing.T) { | |
| 105 | - | host := testBrokerHost("user@arch") | |
| 106 | - | host.LastSuccess = time.Unix(50, 0) | |
| 107 | - | tc.mutate(&host) | |
| 108 | - | if gotErr := ValidateDurableHostProjection(host); (gotErr != nil) != tc.wantErr { | |
| 126 | + | obs := testBrokerObservation("user@arch") | |
| 127 | + | obs.LastSuccess = time.Unix(50, 0) | |
| 128 | + | tc.mutate(&obs) | |
| 129 | + | if gotErr := ValidateDurableHostProjection(obs); (gotErr != nil) != tc.wantErr { | |
| 109 | 130 | t.Fatalf("ValidateDurableHostProjection() = %v, wantErr %t", gotErr, tc.wantErr) | |
| 110 | 131 | } | |
| 111 | 132 | }) | |
| @@ -121,21 +142,27 @@ func TestBrokerSnapshotValidateBounds(t *testing.T) { | |||
| 121 | 142 | {name: "valid", mutate: func(*BrokerSnapshot) {}, wantErr: false}, | |
| 122 | 143 | {name: "zero epoch", mutate: func(s *BrokerSnapshot) { s.Epoch = 0 }, wantErr: true}, | |
| 123 | 144 | {name: "zero revision", mutate: func(s *BrokerSnapshot) { s.Revision = 0 }, wantErr: true}, | |
| 124 | - | {name: "too many hosts", mutate: func(s *BrokerSnapshot) { | |
| 125 | - | s.Hosts = make([]RemoteHostSnapshot, BrokerMaxHosts+1) | |
| 126 | - | for i := range s.Hosts { | |
| 127 | - | s.Hosts[i] = testBrokerHost("user@arch") | |
| 128 | - | s.Hosts[i].Registration = testBrokerRegistration("user@arch", 1, 1) | |
| 145 | + | {name: "too many daemons", mutate: func(s *BrokerSnapshot) { | |
| 146 | + | s.Daemons = make([]BrokerDaemonObservation, BrokerMaxDaemonsPerSnapshot+1) | |
| 147 | + | for i := range s.Daemons { | |
| 148 | + | s.Daemons[i] = testBrokerObservation("user@arch") | |
| 149 | + | s.Daemons[i].Registration = testBrokerRegistration("user@arch", 1, 1) | |
| 129 | 150 | } | |
| 130 | 151 | }, wantErr: true}, | |
| 152 | + | {name: "local daemon plus remote hosts is valid", mutate: func(s *BrokerSnapshot) { | |
| 153 | + | s.Daemons = append([]BrokerDaemonObservation{testLocalBrokerObservation()}, s.Daemons...) | |
| 154 | + | }}, | |
| 155 | + | {name: "two local daemons", mutate: func(s *BrokerSnapshot) { | |
| 156 | + | s.Daemons = append(s.Daemons, testLocalBrokerObservation(), testLocalBrokerObservation()) | |
| 157 | + | }, wantErr: true}, | |
| 131 | 158 | {name: "duplicate host", mutate: func(s *BrokerSnapshot) { | |
| 132 | - | s.Hosts = append(s.Hosts, testBrokerHost("user@arch")) | |
| 159 | + | s.Daemons = append(s.Daemons, testBrokerObservation("user@arch")) | |
| 133 | 160 | }, wantErr: true}, | |
| 134 | 161 | {name: "endpoint registration mismatch", mutate: func(s *BrokerSnapshot) { | |
| 135 | - | s.Hosts[0].Registration = testBrokerRegistration("user@other", 1, 1) | |
| 162 | + | s.Daemons[0].Registration = testBrokerRegistration("user@other", 1, 1) | |
| 136 | 163 | }, wantErr: true}, | |
| 137 | 164 | {name: "zero registration incarnation", mutate: func(s *BrokerSnapshot) { | |
| 138 | - | s.Hosts[0].Registration = domain.RemoteRegistration{Endpoint: "user@arch", Generation: 1} | |
| 165 | + | s.Daemons[0].Registration = domain.RemoteRegistration{Endpoint: "user@arch", Generation: 1} | |
| 139 | 166 | }, wantErr: true}, | |
| 140 | 167 | {name: "live host as tombstone", mutate: func(s *BrokerSnapshot) { | |
| 141 | 168 | s.Removed = []BrokerHostTombstone{{ | |
| @@ -228,7 +255,9 @@ func TestBrokerSnapshotEpochRevisionOrdering(t *testing.T) { | |||
| 228 | 255 | ||
| 229 | 256 | func TestBrokerSnapshotCloneAndFind(t *testing.T) { | |
| 230 | 257 | original := validBrokerSnapshot() | |
| 231 | - | original.Hosts[0].Sessions = []catalogue.RemoteCatalogSession{{ | |
| 258 | + | original.Daemons = append([]BrokerDaemonObservation{testLocalBrokerObservation()}, original.Daemons...) | |
| 259 | + | original.Daemons[1].InventoryKnown = true | |
| 260 | + | original.Daemons[1].Sessions = []catalogue.RemoteCatalogSession{{ | |
| 232 | 261 | LifecycleID: testBrokerLifecycle(), | |
| 233 | 262 | Name: "work", | |
| 234 | 263 | State: catalogue.RemoteCatalogSessionUp, | |
| @@ -241,9 +270,18 @@ func TestBrokerSnapshotCloneAndFind(t *testing.T) { | |||
| 241 | 270 | if _, ok := cloned.Find("user@mule"); ok { | |
| 242 | 271 | t.Fatal("Find() must miss unknown endpoints") | |
| 243 | 272 | } | |
| 244 | - | cloned.Hosts[0].Sessions[0].Name = "mutated" | |
| 245 | - | cloned.Hosts[0].Sessions[0].Tabs[0].ID = "mutated" | |
| 246 | - | if original.Hosts[0].Sessions[0].Name != "work" || original.Hosts[0].Sessions[0].Tabs[0].ID != "tab-1" { | |
| 273 | + | local := 0 | |
| 274 | + | for _, daemon := range cloned.Daemons { | |
| 275 | + | if daemon.Local { | |
| 276 | + | local++ | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | if local != 1 { | |
| 280 | + | t.Fatalf("Clone() lost the local daemon entry: %d local entries", local) | |
| 281 | + | } | |
| 282 | + | cloned.Daemons[1].Sessions[0].Name = "mutated" | |
| 283 | + | cloned.Daemons[1].Sessions[0].Tabs[0].ID = "mutated" | |
| 284 | + | if original.Daemons[1].Sessions[0].Name != "work" || original.Daemons[1].Sessions[0].Tabs[0].ID != "tab-1" { | |
| 247 | 285 | t.Fatal("Clone() shares memory with the original") | |
| 248 | 286 | } | |
| 249 | 287 | } | |
| @@ -352,6 +390,7 @@ func TestBrokerOpenStreamRequestValidate(t *testing.T) { | |||
| 352 | 390 | valid := func() BrokerOpenStreamRequest { | |
| 353 | 391 | return BrokerOpenStreamRequest{ | |
| 354 | 392 | Epoch: 1, Purpose: BrokerStreamAttachment, | |
| 393 | + | Admission: BrokerAdmissionExact, | |
| 355 | 394 | Connection: BrokerConnectionID{1}, | |
| 356 | 395 | Stream: 2, | |
| 357 | 396 | Endpoint: "user@arch", | |
| @@ -379,6 +418,26 @@ func TestBrokerOpenStreamRequestValidate(t *testing.T) { | |||
| 379 | 418 | {name: "bad session name", mutate: func(r *BrokerOpenStreamRequest) { | |
| 380 | 419 | r.Target = protocol.ExactSessionTarget{LifecycleID: testBrokerLifecycle(), SessionName: "bad name!"} | |
| 381 | 420 | }}, | |
| 421 | + | {name: "exact admission carries a creation name", mutate: func(r *BrokerOpenStreamRequest) { | |
| 422 | + | r.Name = "work" | |
| 423 | + | }}, | |
| 424 | + | {name: "named creation carries an exact target", mutate: func(r *BrokerOpenStreamRequest) { | |
| 425 | + | r.Admission = BrokerAdmissionCreateNamed | |
| 426 | + | r.Name = "work" | |
| 427 | + | }}, | |
| 428 | + | {name: "named creation with invalid name", mutate: func(r *BrokerOpenStreamRequest) { | |
| 429 | + | r.Admission = BrokerAdmissionCreateNamed | |
| 430 | + | r.Name = "bad name!" | |
| 431 | + | r.Target = protocol.ExactSessionTarget{} | |
| 432 | + | }}, | |
| 433 | + | {name: "ephemeral creation carries a name", mutate: func(r *BrokerOpenStreamRequest) { | |
| 434 | + | r.Admission = BrokerAdmissionCreateEphemeral | |
| 435 | + | r.Target = protocol.ExactSessionTarget{} | |
| 436 | + | r.Name = "work" | |
| 437 | + | }}, | |
| 438 | + | {name: "ephemeral creation carries a target", mutate: func(r *BrokerOpenStreamRequest) { | |
| 439 | + | r.Admission = BrokerAdmissionCreateEphemeral | |
| 440 | + | }}, | |
| 382 | 441 | {name: "too many env entries", mutate: func(r *BrokerOpenStreamRequest) { | |
| 383 | 442 | r.Env = make([]string, BrokerMaxEnvEntries+1) | |
| 384 | 443 | }}, | |
| @@ -546,24 +605,107 @@ func TestBrokerInterfacesArePorts(t *testing.T) { | |||
| 546 | 605 | } | |
| 547 | 606 | } | |
| 548 | 607 | ||
| 608 | + | // TestSanitizeBrokerDisplayText pins the sanitizer to the display validator: | |
| 609 | + | // every result is valid UTF-8 and carries no rune validateBrokerDisplayText | |
| 610 | + | // refuses, so a producer can sanitize a derived presentation hint and never | |
| 611 | + | // publish a value its own contract rejects. | |
| 612 | + | func TestSanitizeBrokerDisplayText(t *testing.T) { | |
| 613 | + | tests := []struct { | |
| 614 | + | name string | |
| 615 | + | input string | |
| 616 | + | want string | |
| 617 | + | }{ | |
| 618 | + | {name: "ordinary text is returned unchanged", input: "user@arch", want: "user@arch"}, | |
| 619 | + | {name: "login-prefixed origin is returned unchanged", input: "user@host.example:2222", want: "user@host.example:2222"}, | |
| 620 | + | {name: "accented and emoji text is kept intact", input: "caf\u00e9 \U0001f5a5", want: "caf\u00e9 \U0001f5a5"}, | |
| 621 | + | {name: "right-to-left override is dropped", input: "sh\u202eell", want: "shell"}, | |
| 622 | + | {name: "left-to-right embedding is dropped", input: "a\u202ab", want: "ab"}, | |
| 623 | + | {name: "C0 control is dropped", input: "a\x00b\x1bc", want: "abc"}, | |
| 624 | + | {name: "line separator is dropped", input: "a\u2028b", want: "ab"}, | |
| 625 | + | {name: "paragraph separator is dropped", input: "a\u2029b", want: "ab"}, | |
| 626 | + | {name: "invalid utf-8 is replaced", input: "ok\xff\xfe", want: "ok\ufffd\ufffd"}, | |
| 627 | + | {name: "every rune disallowed yields empty", input: "\u202e\u2028\x00", want: ""}, | |
| 628 | + | } | |
| 629 | + | for _, tc := range tests { | |
| 630 | + | t.Run(tc.name, func(t *testing.T) { | |
| 631 | + | got := SanitizeBrokerDisplayText(tc.input) | |
| 632 | + | require.Equal(t, tc.want, got) | |
| 633 | + | require.True(t, utf8.ValidString(got), "sanitized text must always be valid UTF-8") | |
| 634 | + | require.Equal(t, -1, strings.IndexFunc(got, func(r rune) bool { return !brokerDisplayRuneAllowed(r) }), "sanitized text must carry no disallowed rune") | |
| 635 | + | require.NoError(t, validateBrokerDisplayText(got, BrokerMaxDisplayOriginBytes, "display origin")) | |
| 636 | + | }) | |
| 637 | + | } | |
| 638 | + | } | |
| 639 | + | ||
| 640 | + | // TestBrokerSnapshotValidateLocalDaemonPlacement pins the wire placement rule: | |
| 641 | + | // the local daemon observation is carried at index zero only, a local | |
| 642 | + | // observation anywhere else is refused, and a local daemon is still refused | |
| 643 | + | // when it appears twice. | |
| 644 | + | func TestBrokerSnapshotValidateLocalDaemonPlacement(t *testing.T) { | |
| 645 | + | tests := []struct { | |
| 646 | + | name string | |
| 647 | + | daemons []BrokerDaemonObservation | |
| 648 | + | wantErrContains string | |
| 649 | + | }{ | |
| 650 | + | { | |
| 651 | + | name: "local daemon first", | |
| 652 | + | daemons: []BrokerDaemonObservation{testLocalBrokerObservation(), testBrokerObservation("user@arch")}, | |
| 653 | + | }, | |
| 654 | + | { | |
| 655 | + | name: "local daemon at index one is refused", | |
| 656 | + | daemons: []BrokerDaemonObservation{testBrokerObservation("user@arch"), testLocalBrokerObservation()}, | |
| 657 | + | wantErrContains: "local daemon at index 1", | |
| 658 | + | }, | |
| 659 | + | { | |
| 660 | + | name: "local daemon after two remotes is refused", | |
| 661 | + | daemons: []BrokerDaemonObservation{testBrokerObservation("user@arch"), testBrokerObservation("user@other"), testLocalBrokerObservation()}, | |
| 662 | + | wantErrContains: "local daemon at index 2", | |
| 663 | + | }, | |
| 664 | + | { | |
| 665 | + | name: "two local daemons are refused", | |
| 666 | + | daemons: []BrokerDaemonObservation{testLocalBrokerObservation(), testLocalBrokerObservation()}, | |
| 667 | + | wantErrContains: "more than one local daemon", | |
| 668 | + | }, | |
| 669 | + | } | |
| 670 | + | for _, tc := range tests { | |
| 671 | + | t.Run(tc.name, func(t *testing.T) { | |
| 672 | + | snapshot := validBrokerSnapshot() | |
| 673 | + | snapshot.Daemons = tc.daemons | |
| 674 | + | err := snapshot.Validate() | |
| 675 | + | if tc.wantErrContains != "" { | |
| 676 | + | require.ErrorContains(t, err, tc.wantErrContains) | |
| 677 | + | return | |
| 678 | + | } | |
| 679 | + | require.NoError(t, err) | |
| 680 | + | }) | |
| 681 | + | } | |
| 682 | + | } | |
| 683 | + | ||
| 549 | 684 | func TestBrokerStreamPurposeShapes(t *testing.T) { | |
| 550 | 685 | for _, tc := range []struct { | |
| 551 | - | name string | |
| 552 | - | purpose BrokerStreamPurpose | |
| 553 | - | target bool | |
| 554 | - | env bool | |
| 555 | - | valid bool | |
| 686 | + | name string | |
| 687 | + | purpose BrokerStreamPurpose | |
| 688 | + | admission BrokerStreamAdmission | |
| 689 | + | createName string | |
| 690 | + | target bool | |
| 691 | + | env bool | |
| 692 | + | valid bool | |
| 556 | 693 | }{ | |
| 557 | - | {"attachment", BrokerStreamAttachment, true, true, true}, | |
| 558 | - | {"attachment missing target", BrokerStreamAttachment, false, false, false}, | |
| 559 | - | {"control", BrokerStreamControl, false, false, true}, | |
| 560 | - | {"observation", BrokerStreamObservation, false, false, true}, | |
| 561 | - | {"control target", BrokerStreamControl, true, false, false}, | |
| 562 | - | {"observation env", BrokerStreamObservation, false, true, false}, | |
| 563 | - | {"unknown", 0, false, false, false}, | |
| 694 | + | {"attachment exact", BrokerStreamAttachment, BrokerAdmissionExact, "", true, true, true}, | |
| 695 | + | {"attachment missing target", BrokerStreamAttachment, BrokerAdmissionExact, "", false, false, false}, | |
| 696 | + | {"attachment missing admission", BrokerStreamAttachment, 0, "", true, false, false}, | |
| 697 | + | {"attachment named creation", BrokerStreamAttachment, BrokerAdmissionCreateNamed, "work", false, true, true}, | |
| 698 | + | {"attachment ephemeral creation", BrokerStreamAttachment, BrokerAdmissionCreateEphemeral, "", false, true, true}, | |
| 699 | + | {"control", BrokerStreamControl, 0, "", false, false, true}, | |
| 700 | + | {"observation", BrokerStreamObservation, 0, "", false, false, true}, | |
| 701 | + | {"control target", BrokerStreamControl, 0, "", true, false, false}, | |
| 702 | + | {"control admission", BrokerStreamControl, BrokerAdmissionExact, "", false, false, false}, | |
| 703 | + | {"observation env", BrokerStreamObservation, 0, "", false, true, false}, | |
| 704 | + | {"observation admission", BrokerStreamObservation, BrokerAdmissionCreateEphemeral, "", false, false, false}, | |
| 705 | + | {"unknown", 0, 0, "", false, false, false}, | |
| 564 | 706 | } { | |
| 565 | 707 | t.Run(tc.name, func(t *testing.T) { | |
| 566 | - | r := BrokerOpenStreamRequest{Epoch: 1, Connection: BrokerConnectionID{1}, Stream: 1, Local: true, Purpose: tc.purpose, Policy: testBrokerPolicy()} | |
| 708 | + | r := BrokerOpenStreamRequest{Epoch: 1, Connection: BrokerConnectionID{1}, Stream: 1, Local: true, Purpose: tc.purpose, Admission: tc.admission, Name: tc.createName, Policy: testBrokerPolicy()} | |
| 567 | 709 | if tc.target { | |
| 568 | 710 | r.Target = testBrokerTarget() | |
| 569 | 711 | } | |
M internal/ports/mocks/broker_mock.go +8 -8
| @@ -871,22 +871,22 @@ func (_m *MockBrokerHostProbe) EXPECT() *MockBrokerHostProbe_Expecter { | |||
| 871 | 871 | } | |
| 872 | 872 | ||
| 873 | 873 | // Probe provides a mock function for the type MockBrokerHostProbe | |
| 874 | - | func (_mock *MockBrokerHostProbe) Probe(ctx context.Context, registration domain.RemoteRegistration) (ports.RemoteHostSnapshot, error) { | |
| 874 | + | func (_mock *MockBrokerHostProbe) Probe(ctx context.Context, registration domain.RemoteRegistration) (ports.BrokerDaemonObservation, error) { | |
| 875 | 875 | ret := _mock.Called(ctx, registration) | |
| 876 | 876 | ||
| 877 | 877 | if len(ret) == 0 { | |
| 878 | 878 | panic("no return value specified for Probe") | |
| 879 | 879 | } | |
| 880 | 880 | ||
| 881 | - | var r0 ports.RemoteHostSnapshot | |
| 881 | + | var r0 ports.BrokerDaemonObservation | |
| 882 | 882 | var r1 error | |
| 883 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RemoteRegistration) (ports.RemoteHostSnapshot, error)); ok { | |
| 883 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RemoteRegistration) (ports.BrokerDaemonObservation, error)); ok { | |
| 884 | 884 | return returnFunc(ctx, registration) | |
| 885 | 885 | } | |
| 886 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RemoteRegistration) ports.RemoteHostSnapshot); ok { | |
| 886 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RemoteRegistration) ports.BrokerDaemonObservation); ok { | |
| 887 | 887 | r0 = returnFunc(ctx, registration) | |
| 888 | 888 | } else { | |
| 889 | - | r0 = ret.Get(0).(ports.RemoteHostSnapshot) | |
| 889 | + | r0 = ret.Get(0).(ports.BrokerDaemonObservation) | |
| 890 | 890 | } | |
| 891 | 891 | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RemoteRegistration) error); ok { | |
| 892 | 892 | r1 = returnFunc(ctx, registration) | |
| @@ -926,12 +926,12 @@ func (_c *MockBrokerHostProbe_Probe_Call) Run(run func(ctx context.Context, regi | |||
| 926 | 926 | return _c | |
| 927 | 927 | } | |
| 928 | 928 | ||
| 929 | - | func (_c *MockBrokerHostProbe_Probe_Call) Return(remoteHostSnapshot ports.RemoteHostSnapshot, err error) *MockBrokerHostProbe_Probe_Call { | |
| 930 | - | _c.Call.Return(remoteHostSnapshot, err) | |
| 929 | + | func (_c *MockBrokerHostProbe_Probe_Call) Return(brokerDaemonObservation ports.BrokerDaemonObservation, err error) *MockBrokerHostProbe_Probe_Call { | |
| 930 | + | _c.Call.Return(brokerDaemonObservation, err) | |
| 931 | 931 | return _c | |
| 932 | 932 | } | |
| 933 | 933 | ||
| 934 | - | func (_c *MockBrokerHostProbe_Probe_Call) RunAndReturn(run func(ctx context.Context, registration domain.RemoteRegistration) (ports.RemoteHostSnapshot, error)) *MockBrokerHostProbe_Probe_Call { | |
| 934 | + | func (_c *MockBrokerHostProbe_Probe_Call) RunAndReturn(run func(ctx context.Context, registration domain.RemoteRegistration) (ports.BrokerDaemonObservation, error)) *MockBrokerHostProbe_Probe_Call { | |
| 935 | 935 | _c.Call.Return(run) | |
| 936 | 936 | return _c | |
| 937 | 937 | } | |
M internal/protocol/session.go +1 -1
| @@ -16,7 +16,7 @@ import ( | |||
| 16 | 16 | // daemonmux physical preamble conversation (multiplex.proto), whose | |
| 17 | 17 | // negotiated ceilings and accepted daemon binding ride the same wire | |
| 18 | 18 | // version as the session and broker conversations. | |
| 19 | - | const Version uint16 = 54 | |
| 19 | + | const Version uint16 = 55 | |
| 20 | 20 | ||
| 21 | 21 | // HandshakeTimeout bounds every transport handshake from connect through the | |
| 22 | 22 | // first committed publication. It excludes the preceding client-local | |
M internal/protocol/session_test.go +1 -1
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | func TestVersionRemainsWireCompatible(t *testing.T) { | |
| 13 | - | require.Equal(t, uint16(54), Version) | |
| 13 | + | require.Equal(t, uint16(55), Version) | |
| 14 | 14 | require.Equal(t, (16<<20)-407, MaxOutputDataLen) | |
| 15 | 15 | } | |
| 16 | 16 | ||
M internal/protocol/wire/broker.pb.go +223 -106 generated file: collapsed
M internal/protocol/wire/broker_test.go +16 -13
| @@ -178,9 +178,12 @@ func brokerServerSamples() map[string]*BrokerServerEnvelope { | |||
| 178 | 178 | "registered": {Payload: &BrokerServerEnvelope_Registered{Registered: &Registered{Scope: brokerScope()}}}, | |
| 179 | 179 | "snapshot_part": {Payload: &BrokerServerEnvelope_SnapshotPart{SnapshotPart: &SnapshotPart{ | |
| 180 | 180 | Scope: brokerScope(), Generation: 2, Revision: 5, Index: 9, | |
| 181 | - | Part: &SnapshotPart_Host{Host: &SnapshotHost{ | |
| 181 | + | Part: &SnapshotPart_Daemon{Daemon: &SnapshotDaemon{ | |
| 182 | 182 | HostIndex: 1, Endpoint: "dev@host:22", DisplayOrigin: "dev@host", | |
| 183 | - | Rank: 2, Registration: brokerRegistration(), Availability: 1, | |
| 183 | + | Rank: 2, Registration: brokerRegistration(), Policy: &BrokerWirePolicy{ProtocolVersion: 55}, | |
| 184 | + | DaemonIdentity: "authed-daemon", DaemonIncarnation: bytes.Repeat([]byte{0x9}, 16), | |
| 185 | + | ProtocolVersion: 55, Capabilities: 1, | |
| 186 | + | Availability: 1, | |
| 184 | 187 | Checking: true, | |
| 185 | 188 | LastAttempt: &BrokerTimestamp{Seconds: 1700000000, Nanos: 1}, | |
| 186 | 189 | LastSuccess: &BrokerTimestamp{Seconds: 1700000001, Nanos: 2}, | |
| @@ -249,11 +252,11 @@ func TestBrokerEnvelopeRoundTrip(t *testing.T) { | |||
| 249 | 252 | }) | |
| 250 | 253 | } | |
| 251 | 254 | ||
| 252 | - | // The snapshot nested unions round-trip their non-host parts too, so | |
| 255 | + | // The snapshot nested unions round-trip their non-daemon parts too, so | |
| 253 | 256 | // every part tag is exercised. | |
| 254 | 257 | for name, part := range map[string]*SnapshotPart{ | |
| 255 | - | "begin": {Part: &SnapshotPart_Begin{Begin: &SnapshotBegin{HostCount: 1, SessionCount: 2, TombstoneCount: 3}}}, | |
| 256 | - | "session": {Part: &SnapshotPart_Session{Session: &SnapshotSession{HostIndex: 1, SessionIndex: 2, Session: brokerCatalogSession()}}}, | |
| 258 | + | "begin": {Part: &SnapshotPart_Begin{Begin: &SnapshotBegin{HostCount: 1, SessionCount: 2, TombstoneCount: 3, LocalPresent: true}}}, | |
| 259 | + | "session": {Part: &SnapshotPart_Session{Session: &SnapshotSession{HostIndex: 1, SessionIndex: 2, Session: brokerCatalogSession(), Local: true}}}, | |
| 257 | 260 | "tombstone": {Part: &SnapshotPart_Tombstone{Tombstone: &SnapshotTombstone{TombstoneIndex: 3, Registration: brokerRegistration(), RetiredRevision: 4}}}, | |
| 258 | 261 | "end": {Part: &SnapshotPart_End{End: &SnapshotEnd{}}}, | |
| 259 | 262 | } { | |
| @@ -318,9 +321,9 @@ func TestBrokerEnvelopeDuplicateAlternatives(t *testing.T) { | |||
| 318 | 321 | } | |
| 319 | 322 | ||
| 320 | 323 | // The nested snapshot part union is exclusive as well. | |
| 321 | - | host := mustMarshalBroker(t, &SnapshotPart{Part: &SnapshotPart_Host{Host: &SnapshotHost{Endpoint: "dev@host:22"}}}) | |
| 324 | + | daemon := mustMarshalBroker(t, &SnapshotPart{Part: &SnapshotPart_Daemon{Daemon: &SnapshotDaemon{Endpoint: "dev@host:22"}}}) | |
| 322 | 325 | end := mustMarshalBroker(t, &SnapshotPart{Part: &SnapshotPart_End{End: &SnapshotEnd{}}}) | |
| 323 | - | if err := ScanEnvelope(&SnapshotPart{}, append(append([]byte(nil), host...), end...)); !errors.Is(err, ErrScanDuplicate) { | |
| 326 | + | if err := ScanEnvelope(&SnapshotPart{}, append(append([]byte(nil), daemon...), end...)); !errors.Is(err, ErrScanDuplicate) { | |
| 324 | 327 | t.Fatalf("duplicate snapshot part = %v, want ErrScanDuplicate", err) | |
| 325 | 328 | } | |
| 326 | 329 | } | |
| @@ -353,12 +356,12 @@ func TestBrokerEnvelopeRejectsUnknownAndWrongTypes(t *testing.T) { | |||
| 353 | 356 | } | |
| 354 | 357 | ||
| 355 | 358 | // Unknown field inside a nested snapshot part message. | |
| 356 | - | part := protowire.AppendTag(nil, 11, protowire.BytesType) // SnapshotPart.host | |
| 357 | - | host := protowire.AppendTag(nil, 2, protowire.BytesType) // SnapshotHost.endpoint | |
| 358 | - | host = protowire.AppendBytes(host, []byte("dev@host:22")) | |
| 359 | - | host = protowire.AppendTag(host, 99, protowire.VarintType) | |
| 360 | - | host = protowire.AppendVarint(host, 1) | |
| 361 | - | part = protowire.AppendBytes(part, host) | |
| 359 | + | part := protowire.AppendTag(nil, 15, protowire.BytesType) // SnapshotPart.daemon | |
| 360 | + | daemon := protowire.AppendTag(nil, 3, protowire.BytesType) // SnapshotDaemon.endpoint | |
| 361 | + | daemon = protowire.AppendBytes(daemon, []byte("dev@host:22")) | |
| 362 | + | daemon = protowire.AppendTag(daemon, 99, protowire.VarintType) | |
| 363 | + | daemon = protowire.AppendVarint(daemon, 1) | |
| 364 | + | part = protowire.AppendBytes(part, daemon) | |
| 362 | 365 | raw = protowire.AppendTag(nil, 202, protowire.BytesType) // BrokerServerEnvelope.snapshot_part | |
| 363 | 366 | raw = protowire.AppendBytes(raw, part) | |
| 364 | 367 | if err := ScanEnvelope(&BrokerServerEnvelope{}, raw); !errors.Is(err, ErrScanUnknown) { | |
M internal/protocol/wire/envelope_inventory_test.go +2 -2
| @@ -15,8 +15,8 @@ import ( | |||
| 15 | 15 | // shares a payload type except route navigation failure, and the preamble | |
| 16 | 16 | // constants agree with the schema authority. | |
| 17 | 17 | func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) { | |
| 18 | - | if protocol.Version != 54 { | |
| 19 | - | t.Fatalf("protocol.Version = %d, want 54", protocol.Version) | |
| 18 | + | if protocol.Version != 55 { | |
| 19 | + | t.Fatalf("protocol.Version = %d, want 55", protocol.Version) | |
| 20 | 20 | } | |
| 21 | 21 | if ProtocolEpoch != 1 { | |
| 22 | 22 | t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch) | |
M internal/protocol/wire/multiplex.pb.go +31 -9
| @@ -120,13 +120,18 @@ type MuxOpen struct { | |||
| 120 | 120 | state protoimpl.MessageState `protogen:"open.v1"` | |
| 121 | 121 | Ref *MuxStreamRef `protobuf:"bytes,1,opt,name=ref,proto3" json:"ref,omitempty"` | |
| 122 | 122 | // Closed taxonomy: 1 = attachment, 2 = control, 3 = observation. | |
| 123 | - | Purpose uint32 `protobuf:"varint,2,opt,name=purpose,proto3" json:"purpose,omitempty"` | |
| 124 | - | Local bool `protobuf:"varint,3,opt,name=local,proto3" json:"local,omitempty"` | |
| 125 | - | Endpoint string `protobuf:"bytes,4,opt,name=endpoint,proto3" json:"endpoint,omitempty"` | |
| 126 | - | Registration *RemoteRegistration `protobuf:"bytes,5,opt,name=registration,proto3" json:"registration,omitempty"` | |
| 127 | - | Target *ExactTarget `protobuf:"bytes,6,opt,name=target,proto3" json:"target,omitempty"` | |
| 128 | - | Env []string `protobuf:"bytes,7,rep,name=env,proto3" json:"env,omitempty"` | |
| 129 | - | Policy *BrokerWirePolicy `protobuf:"bytes,8,opt,name=policy,proto3" json:"policy,omitempty"` | |
| 123 | + | Purpose uint32 `protobuf:"varint,2,opt,name=purpose,proto3" json:"purpose,omitempty"` | |
| 124 | + | Local bool `protobuf:"varint,3,opt,name=local,proto3" json:"local,omitempty"` | |
| 125 | + | Endpoint string `protobuf:"bytes,4,opt,name=endpoint,proto3" json:"endpoint,omitempty"` | |
| 126 | + | Registration *RemoteRegistration `protobuf:"bytes,5,opt,name=registration,proto3" json:"registration,omitempty"` | |
| 127 | + | Target *ExactTarget `protobuf:"bytes,6,opt,name=target,proto3" json:"target,omitempty"` | |
| 128 | + | Env []string `protobuf:"bytes,7,rep,name=env,proto3" json:"env,omitempty"` | |
| 129 | + | Policy *BrokerWirePolicy `protobuf:"bytes,8,opt,name=policy,proto3" json:"policy,omitempty"` | |
| 130 | + | // Closed attachment-admission taxonomy (ports.BrokerStreamAdmission): | |
| 131 | + | // 0 = none (control/observation), 1 = exact attach/resume carrying | |
| 132 | + | // target, 2 = create named carrying name, 3 = create ephemeral. | |
| 133 | + | Admission uint32 `protobuf:"varint,9,opt,name=admission,proto3" json:"admission,omitempty"` | |
| 134 | + | Name string `protobuf:"bytes,10,opt,name=name,proto3" json:"name,omitempty"` | |
| 130 | 135 | unknownFields protoimpl.UnknownFields | |
| 131 | 136 | sizeCache protoimpl.SizeCache | |
| 132 | 137 | } | |
| @@ -217,6 +222,20 @@ func (x *MuxOpen) GetPolicy() *BrokerWirePolicy { | |||
| 217 | 222 | return nil | |
| 218 | 223 | } | |
| 219 | 224 | ||
| 225 | + | func (x *MuxOpen) GetAdmission() uint32 { | |
| 226 | + | if x != nil { | |
| 227 | + | return x.Admission | |
| 228 | + | } | |
| 229 | + | return 0 | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | func (x *MuxOpen) GetName() string { | |
| 233 | + | if x != nil { | |
| 234 | + | return x.Name | |
| 235 | + | } | |
| 236 | + | return "" | |
| 237 | + | } | |
| 238 | + | ||
| 220 | 239 | // MuxOpened confirms one logical stream is established. | |
| 221 | 240 | type MuxOpened struct { | |
| 222 | 241 | state protoimpl.MessageState `protogen:"open.v1"` | |
| @@ -1005,7 +1024,7 @@ const file_multiplex_proto_rawDesc = "" + | |||
| 1005 | 1024 | "\x12physical_stream_id\x18\x01 \x01(\x04R\x10physicalStreamId\x12!\n" + | |
| 1006 | 1025 | "\fbroker_epoch\x18\x02 \x01(\x04R\vbrokerEpoch\x12#\n" + | |
| 1007 | 1026 | "\rconnection_id\x18\x03 \x01(\fR\fconnectionId\x12(\n" + | |
| 1008 | - | "\x10client_stream_id\x18\x04 \x01(\x04R\x0eclientStreamId\"\xc2\x02\n" + | |
| 1027 | + | "\x10client_stream_id\x18\x04 \x01(\x04R\x0eclientStreamId\"\xf4\x02\n" + | |
| 1009 | 1028 | "\aMuxOpen\x12+\n" + | |
| 1010 | 1029 | "\x03ref\x18\x01 \x01(\v2\x19.vev.wire.v1.MuxStreamRefR\x03ref\x12\x18\n" + | |
| 1011 | 1030 | "\apurpose\x18\x02 \x01(\rR\apurpose\x12\x14\n" + | |
| @@ -1014,7 +1033,10 @@ const file_multiplex_proto_rawDesc = "" + | |||
| 1014 | 1033 | "\fregistration\x18\x05 \x01(\v2\x1f.vev.wire.v1.RemoteRegistrationR\fregistration\x120\n" + | |
| 1015 | 1034 | "\x06target\x18\x06 \x01(\v2\x18.vev.wire.v1.ExactTargetR\x06target\x12\x10\n" + | |
| 1016 | 1035 | "\x03env\x18\a \x03(\tR\x03env\x125\n" + | |
| 1017 | - | "\x06policy\x18\b \x01(\v2\x1d.vev.wire.v1.BrokerWirePolicyR\x06policy\"8\n" + | |
| 1036 | + | "\x06policy\x18\b \x01(\v2\x1d.vev.wire.v1.BrokerWirePolicyR\x06policy\x12\x1c\n" + | |
| 1037 | + | "\tadmission\x18\t \x01(\rR\tadmission\x12\x12\n" + | |
| 1038 | + | "\x04name\x18\n" + | |
| 1039 | + | " \x01(\tR\x04name\"8\n" + | |
| 1018 | 1040 | "\tMuxOpened\x12+\n" + | |
| 1019 | 1041 | "\x03ref\x18\x01 \x01(\v2\x19.vev.wire.v1.MuxStreamRefR\x03ref\"o\n" + | |
| 1020 | 1042 | "\n" + | |
M internal/protocol/wire/schema/broker.proto +56 -21
| @@ -150,6 +150,11 @@ message Reconcile { | |||
| 150 | 150 | // OpenStream asks the broker to open one independently cancellable logical | |
| 151 | 151 | // stream to the owning daemon. env is the per-request session environment | |
| 152 | 152 | // and is never inherited from the broker process environment. | |
| 153 | + | // | |
| 154 | + | // admission is the closed attachment-admission taxonomy: an attachment | |
| 155 | + | // stream names exactly how the daemon must admit it, while control and | |
| 156 | + | // observation streams carry none (0). name is validated session-name | |
| 157 | + | // authority for create-named and is empty for every other variant. | |
| 153 | 158 | message OpenStream { | |
| 154 | 159 | BrokerStreamRef ref = 1; | |
| 155 | 160 | // Closed taxonomy: 1 = attachment, 2 = control, 3 = observation. | |
| @@ -157,9 +162,16 @@ message OpenStream { | |||
| 157 | 162 | bool local = 3; | |
| 158 | 163 | string endpoint = 4; | |
| 159 | 164 | RemoteRegistration registration = 5; | |
| 165 | + | // Present only for exact attach/resume; the zero-value message is never | |
| 166 | + | // sent for a creation admission. | |
| 160 | 167 | ExactTarget target = 6; | |
| 161 | 168 | repeated string env = 7; | |
| 162 | 169 | BrokerWirePolicy policy = 8; | |
| 170 | + | // Closed admission taxonomy: 0 = none (control/observation), 1 = exact | |
| 171 | + | // attach/resume, 2 = create named, 3 = create ephemeral. | |
| 172 | + | uint32 admission = 9; | |
| 173 | + | // Validated session name for create-named; empty otherwise. | |
| 174 | + | string name = 10; | |
| 163 | 175 | } | |
| 164 | 176 | ||
| 165 | 177 | // ClientStreamData carries one opaque client-to-daemon stream frame. | |
| @@ -188,7 +200,7 @@ message SnapshotPart { | |||
| 188 | 200 | uint32 index = 4; | |
| 189 | 201 | oneof part { | |
| 190 | 202 | SnapshotBegin begin = 10; | |
| 191 | - | SnapshotHost host = 11; | |
| 203 | + | SnapshotDaemon daemon = 15; | |
| 192 | 204 | SnapshotSession session = 12; | |
| 193 | 205 | SnapshotTombstone tombstone = 13; | |
| 194 | 206 | SnapshotEnd end = 14; | |
| @@ -196,40 +208,63 @@ message SnapshotPart { | |||
| 196 | 208 | } | |
| 197 | 209 | ||
| 198 | 210 | // SnapshotBegin opens a snapshot publication with its element counts. | |
| 211 | + | // host_count counts every daemon part in the transfer (the local daemon plus | |
| 212 | + | // every remote host), and local_present reports whether daemon index 0 is the | |
| 213 | + | // local daemon. A publication carries at most one local daemon, always first. | |
| 199 | 214 | message SnapshotBegin { | |
| 200 | 215 | uint32 host_count = 1; | |
| 201 | 216 | uint32 session_count = 2; | |
| 202 | 217 | uint32 tombstone_count = 3; | |
| 218 | + | bool local_present = 4; | |
| 203 | 219 | } | |
| 204 | 220 | ||
| 205 | - | // SnapshotHost is one host projection (ports.RemoteHostSnapshot). The | |
| 206 | - | // endpoint and display origin stay separate: the latter is | |
| 207 | - | // presentation-only. Sessions travel as separate SnapshotSession parts. | |
| 208 | - | message SnapshotHost { | |
| 221 | + | // SnapshotDaemon is one daemon projection (ports.BrokerDaemonObservation), | |
| 222 | + | // local or remote. Configured authority (endpoint, registration, policy, | |
| 223 | + | // display origin, rank) stays separate from observed state (identity, | |
| 224 | + | // incarnation, version, capabilities, availability, failure, freshness), | |
| 225 | + | // and observation never invents unknown values. registration is absent for | |
| 226 | + | // the local daemon; policy is always present. Sessions travel as separate | |
| 227 | + | // SnapshotSession parts. | |
| 228 | + | message SnapshotDaemon { | |
| 209 | 229 | uint32 host_index = 1; | |
| 210 | - | string endpoint = 2; | |
| 211 | - | string display_origin = 3; | |
| 212 | - | uint32 rank = 4; | |
| 213 | - | RemoteRegistration registration = 5; | |
| 214 | - | // Closed availability taxonomy; 0 is the zero value. | |
| 215 | - | uint32 availability = 6; | |
| 216 | - | bool checking = 7; | |
| 217 | - | BrokerTimestamp last_attempt = 8; | |
| 218 | - | BrokerTimestamp last_success = 9; | |
| 219 | - | BrokerTimestamp next_due = 10; | |
| 220 | - | uint64 consecutive_failures = 11; | |
| 221 | - | uint64 failure_episode = 12; | |
| 230 | + | bool local = 2; | |
| 231 | + | string endpoint = 3; | |
| 232 | + | string display_origin = 4; | |
| 233 | + | uint32 rank = 5; | |
| 234 | + | RemoteRegistration registration = 6; | |
| 235 | + | BrokerWirePolicy policy = 7; | |
| 236 | + | // Authenticated daemon identity; empty until observed. | |
| 237 | + | string daemon_identity = 8; | |
| 238 | + | // Daemon process incarnation; exactly 16 bytes when an identity is | |
| 239 | + | // present and absent otherwise. | |
| 240 | + | bytes daemon_incarnation = 9; | |
| 241 | + | // Observed daemon protocol version; 0 = unobserved. | |
| 242 | + | uint32 protocol_version = 10; | |
| 243 | + | // Observed capability bitmask (mirrors protocol.Capability* bits). | |
| 244 | + | uint32 capabilities = 11; | |
| 245 | + | // Closed availability taxonomy (domain.RemoteAvailability); 0 is | |
| 246 | + | // invalid, an unobserved daemon carries RemoteAvailabilityUnknown. | |
| 247 | + | uint32 availability = 12; | |
| 248 | + | bool checking = 13; | |
| 249 | + | BrokerTimestamp last_attempt = 14; | |
| 250 | + | BrokerTimestamp last_success = 15; | |
| 251 | + | BrokerTimestamp next_due = 16; | |
| 252 | + | uint64 consecutive_failures = 17; | |
| 253 | + | uint64 failure_episode = 18; | |
| 222 | 254 | // Closed cause taxonomy (domain.RemoteFailureKind); 0 = none. | |
| 223 | - | uint32 failure_kind = 13; | |
| 224 | - | bool inventory_known = 14; | |
| 225 | - | uint32 session_count = 15; | |
| 255 | + | uint32 failure_kind = 19; | |
| 256 | + | bool inventory_known = 20; | |
| 257 | + | uint32 session_count = 21; | |
| 226 | 258 | } | |
| 227 | 259 | ||
| 228 | - | // SnapshotSession is one catalogue session bound to its host by index. | |
| 260 | + | // SnapshotSession is one catalogue session bound to its daemon by index. | |
| 261 | + | // local marks a session of the local daemon; its host_index is then exactly | |
| 262 | + | // 0 and follows the local daemon part rather than a remote host index. | |
| 229 | 263 | message SnapshotSession { | |
| 230 | 264 | uint32 host_index = 1; | |
| 231 | 265 | uint32 session_index = 2; | |
| 232 | 266 | BrokerCatalogSession session = 3; | |
| 267 | + | bool local = 4; | |
| 233 | 268 | } | |
| 234 | 269 | ||
| 235 | 270 | // SnapshotTombstone fences one retired host registration so a stale probe | |
M internal/protocol/wire/schema/multiplex.proto +5 -0
| @@ -51,6 +51,11 @@ message MuxOpen { | |||
| 51 | 51 | ExactTarget target = 6; | |
| 52 | 52 | repeated string env = 7; | |
| 53 | 53 | BrokerWirePolicy policy = 8; | |
| 54 | + | // Closed attachment-admission taxonomy (ports.BrokerStreamAdmission): | |
| 55 | + | // 0 = none (control/observation), 1 = exact attach/resume carrying | |
| 56 | + | // target, 2 = create named carrying name, 3 = create ephemeral. | |
| 57 | + | uint32 admission = 9; | |
| 58 | + | string name = 10; | |
| 54 | 59 | } | |
| 55 | 60 | ||
| 56 | 61 | // MuxOpened confirms one logical stream is established. | |
M internal/usecase/broker/registry.go +260 -51
| @@ -8,10 +8,12 @@ import ( | |||
| 8 | 8 | "hash/fnv" | |
| 9 | 9 | "log/slog" | |
| 10 | 10 | "reflect" | |
| 11 | + | "slices" | |
| 11 | 12 | "sort" | |
| 12 | 13 | "sync" | |
| 13 | 14 | "sync/atomic" | |
| 14 | 15 | "time" | |
| 16 | + | "unicode/utf8" | |
| 15 | 17 | ||
| 16 | 18 | "github.com/bnema/vev/internal/domain" | |
| 17 | 19 | "github.com/bnema/vev/internal/ports" | |
| @@ -79,7 +81,15 @@ type Registry struct { | |||
| 79 | 81 | // completion is fenced even when the endpoint is removed and re-added with | |
| 80 | 82 | // the identical registration. | |
| 81 | 83 | attempts uint64 | |
| 82 | - | hosts map[string]ports.RemoteHostSnapshot | |
| 84 | + | // hosts is the broker-native projection per endpoint. Configured | |
| 85 | + | // authority (endpoint, registration, policy, display origin, rank) is | |
| 86 | + | // stamped here from durable membership; probe results only supply observed | |
| 87 | + | // state and can never override it. | |
| 88 | + | hosts map[string]ports.BrokerDaemonObservation | |
| 89 | + | // order is the publication order of remote hosts: registration order from | |
| 90 | + | // durable membership. A snapshot carries the local daemon first (when a | |
| 91 | + | // producer exists) and then these remotes in this order. | |
| 92 | + | order []string | |
| 83 | 93 | // inflight records the one admitted attempt per endpoint. The record | |
| 84 | 94 | // carries the attempt's derived context cancel so a retired attempt is | |
| 85 | 95 | // released the moment its registration is replaced, removed, or the run | |
| @@ -128,7 +138,7 @@ func NewRegistryWithConfig(epoch ports.BrokerEpoch, store ports.BrokerHostStore, | |||
| 128 | 138 | r := &Registry{ | |
| 129 | 139 | epoch: epoch, probe: probe, clock: clock, log: log, | |
| 130 | 140 | observationDisabled: cfg.ObservationDisabled, | |
| 131 | - | hosts: make(map[string]ports.RemoteHostSnapshot), inflight: make(map[string]*probeAttempt), | |
| 141 | + | hosts: make(map[string]ports.BrokerDaemonObservation), inflight: make(map[string]*probeAttempt), | |
| 132 | 142 | pending: make(map[string]bool), tombstones: make(map[string]ports.BrokerHostTombstone), | |
| 133 | 143 | subs: make(map[*subscription]struct{}), wake: make(chan struct{}, 1), | |
| 134 | 144 | freshFor: defaultFreshFor, retryBase: defaultRetryBase, retryMax: defaultRetryLimit, | |
| @@ -183,19 +193,87 @@ func (r *Registry) projectMembership(hosts ports.BrokerHosts) error { | |||
| 183 | 193 | if err := hosts.Validate(); err != nil { | |
| 184 | 194 | return err | |
| 185 | 195 | } | |
| 186 | - | next := make(map[string]ports.RemoteHostSnapshot, len(hosts.Hosts)) | |
| 187 | - | for _, record := range hosts.Hosts { | |
| 196 | + | next := make(map[string]ports.BrokerDaemonObservation, len(hosts.Hosts)) | |
| 197 | + | order := make([]string, 0, len(hosts.Hosts)) | |
| 198 | + | for rank, record := range hosts.Hosts { | |
| 188 | 199 | registration := record.Registration | |
| 189 | 200 | restored, present := r.hosts[registration.Endpoint] | |
| 190 | 201 | if !present || !restored.Registration.Equal(registration) { | |
| 191 | - | restored = ports.RemoteHostSnapshot{Endpoint: registration.Endpoint, Registration: registration, Availability: domain.RemoteAvailabilityUnknown} | |
| 202 | + | restored = ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnknown} | |
| 192 | 203 | } | |
| 193 | - | next[registration.Endpoint] = restored | |
| 204 | + | projection := stampHostAuthority(restored, record, rank) | |
| 205 | + | // A durable membership whose projection cannot be published is refused | |
| 206 | + | // here, at the boundary that owns authority, instead of silently | |
| 207 | + | // leaving the registry unable to publish anything. | |
| 208 | + | if err := projection.Validate(); err != nil { | |
| 209 | + | return fmt.Errorf("broker: host record %q is not publishable: %w", registration.Endpoint, err) | |
| 210 | + | } | |
| 211 | + | next[registration.Endpoint] = projection | |
| 212 | + | order = append(order, registration.Endpoint) | |
| 194 | 213 | } | |
| 195 | 214 | r.hosts = next | |
| 215 | + | r.order = order | |
| 196 | 216 | return nil | |
| 197 | 217 | } | |
| 198 | 218 | ||
| 219 | + | // stampHostAuthority overwrites the configured authority fields of a daemon | |
| 220 | + | // projection with durable membership authority. Membership is the single | |
| 221 | + | // authority for endpoint, registration, policy, and the presentation hints | |
| 222 | + | // derived from them; a probe, a restored durable observation, or any other | |
| 223 | + | // producer can never supply or override them. The durable observation format | |
| 224 | + | // deliberately carries no policy (see durable), so this stamp is also what | |
| 225 | + | // gives every restored observation a valid policy. | |
| 226 | + | func stampHostAuthority(observation ports.BrokerDaemonObservation, record ports.BrokerHostRecord, rank int) ports.BrokerDaemonObservation { | |
| 227 | + | observation.Local = false | |
| 228 | + | observation.Endpoint = record.Registration.Endpoint | |
| 229 | + | observation.DisplayOrigin = hostDisplayOrigin(record.Registration.Endpoint) | |
| 230 | + | observation.Rank = rank | |
| 231 | + | observation.Registration = record.Registration | |
| 232 | + | observation.Policy = record.Policy | |
| 233 | + | if observation.Availability == 0 { | |
| 234 | + | // An unobserved daemon reports the explicit unknown availability; zero | |
| 235 | + | // is never a valid publication. | |
| 236 | + | observation.Availability = domain.RemoteAvailabilityUnknown | |
| 237 | + | } | |
| 238 | + | return observation | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | // stampObservationAuthority copies the configured authority already stamped on | |
| 242 | + | // a live projection onto a fresh probe result. Probe results supply only | |
| 243 | + | // observed state, so their endpoint, registration, policy, display origin, and | |
| 244 | + | // rank fields are discarded wholesale. | |
| 245 | + | func stampObservationAuthority(observation, authority ports.BrokerDaemonObservation) ports.BrokerDaemonObservation { | |
| 246 | + | observation.Local = false | |
| 247 | + | observation.Endpoint = authority.Endpoint | |
| 248 | + | observation.DisplayOrigin = authority.DisplayOrigin | |
| 249 | + | observation.Rank = authority.Rank | |
| 250 | + | observation.Registration = authority.Registration | |
| 251 | + | observation.Policy = authority.Policy | |
| 252 | + | return observation | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | // hostDisplayOrigin derives the presentation hint for a configured host. The | |
| 256 | + | // routing endpoint stays authoritative; the origin is only the login prefix | |
| 257 | + | // stripped for display, and an endpoint that carries none is its own origin. | |
| 258 | + | // The hint is sanitized to the display-text rule before it is clamped rune- and | |
| 259 | + | // byte-safely to the ports bound: an endpoint the routing validator accepts may | |
| 260 | + | // still carry bidi or control runes, and rendering those would let a derived | |
| 261 | + | // hint reorder the picker text around it. A derived hint carries no routing | |
| 262 | + | // authority, so the refused runes are dropped rather than the host rejected. A | |
| 263 | + | // target left with no displayable rune at all yields no origin, which its | |
| 264 | + | // caller refuses as unpublishable membership instead of inventing display text. | |
| 265 | + | func hostDisplayOrigin(endpoint string) string { | |
| 266 | + | origin := ports.SanitizeBrokerDisplayText(domain.RemoteDisplayOrigin(endpoint)) | |
| 267 | + | if len(origin) <= ports.BrokerMaxDisplayOriginBytes { | |
| 268 | + | return origin | |
| 269 | + | } | |
| 270 | + | truncated := origin[:ports.BrokerMaxDisplayOriginBytes] | |
| 271 | + | for len(truncated) > 0 && !utf8.ValidString(truncated) { | |
| 272 | + | truncated = truncated[:len(truncated)-1] | |
| 273 | + | } | |
| 274 | + | return truncated | |
| 275 | + | } | |
| 276 | + | ||
| 199 | 277 | // restore adopts a validated durable snapshot. A zero-epoch snapshot is only | |
| 200 | 278 | // the empty placeholder a store returns before its first write: any record | |
| 201 | 279 | // without an epoch is rejected instead of silently dropped. A persisted | |
| @@ -207,7 +285,7 @@ func (r *Registry) projectMembership(hosts ports.BrokerHosts) error { | |||
| 207 | 285 | // fencing state and never survive a restart. | |
| 208 | 286 | func (r *Registry) restore(snapshot ports.BrokerSnapshot) error { | |
| 209 | 287 | if snapshot.Epoch == 0 { | |
| 210 | - | if len(snapshot.Hosts) > 0 || len(snapshot.Removed) > 0 || snapshot.Revision != 0 { | |
| 288 | + | if len(snapshot.Daemons) > 0 || len(snapshot.Removed) > 0 || snapshot.Revision != 0 { | |
| 211 | 289 | return errors.New("broker: persisted snapshot has no epoch") | |
| 212 | 290 | } | |
| 213 | 291 | return nil | |
| @@ -215,17 +293,78 @@ func (r *Registry) restore(snapshot ports.BrokerSnapshot) error { | |||
| 215 | 293 | if snapshot.Epoch == r.epoch { | |
| 216 | 294 | return errors.New("broker: persisted snapshot epoch matches the new registry epoch") | |
| 217 | 295 | } | |
| 218 | - | if err := snapshot.Validate(); err != nil { | |
| 296 | + | if err := validateRestoredSnapshot(snapshot); err != nil { | |
| 219 | 297 | return err | |
| 220 | 298 | } | |
| 221 | - | for _, host := range snapshot.Hosts { | |
| 222 | - | host = host.Clone() | |
| 223 | - | host.Checking = false | |
| 224 | - | r.hosts[host.Endpoint] = host | |
| 299 | + | for _, daemon := range snapshot.Daemons { | |
| 300 | + | daemon = daemon.Clone() | |
| 301 | + | daemon.Checking = false | |
| 302 | + | r.hosts[daemon.Endpoint] = daemon | |
| 225 | 303 | } | |
| 226 | 304 | return nil | |
| 227 | 305 | } | |
| 228 | 306 | ||
| 307 | + | // validateRestoredSnapshot applies the durable shape rules to a snapshot read | |
| 308 | + | // back from a store: every observation must be a remote, non-local projection | |
| 309 | + | // of an exact valid registration whose endpoint matches, with a closed | |
| 310 | + | // availability/failure range and a catalogue-valid inventory. Policy and | |
| 311 | + | // display authority are deliberately not checked here because the durable | |
| 312 | + | // format omits policy and projectMembership stamps both from membership, so a | |
| 313 | + | // restored observation is always remote-only and always carries authority once | |
| 314 | + | // membership is projected. | |
| 315 | + | func validateRestoredSnapshot(snapshot ports.BrokerSnapshot) error { | |
| 316 | + | if snapshot.Revision == 0 { | |
| 317 | + | return errors.New("broker: persisted snapshot has no revision") | |
| 318 | + | } | |
| 319 | + | if len(snapshot.Daemons) > ports.BrokerMaxDaemonsPerSnapshot { | |
| 320 | + | return errors.New("broker: persisted snapshot has too many daemons") | |
| 321 | + | } | |
| 322 | + | seen := make(map[string]struct{}, len(snapshot.Daemons)) | |
| 323 | + | for _, daemon := range snapshot.Daemons { | |
| 324 | + | if err := validateDurableObservation(daemon); err != nil { | |
| 325 | + | return err | |
| 326 | + | } | |
| 327 | + | if _, duplicate := seen[daemon.Endpoint]; duplicate { | |
| 328 | + | return fmt.Errorf("broker: persisted snapshot has duplicate host %q", daemon.Endpoint) | |
| 329 | + | } | |
| 330 | + | seen[daemon.Endpoint] = struct{}{} | |
| 331 | + | } | |
| 332 | + | if len(snapshot.Removed) > ports.BrokerMaxTombstones { | |
| 333 | + | return errors.New("broker: persisted snapshot has too many tombstones") | |
| 334 | + | } | |
| 335 | + | for _, tombstone := range snapshot.Removed { | |
| 336 | + | if err := tombstone.Validate(); err != nil { | |
| 337 | + | return err | |
| 338 | + | } | |
| 339 | + | if _, live := seen[tombstone.Endpoint]; live { | |
| 340 | + | return fmt.Errorf("broker: persisted snapshot carries live host %q as tombstone", tombstone.Endpoint) | |
| 341 | + | } | |
| 342 | + | } | |
| 343 | + | return nil | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | // validateDurableObservation reports whether one observation satisfies the | |
| 347 | + | // durable shape shared with the offline store: remote-only, an exact valid | |
| 348 | + | // registration matching its endpoint, and the ports durable projection rules. | |
| 349 | + | func validateDurableObservation(daemon ports.BrokerDaemonObservation) error { | |
| 350 | + | if daemon.Local { | |
| 351 | + | return errors.New("broker: persisted local daemon observation") | |
| 352 | + | } | |
| 353 | + | if err := domain.ValidateRemoteHostTarget(daemon.Endpoint); err != nil { | |
| 354 | + | return fmt.Errorf("broker: persisted observation: %w", err) | |
| 355 | + | } | |
| 356 | + | if err := daemon.Registration.Validate(); err != nil { | |
| 357 | + | return fmt.Errorf("broker: persisted observation: %w", err) | |
| 358 | + | } | |
| 359 | + | if daemon.Endpoint != daemon.Registration.Endpoint { | |
| 360 | + | return errors.New("broker: persisted observation endpoint does not match registration") | |
| 361 | + | } | |
| 362 | + | if len(daemon.Sessions) > 0 && !daemon.InventoryKnown { | |
| 363 | + | return errors.New("broker: persisted observation carries sessions without known inventory") | |
| 364 | + | } | |
| 365 | + | return ports.ValidateDurableHostProjection(daemon) | |
| 366 | + | } | |
| 367 | + | ||
| 229 | 368 | // Snapshot returns a defensive immutable copy without I/O. | |
| 230 | 369 | func (r *Registry) Snapshot() ports.BrokerSnapshot { return r.current.Load().Clone() } | |
| 231 | 370 | ||
| @@ -262,10 +401,12 @@ func (r *Registry) ReplaceHosts(records []ports.BrokerHostRecord) error { | |||
| 262 | 401 | if err := ports.ValidateBrokerHostRecords(records); err != nil { | |
| 263 | 402 | return err | |
| 264 | 403 | } | |
| 265 | - | next := make(map[string]ports.RemoteHostSnapshot, len(records)) | |
| 266 | - | for _, record := range records { | |
| 404 | + | next := make(map[string]ports.BrokerDaemonObservation, len(records)) | |
| 405 | + | order := make([]string, 0, len(records)) | |
| 406 | + | for rank, record := range records { | |
| 267 | 407 | reg := record.Registration | |
| 268 | - | next[reg.Endpoint] = ports.RemoteHostSnapshot{Endpoint: reg.Endpoint, Registration: reg, Availability: domain.RemoteAvailabilityUnknown} | |
| 408 | + | next[reg.Endpoint] = stampHostAuthority(ports.BrokerDaemonObservation{}, record, rank) | |
| 409 | + | order = append(order, reg.Endpoint) | |
| 269 | 410 | } | |
| 270 | 411 | r.mu.Lock() | |
| 271 | 412 | defer r.mu.Unlock() | |
| @@ -279,7 +420,7 @@ func (r *Registry) ReplaceHosts(records []ports.BrokerHostRecord) error { | |||
| 279 | 420 | return fmt.Errorf("broker: replace hosts: %w", err) | |
| 280 | 421 | } | |
| 281 | 422 | r.authority = ports.BrokerHosts{Revision: r.authority.Revision + 1, Hosts: records} | |
| 282 | - | r.setHostsLocked(next) | |
| 423 | + | r.setHostsLocked(next, order) | |
| 283 | 424 | return nil | |
| 284 | 425 | } | |
| 285 | 426 | ||
| @@ -291,19 +432,33 @@ func (r *Registry) ReplaceHosts(records []ports.BrokerHostRecord) error { | |||
| 291 | 432 | // Removed registrations retire bounded tombstones so a stale completion can | |
| 292 | 433 | // never revive them; replaced or re-added endpoints are fenced by the | |
| 293 | 434 | // registry attempt token instead. | |
| 294 | - | func (r *Registry) setHosts(registrations []domain.RemoteRegistration) error { | |
| 295 | - | if len(registrations) > ports.BrokerMaxHosts { | |
| 296 | - | return fmt.Errorf("broker: %d host registrations exceed the limit of %d", len(registrations), ports.BrokerMaxHosts) | |
| 297 | - | } | |
| 298 | - | next := make(map[string]ports.RemoteHostSnapshot, len(registrations)) | |
| 299 | - | for _, registration := range registrations { | |
| 300 | - | if err := registration.Validate(); err != nil { | |
| 435 | + | func (r *Registry) setHosts(records []ports.BrokerHostRecord) error { | |
| 436 | + | if len(records) > ports.BrokerMaxHosts { | |
| 437 | + | return fmt.Errorf("broker: %d host registrations exceed the limit of %d", len(records), ports.BrokerMaxHosts) | |
| 438 | + | } | |
| 439 | + | next := make(map[string]ports.BrokerDaemonObservation, len(records)) | |
| 440 | + | order := make([]string, 0, len(records)) | |
| 441 | + | for rank, record := range records { | |
| 442 | + | if err := record.Registration.Validate(); err != nil { | |
| 301 | 443 | return err | |
| 302 | 444 | } | |
| 445 | + | if err := record.Policy.Validate(); err != nil { | |
| 446 | + | return err | |
| 447 | + | } | |
| 448 | + | registration := record.Registration | |
| 303 | 449 | if _, duplicate := next[registration.Endpoint]; duplicate { | |
| 304 | 450 | return errors.New("broker: duplicate host registration") | |
| 305 | 451 | } | |
| 306 | - | next[registration.Endpoint] = ports.RemoteHostSnapshot{Endpoint: registration.Endpoint, Registration: registration, Availability: domain.RemoteAvailabilityUnknown} | |
| 452 | + | observation := stampHostAuthority(ports.BrokerDaemonObservation{}, record, rank) | |
| 453 | + | // Membership the registry cannot publish is a caller error, refused here | |
| 454 | + | // rather than left to freeze every later publication: an endpoint whose | |
| 455 | + | // derived display hint is empty after the display rule drops its runes | |
| 456 | + | // makes every snapshot carrying it invalid. | |
| 457 | + | if err := observation.Validate(); err != nil { | |
| 458 | + | return fmt.Errorf("broker: host record %q is not publishable: %w", registration.Endpoint, err) | |
| 459 | + | } | |
| 460 | + | next[registration.Endpoint] = observation | |
| 461 | + | order = append(order, registration.Endpoint) | |
| 307 | 462 | } | |
| 308 | 463 | ||
| 309 | 464 | r.mu.Lock() | |
| @@ -311,21 +466,27 @@ func (r *Registry) setHosts(registrations []domain.RemoteRegistration) error { | |||
| 311 | 466 | if r.revisionExhaustedLocked() { | |
| 312 | 467 | return fmt.Errorf("broker: set hosts: %w", ports.ErrBrokerRevisionExhausted) | |
| 313 | 468 | } | |
| 314 | - | r.setHostsLocked(next) | |
| 469 | + | r.setHostsLocked(next, order) | |
| 315 | 470 | return nil | |
| 316 | 471 | } | |
| 317 | 472 | ||
| 318 | 473 | // setHostsLocked applies only a projection; the public path commits authority | |
| 319 | 474 | // first. The caller holds mu through both steps, fencing probe completions. | |
| 320 | - | func (r *Registry) setHostsLocked(next map[string]ports.RemoteHostSnapshot) { | |
| 321 | - | if registrationsUnchanged(r.hosts, next) { | |
| 475 | + | // order is the registration order the projection was built from; it becomes | |
| 476 | + | // the publication order of remote daemons. | |
| 477 | + | func (r *Registry) setHostsLocked(next map[string]ports.BrokerDaemonObservation, order []string) { | |
| 478 | + | if registrationsUnchanged(r.hosts, next, r.order, order) { | |
| 322 | 479 | return | |
| 323 | 480 | } | |
| 324 | 481 | for endpoint, current := range r.hosts { | |
| 325 | 482 | candidate, present := next[endpoint] | |
| 326 | - | if present && current.Registration.Equal(candidate.Registration) { | |
| 327 | - | // Unchanged registration: keep the live observation as is. | |
| 328 | - | next[endpoint] = current | |
| 483 | + | if present && current.Registration.Equal(candidate.Registration) && current.Policy == candidate.Policy { | |
| 484 | + | // Unchanged authority: keep the live observation as is. Rank is the | |
| 485 | + | // one authority field that follows membership order rather than the | |
| 486 | + | // record, so it is re-stamped from the new projection. | |
| 487 | + | kept := current | |
| 488 | + | kept.Rank = candidate.Rank | |
| 489 | + | next[endpoint] = kept | |
| 329 | 490 | continue | |
| 330 | 491 | } | |
| 331 | 492 | // A retired registration owns no observation: cancelling its attempt | |
| @@ -351,6 +512,7 @@ func (r *Registry) setHostsLocked(next map[string]ports.RemoteHostSnapshot) { | |||
| 351 | 512 | delete(r.tombstones, endpoint) | |
| 352 | 513 | } | |
| 353 | 514 | r.hosts = next | |
| 515 | + | r.order = order | |
| 354 | 516 | r.publishLocked(true) | |
| 355 | 517 | r.hint() | |
| 356 | 518 | } | |
| @@ -480,7 +642,7 @@ type probeResult struct { | |||
| 480 | 642 | endpoint string | |
| 481 | 643 | registration domain.RemoteRegistration | |
| 482 | 644 | attempt uint64 | |
| 483 | - | snapshot ports.RemoteHostSnapshot | |
| 645 | + | snapshot ports.BrokerDaemonObservation | |
| 484 | 646 | err error | |
| 485 | 647 | at time.Time | |
| 486 | 648 | } | |
| @@ -547,9 +709,10 @@ func (r *Registry) apply(result probeResult) { | |||
| 547 | 709 | ||
| 548 | 710 | kind, availability, failed := observationOutcome(result) | |
| 549 | 711 | if !failed { | |
| 550 | - | observed := result.snapshot.Clone() | |
| 551 | - | observed.Endpoint = result.endpoint | |
| 552 | - | observed.Registration = result.registration | |
| 712 | + | // Configured authority comes only from the registry's own projection: | |
| 713 | + | // a probe result supplies observed state, never endpoint, registration, | |
| 714 | + | // policy, display origin, or rank. | |
| 715 | + | observed := stampObservationAuthority(result.snapshot.Clone(), current) | |
| 553 | 716 | observed.Checking = false | |
| 554 | 717 | observed.LastAttempt = result.at | |
| 555 | 718 | observed.LastSuccess = result.at | |
| @@ -557,10 +720,16 @@ func (r *Registry) apply(result probeResult) { | |||
| 557 | 720 | observed.ConsecutiveFailures = 0 | |
| 558 | 721 | observed.LastFailure = domain.RemoteFailure{} | |
| 559 | 722 | observed.FailureEpisode = current.FailureEpisode | |
| 560 | - | // A projection the durable store would reject is an invalid response, | |
| 561 | - | // not an observation: publishing it would leave the registry valid but | |
| 562 | - | // permanently unpersisted. The store applies the same rule. | |
| 563 | - | if err := ports.ValidateDurableHostProjection(observed); err != nil { | |
| 723 | + | // The published observation must be fully valid once authority is | |
| 724 | + | // stamped: a probe that invents partial identity (identity without a | |
| 725 | + | // protocol version) or an inventory without a known catalogue is an | |
| 726 | + | // invalid response, never a published observation. The durable rule is | |
| 727 | + | // re-checked too: a projection the offline store would reject is never | |
| 728 | + | // published as if it had persisted. The store applies the same rule. | |
| 729 | + | if err := observed.Validate(); err != nil { | |
| 730 | + | r.log.Debug("broker: observation is invalid", "endpoint", result.endpoint, "err", err) | |
| 731 | + | kind, availability, failed = domain.RemoteFailureInvalidResponse, domain.RemoteAvailabilityInvalidResponse, true | |
| 732 | + | } else if err := ports.ValidateDurableHostProjection(observed); err != nil { | |
| 564 | 733 | r.log.Debug("broker: observation is not persistable", "endpoint", result.endpoint, "err", err) | |
| 565 | 734 | kind, availability, failed = domain.RemoteFailureInvalidResponse, domain.RemoteAvailabilityInvalidResponse, true | |
| 566 | 735 | } else { | |
| @@ -687,16 +856,23 @@ func (r *Registry) nextDelay(now time.Time) time.Duration { | |||
| 687 | 856 | return 0 | |
| 688 | 857 | } | |
| 689 | 858 | ||
| 690 | - | // registrationsUnchanged reports exact membership equality: the same | |
| 691 | - | // endpoints bound to the same registration identities. Projection fields such | |
| 692 | - | // as availability, checking, or inventory never participate. | |
| 693 | - | func registrationsUnchanged(current, next map[string]ports.RemoteHostSnapshot) bool { | |
| 859 | + | // registrationsUnchanged reports exact membership equality: the same endpoints | |
| 860 | + | // in the same registration order, bound to the same registration identities and | |
| 861 | + | // policy. Publication order and presentation rank are functions of durable | |
| 862 | + | // membership order, so a reordering of identical records is a membership change | |
| 863 | + | // that must republish; otherwise the same durable state yields different | |
| 864 | + | // publications depending on process history. Projection fields such as | |
| 865 | + | // availability, checking, or inventory never participate. | |
| 866 | + | func registrationsUnchanged(current, next map[string]ports.BrokerDaemonObservation, currentOrder, nextOrder []string) bool { | |
| 694 | 867 | if len(current) != len(next) { | |
| 695 | 868 | return false | |
| 696 | 869 | } | |
| 870 | + | if !slices.Equal(currentOrder, nextOrder) { | |
| 871 | + | return false | |
| 872 | + | } | |
| 697 | 873 | for endpoint, candidate := range next { | |
| 698 | 874 | existing, ok := current[endpoint] | |
| 699 | - | if !ok || !existing.Registration.Equal(candidate.Registration) { | |
| 875 | + | if !ok || !existing.Registration.Equal(candidate.Registration) || existing.Policy != candidate.Policy { | |
| 700 | 876 | return false | |
| 701 | 877 | } | |
| 702 | 878 | } | |
| @@ -782,13 +958,28 @@ func (r *Registry) publishLocked(persist bool) { | |||
| 782 | 958 | if r.revisionExhaustedLocked() { | |
| 783 | 959 | return | |
| 784 | 960 | } | |
| 785 | - | r.revision++ | |
| 786 | - | hosts := make([]ports.RemoteHostSnapshot, 0, len(r.hosts)) | |
| 787 | - | for _, host := range r.hosts { | |
| 788 | - | hosts = append(hosts, host.Clone()) | |
| 961 | + | // Remote daemons are published in registration order. The local daemon, | |
| 962 | + | // when a producer exists (Plan 001 P5.3a), is prepended ahead of these; | |
| 963 | + | // the registry produces no local observation yet and never invents one. | |
| 964 | + | daemons := make([]ports.BrokerDaemonObservation, 0, len(r.hosts)) | |
| 965 | + | for _, endpoint := range r.order { | |
| 966 | + | host, ok := r.hosts[endpoint] | |
| 967 | + | if !ok { | |
| 968 | + | continue | |
| 969 | + | } | |
| 970 | + | daemons = append(daemons, host.Clone()) | |
| 789 | 971 | } | |
| 790 | - | sort.Slice(hosts, func(i, j int) bool { return hosts[i].Endpoint < hosts[j].Endpoint }) | |
| 791 | - | snapshot := ports.BrokerSnapshot{Epoch: r.epoch, Revision: r.revision, Hosts: hosts, Removed: r.tombstonesLocked()} | |
| 972 | + | snapshot := ports.BrokerSnapshot{Epoch: r.epoch, Revision: r.revision + 1, Daemons: daemons, Removed: r.tombstonesLocked()} | |
| 973 | + | if err := snapshot.Validate(); err != nil { | |
| 974 | + | // Fail closed: a publication the wire refuses aborts every client | |
| 975 | + | // connection on subscribe, so an invalid projection is never published. | |
| 976 | + | // The last valid snapshot stays current, nothing is persisted, and the | |
| 977 | + | // revision series does not advance past a publication that never | |
| 978 | + | // happened. | |
| 979 | + | r.log.Error("broker: refused invalid snapshot publication", "revision", r.revision+1, "err", err) | |
| 980 | + | return | |
| 981 | + | } | |
| 982 | + | r.revision = snapshot.Revision | |
| 792 | 983 | r.current.Store(&snapshot) | |
| 793 | 984 | if persist { | |
| 794 | 985 | // enqueue only stages an immutable publication: rendering the durable | |
| @@ -807,12 +998,30 @@ func (r *Registry) publishLocked(persist bool) { | |||
| 807 | 998 | // they retire registrations only for this process lifetime and a restart never | |
| 808 | 999 | // adopts them, so persisting them would leak stale authority into durable | |
| 809 | 1000 | // state. Removed is therefore cleared before persistence. | |
| 1001 | + | // | |
| 1002 | + | // Policy is membership authority, not observation state, so the durable format | |
| 1003 | + | // deliberately omits it: membership is the single policy authority, and the | |
| 1004 | + | // loader re-stamps every restored observation from the matching | |
| 1005 | + | // ports.BrokerHostRecord (see stampHostAuthority). Persisting it would create a | |
| 1006 | + | // second, stale authority that a later membership change could contradict. | |
| 1007 | + | // | |
| 1008 | + | // Only remote observations are durable: a local daemon observation is | |
| 1009 | + | // process-local state and is dropped here (the store also rejects one), so a | |
| 1010 | + | // future local producer can never break durable persistence. | |
| 810 | 1011 | func durable(snapshot ports.BrokerSnapshot) ports.BrokerSnapshot { | |
| 811 | 1012 | out := snapshot.Clone() | |
| 812 | 1013 | out.Removed = nil | |
| 813 | - | for i := range out.Hosts { | |
| 814 | - | out.Hosts[i].Checking = false | |
| 1014 | + | daemons := out.Daemons[:0] | |
| 1015 | + | for _, daemon := range out.Daemons { | |
| 1016 | + | if daemon.Local { | |
| 1017 | + | continue | |
| 1018 | + | } | |
| 1019 | + | daemon.Checking = false | |
| 1020 | + | daemon.LastFailure.Err = nil | |
| 1021 | + | daemon.Policy = ports.BrokerPolicy{} | |
| 1022 | + | daemons = append(daemons, daemon) | |
| 815 | 1023 | } | |
| 1024 | + | out.Daemons = daemons | |
| 816 | 1025 | return out | |
| 817 | 1026 | } | |
| 818 | 1027 | ||
M internal/usecase/broker/registry_durable_test.go +68 -6
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "github.com/bnema/vev/internal/domain" | |
| 11 | 11 | "github.com/bnema/vev/internal/ports" | |
| 12 | 12 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 13 | + | "github.com/bnema/vev/internal/protocol" | |
| 13 | 14 | "github.com/stretchr/testify/require" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| @@ -38,22 +39,22 @@ func TestRegistryDurableReplaceHostsRestart(t *testing.T) { | |||
| 38 | 39 | require.NoError(t, err) | |
| 39 | 40 | defer r.settle() | |
| 40 | 41 | require.Equal(t, previous, r.authority.Hosts) | |
| 41 | - | require.Len(t, r.Snapshot().Hosts, len(previous)) | |
| 42 | + | require.Len(t, r.Snapshot().Daemons, len(previous)) | |
| 42 | 43 | if len(previous) > 0 { | |
| 43 | - | require.True(t, r.Snapshot().Hosts[0].InventoryKnown, "advisory observation must also survive restart") | |
| 44 | + | require.True(t, r.Snapshot().Daemons[0].InventoryKnown, "advisory observation must also survive restart") | |
| 44 | 45 | } | |
| 45 | 46 | require.NoError(t, r.ReplaceHosts(stage.records)) | |
| 46 | 47 | authority, err := store.LoadHosts() | |
| 47 | 48 | require.NoError(t, err) | |
| 48 | 49 | require.Equal(t, r.authority, authority) | |
| 49 | 50 | require.Equal(t, stage.records, authority.Hosts) | |
| 50 | - | require.Len(t, r.Snapshot().Hosts, len(stage.records)) | |
| 51 | + | require.Len(t, r.Snapshot().Daemons, len(stage.records)) | |
| 51 | 52 | if len(stage.records) > 0 { | |
| 52 | 53 | reg := stage.records[0].Registration | |
| 53 | 54 | ctx, cancel := context.WithCancel(context.Background()) | |
| 54 | 55 | defer cancel() | |
| 55 | 56 | r.inflight[reg.Endpoint] = &probeAttempt{token: 1, cancel: cancel} | |
| 56 | - | r.apply(probeResult{endpoint: reg.Endpoint, registration: reg, attempt: 1, at: r.clock.Now(), snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable, InventoryKnown: true}}) | |
| 57 | + | r.apply(probeResult{endpoint: reg.Endpoint, registration: reg, attempt: 1, at: r.clock.Now(), snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable, InventoryKnown: true}}) | |
| 57 | 58 | require.ErrorIs(t, ctx.Err(), context.Canceled) | |
| 58 | 59 | } | |
| 59 | 60 | r.settle() // flush before inspecting persisted projection | |
| @@ -121,7 +122,7 @@ func TestRegistryReplaceHostsAfterShutdownIsTyped(t *testing.T) { | |||
| 121 | 122 | store := newTestStore() | |
| 122 | 123 | r := newTestRegistry(t, 1, store, newTestProbe(1), newManualClock(time.Unix(100, 0))) | |
| 123 | 124 | record := ports.BrokerHostRecord{Registration: registration(t, "host", 1), Pinned: true, Policy: poolPolicy()} | |
| 124 | - | require.NoError(t, r.setHosts([]domain.RemoteRegistration{record.Registration})) | |
| 125 | + | require.NoError(t, r.setHosts(hostRecords(record.Registration))) | |
| 125 | 126 | r.settle() | |
| 126 | 127 | ||
| 127 | 128 | before := r.Snapshot() | |
| @@ -150,7 +151,7 @@ func TestRegistryDurableChurnFlushesLatestMembership(t *testing.T) { | |||
| 150 | 151 | record.Registration.Generation++ | |
| 151 | 152 | record.Policy.Trust = "replacement-trust" | |
| 152 | 153 | require.NoError(t, r.ReplaceHosts([]ports.BrokerHostRecord{record})) | |
| 153 | - | require.Equal(t, record.Registration, r.Snapshot().Hosts[0].Registration) | |
| 154 | + | require.Equal(t, record.Registration, r.Snapshot().Daemons[0].Registration) | |
| 154 | 155 | if i < 20 { | |
| 155 | 156 | require.NoError(t, r.ReplaceHosts(nil)) | |
| 156 | 157 | } | |
| @@ -163,3 +164,64 @@ func TestRegistryDurableChurnFlushesLatestMembership(t *testing.T) { | |||
| 163 | 164 | require.NoError(t, err) | |
| 164 | 165 | require.Equal(t, r.authority, authority) | |
| 165 | 166 | } | |
| 167 | + | ||
| 168 | + | // TestRegistryDurableRoundTripObservedIdentity proves the durable format carries | |
| 169 | + | // observed daemon identity, incarnation, protocol version, and capabilities | |
| 170 | + | // across a restart. The durable copy deliberately omits policy; the loader | |
| 171 | + | // re-stamps it from the matching membership record, which remains the single | |
| 172 | + | // policy authority. | |
| 173 | + | func TestRegistryDurableRoundTripObservedIdentity(t *testing.T) { | |
| 174 | + | dir := filepath.Join(t.TempDir(), "broker") | |
| 175 | + | record := ports.BrokerHostRecord{Registration: registration(t, "host", 1), Pinned: true, Policy: poolPolicy()} | |
| 176 | + | ||
| 177 | + | store, err := brokerstore.OpenOffline(brokerstore.Options{Dir: dir}) | |
| 178 | + | require.NoError(t, err) | |
| 179 | + | r, err := NewRegistry(1, store, newTestProbe(1), newManualClock(time.Unix(100, 0)), nil) | |
| 180 | + | require.NoError(t, err) | |
| 181 | + | require.NoError(t, r.ReplaceHosts([]ports.BrokerHostRecord{record})) | |
| 182 | + | ||
| 183 | + | reg := record.Registration | |
| 184 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 185 | + | defer cancel() | |
| 186 | + | r.inflight[reg.Endpoint] = &probeAttempt{token: 1, cancel: cancel} | |
| 187 | + | r.apply(probeResult{endpoint: reg.Endpoint, registration: reg, attempt: 1, at: r.clock.Now(), snapshot: ports.BrokerDaemonObservation{ | |
| 188 | + | Identity: "authed-daemon", | |
| 189 | + | Incarnation: ports.BrokerDaemonIncarnation{4, 5}, | |
| 190 | + | ProtocolVersion: protocol.Version, | |
| 191 | + | Capabilities: protocol.CapabilityResume, | |
| 192 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 193 | + | }}) | |
| 194 | + | observed, ok := r.Snapshot().Find(reg.Endpoint) | |
| 195 | + | require.True(t, ok) | |
| 196 | + | require.Equal(t, ports.BrokerDaemonIdentity("authed-daemon"), observed.Identity) | |
| 197 | + | require.ErrorIs(t, ctx.Err(), context.Canceled, "applying the result retires the attempt context") | |
| 198 | + | // Policy is membership authority, never durable observation state. | |
| 199 | + | require.Equal(t, ports.BrokerPolicy{}, durable(r.Snapshot()).Daemons[0].Policy) | |
| 200 | + | ||
| 201 | + | r.settle() // flush before reopening | |
| 202 | + | require.NoError(t, store.Close()) | |
| 203 | + | ||
| 204 | + | reopened, err := brokerstore.OpenOffline(brokerstore.Options{Dir: dir}) | |
| 205 | + | require.NoError(t, err) | |
| 206 | + | defer reopened.Close() | |
| 207 | + | persisted, err := reopened.Load() | |
| 208 | + | require.NoError(t, err) | |
| 209 | + | require.Len(t, persisted.Daemons, 1) | |
| 210 | + | require.Equal(t, ports.BrokerPolicy{}, persisted.Daemons[0].Policy, "the durable format omits policy") | |
| 211 | + | require.Equal(t, ports.BrokerDaemonIdentity("authed-daemon"), persisted.Daemons[0].Identity) | |
| 212 | + | require.Equal(t, ports.BrokerDaemonIncarnation{4, 5}, persisted.Daemons[0].Incarnation) | |
| 213 | + | require.Equal(t, protocol.Version, persisted.Daemons[0].ProtocolVersion) | |
| 214 | + | require.Equal(t, protocol.CapabilityResume, persisted.Daemons[0].Capabilities) | |
| 215 | + | r2, err := NewRegistry(2, reopened, newTestProbe(1), newManualClock(time.Unix(200, 0)), nil) | |
| 216 | + | require.NoError(t, err) | |
| 217 | + | defer r2.settle() | |
| 218 | + | ||
| 219 | + | restored, ok := r2.Snapshot().Find(reg.Endpoint) | |
| 220 | + | require.True(t, ok) | |
| 221 | + | require.Equal(t, observed.Identity, restored.Identity) | |
| 222 | + | require.Equal(t, observed.Incarnation, restored.Incarnation) | |
| 223 | + | require.Equal(t, observed.ProtocolVersion, restored.ProtocolVersion) | |
| 224 | + | require.Equal(t, observed.Capabilities, restored.Capabilities) | |
| 225 | + | require.Equal(t, domain.RemoteAvailabilityReachable, restored.Availability) | |
| 226 | + | require.Equal(t, poolPolicy(), restored.Policy, "the loader re-stamps policy from membership") | |
| 227 | + | } | |
M internal/usecase/broker/registry_store_test.go +9 -9
| @@ -17,7 +17,7 @@ func TestRegistryRestoresAuthoritativeMembership(t *testing.T) { | |||
| 17 | 17 | old := registration(t, "host", 1) | |
| 18 | 18 | current := old | |
| 19 | 19 | store := portsmocks.NewMockBrokerHostStore(t) | |
| 20 | - | snapshot := ports.BrokerSnapshot{Epoch: 1, Revision: 9, Hosts: []ports.RemoteHostSnapshot{{Endpoint: "host", Registration: old, Availability: domain.RemoteAvailabilityReachable, InventoryKnown: true}}} | |
| 20 | + | snapshot := ports.BrokerSnapshot{Epoch: 1, Revision: 9, Daemons: []ports.BrokerDaemonObservation{{Endpoint: "host", Registration: old, Availability: domain.RemoteAvailabilityReachable, InventoryKnown: true}}} | |
| 21 | 21 | hosts := ports.BrokerHosts{Revision: 4} | |
| 22 | 22 | if mode == "replaced" { | |
| 23 | 23 | current.Generation++ | |
| @@ -36,12 +36,12 @@ func TestRegistryRestoresAuthoritativeMembership(t *testing.T) { | |||
| 36 | 36 | require.Equal(t, ports.BrokerEpoch(2), got.Epoch) | |
| 37 | 37 | require.Equal(t, ports.BrokerRevision(1), got.Revision) | |
| 38 | 38 | if mode == "removed" { | |
| 39 | - | require.Empty(t, got.Hosts) | |
| 39 | + | require.Empty(t, got.Daemons) | |
| 40 | 40 | return | |
| 41 | 41 | } | |
| 42 | - | require.Len(t, got.Hosts, 1) | |
| 43 | - | require.Equal(t, current, got.Hosts[0].Registration) | |
| 44 | - | require.Equal(t, mode == "matching", got.Hosts[0].InventoryKnown) | |
| 42 | + | require.Len(t, got.Daemons, 1) | |
| 43 | + | require.Equal(t, current, got.Daemons[0].Registration) | |
| 44 | + | require.Equal(t, mode == "matching", got.Daemons[0].InventoryKnown) | |
| 45 | 45 | }) | |
| 46 | 46 | } | |
| 47 | 47 | } | |
| @@ -98,7 +98,7 @@ func TestRegistryRestoreIsReadOnlyAndPublicationLocal(t *testing.T) { | |||
| 98 | 98 | clock := newManualClock(time.Unix(100, 0)) | |
| 99 | 99 | reg := registration(t, "host", 1) | |
| 100 | 100 | store := portsmocks.NewMockBrokerHostStore(t) | |
| 101 | - | store.EXPECT().Load().Return(ports.BrokerSnapshot{Epoch: 5, Revision: 4096, Hosts: []ports.RemoteHostSnapshot{{ | |
| 101 | + | store.EXPECT().Load().Return(ports.BrokerSnapshot{Epoch: 5, Revision: 4096, Daemons: []ports.BrokerDaemonObservation{{ | |
| 102 | 102 | Endpoint: reg.Endpoint, Registration: reg, Availability: domain.RemoteAvailabilityReachable, InventoryKnown: true, | |
| 103 | 103 | }}}, nil).Once() | |
| 104 | 104 | store.EXPECT().LoadHosts().Return(ports.BrokerHosts{Revision: 99, Hosts: []ports.BrokerHostRecord{{Registration: reg, Pinned: true, Policy: poolPolicy()}}}, nil).Once() | |
| @@ -110,7 +110,7 @@ func TestRegistryRestoreIsReadOnlyAndPublicationLocal(t *testing.T) { | |||
| 110 | 110 | require.Equal(t, ports.BrokerEpoch(6), snapshot.Epoch) | |
| 111 | 111 | // Restoring reuses the matching observation but never republishes it: a | |
| 112 | 112 | // fresh process adopts authority and rewrites only when it observes. | |
| 113 | - | require.Len(t, snapshot.Hosts, 1) | |
| 114 | - | require.True(t, snapshot.Hosts[0].InventoryKnown) | |
| 115 | - | require.Equal(t, domain.RemoteAvailabilityReachable, snapshot.Hosts[0].Availability) | |
| 113 | + | require.Len(t, snapshot.Daemons, 1) | |
| 114 | + | require.True(t, snapshot.Daemons[0].InventoryKnown) | |
| 115 | + | require.Equal(t, domain.RemoteAvailabilityReachable, snapshot.Daemons[0].Availability) | |
| 116 | 116 | } | |
M internal/usecase/broker/registry_test.go +464 -178
| @@ -12,9 +12,11 @@ import ( | |||
| 12 | 12 | "sync" | |
| 13 | 13 | "testing" | |
| 14 | 14 | "time" | |
| 15 | + | "unicode/utf8" | |
| 15 | 16 | ||
| 16 | 17 | "github.com/bnema/vev/internal/domain" | |
| 17 | 18 | "github.com/bnema/vev/internal/ports" | |
| 19 | + | "github.com/bnema/vev/internal/protocol" | |
| 18 | 20 | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 19 | 21 | "github.com/stretchr/testify/require" | |
| 20 | 22 | ) | |
| @@ -202,7 +204,7 @@ func (s *testStore) LoadHosts() (ports.BrokerHosts, error) { | |||
| 202 | 204 | return s.hosts, nil | |
| 203 | 205 | } | |
| 204 | 206 | authority := ports.BrokerHosts{Revision: 1} | |
| 205 | - | for _, host := range s.loaded.Hosts { | |
| 207 | + | for _, host := range s.loaded.Daemons { | |
| 206 | 208 | authority.Hosts = append(authority.Hosts, ports.BrokerHostRecord{Registration: host.Registration, Pinned: true, Policy: poolPolicy()}) | |
| 207 | 209 | } | |
| 208 | 210 | return authority, nil | |
| @@ -368,24 +370,24 @@ type probeCall struct { | |||
| 368 | 370 | } | |
| 369 | 371 | ||
| 370 | 372 | type probeAnswer struct { | |
| 371 | - | snapshot ports.RemoteHostSnapshot | |
| 373 | + | snapshot ports.BrokerDaemonObservation | |
| 372 | 374 | err error | |
| 373 | 375 | } | |
| 374 | 376 | ||
| 375 | - | func (p *testProbe) Probe(ctx context.Context, registration domain.RemoteRegistration) (ports.RemoteHostSnapshot, error) { | |
| 377 | + | func (p *testProbe) Probe(ctx context.Context, registration domain.RemoteRegistration) (ports.BrokerDaemonObservation, error) { | |
| 376 | 378 | call := probeCall{registration: registration, result: make(chan probeAnswer, 1)} | |
| 377 | 379 | select { | |
| 378 | 380 | case p.calls <- call: | |
| 379 | 381 | case <-ctx.Done(): | |
| 380 | 382 | p.noteCanceled() | |
| 381 | - | return ports.RemoteHostSnapshot{}, ctx.Err() | |
| 383 | + | return ports.BrokerDaemonObservation{}, ctx.Err() | |
| 382 | 384 | } | |
| 383 | 385 | select { | |
| 384 | 386 | case answer := <-call.result: | |
| 385 | 387 | return answer.snapshot, answer.err | |
| 386 | 388 | case <-ctx.Done(): | |
| 387 | 389 | p.noteCanceled() | |
| 388 | - | return ports.RemoteHostSnapshot{}, ctx.Err() | |
| 390 | + | return ports.BrokerDaemonObservation{}, ctx.Err() | |
| 389 | 391 | } | |
| 390 | 392 | } | |
| 391 | 393 | ||
| @@ -420,7 +422,7 @@ func newChurnProbe() *churnProbe { | |||
| 420 | 422 | } | |
| 421 | 423 | } | |
| 422 | 424 | ||
| 423 | - | func (p *churnProbe) Probe(ctx context.Context, registration domain.RemoteRegistration) (ports.RemoteHostSnapshot, error) { | |
| 425 | + | func (p *churnProbe) Probe(ctx context.Context, registration domain.RemoteRegistration) (ports.BrokerDaemonObservation, error) { | |
| 424 | 426 | p.mu.Lock() | |
| 425 | 427 | live := 0 | |
| 426 | 428 | for other, endpoint := range p.contexts { | |
| @@ -454,7 +456,7 @@ func (p *churnProbe) Probe(ctx context.Context, registration domain.RemoteRegist | |||
| 454 | 456 | case <-ctx.Done(): | |
| 455 | 457 | case <-p.release: | |
| 456 | 458 | } | |
| 457 | - | return ports.RemoteHostSnapshot{}, ctx.Err() | |
| 459 | + | return ports.BrokerDaemonObservation{}, ctx.Err() | |
| 458 | 460 | } | |
| 459 | 461 | ||
| 460 | 462 | // openRelease lets every still-blocked probe return, so a failing test never | |
| @@ -499,6 +501,21 @@ func registration(t *testing.T, endpoint string, marker byte) domain.RemoteRegis | |||
| 499 | 501 | return result | |
| 500 | 502 | } | |
| 501 | 503 | ||
| 504 | + | // hostRecord builds one pinned authority record with the shared test policy. | |
| 505 | + | // The projection-only setHosts seam takes records now, so membership carries | |
| 506 | + | // the same configured authority (registration plus policy) as production. | |
| 507 | + | func hostRecord(reg domain.RemoteRegistration) ports.BrokerHostRecord { | |
| 508 | + | return ports.BrokerHostRecord{Registration: reg, Pinned: true, Policy: poolPolicy()} | |
| 509 | + | } | |
| 510 | + | ||
| 511 | + | func hostRecords(registrations ...domain.RemoteRegistration) []ports.BrokerHostRecord { | |
| 512 | + | records := make([]ports.BrokerHostRecord, 0, len(registrations)) | |
| 513 | + | for _, reg := range registrations { | |
| 514 | + | records = append(records, hostRecord(reg)) | |
| 515 | + | } | |
| 516 | + | return records | |
| 517 | + | } | |
| 518 | + | ||
| 502 | 519 | // hostSession builds one catalogue-valid session. The registry applies the | |
| 503 | 520 | // same durable projection rules as the store before adopting an observation, so | |
| 504 | 521 | // a fixture session must carry a non-zero lifecycle identity and a non-nil tab | |
| @@ -590,7 +607,7 @@ func waitSnapshot(t *testing.T, r *Registry, predicate func(ports.BrokerSnapshot | |||
| 590 | 607 | } | |
| 591 | 608 | } | |
| 592 | 609 | ||
| 593 | - | func waitHost(t *testing.T, r *Registry, endpoint string, predicate func(ports.RemoteHostSnapshot) bool) ports.RemoteHostSnapshot { | |
| 610 | + | func waitHost(t *testing.T, r *Registry, endpoint string, predicate func(ports.BrokerDaemonObservation) bool) ports.BrokerDaemonObservation { | |
| 594 | 611 | t.Helper() | |
| 595 | 612 | snapshot := waitSnapshot(t, r, func(snapshot ports.BrokerSnapshot) bool { | |
| 596 | 613 | host, ok := snapshot.Find(endpoint) | |
| @@ -603,7 +620,7 @@ func waitHost(t *testing.T, r *Registry, endpoint string, predicate func(ports.R | |||
| 603 | 620 | // requireHostStableWithin asserts a stale completion has no delayed effect: | |
| 604 | 621 | // the published host stays exactly as observed for a bounded window, and any | |
| 605 | 622 | // intervening publication fails immediately. | |
| 606 | - | func requireHostStableWithin(t *testing.T, r *Registry, endpoint string, want ports.RemoteHostSnapshot, window time.Duration) { | |
| 623 | + | func requireHostStableWithin(t *testing.T, r *Registry, endpoint string, want ports.BrokerDaemonObservation, window time.Duration) { | |
| 607 | 624 | t.Helper() | |
| 608 | 625 | sub := r.Subscribe() | |
| 609 | 626 | defer sub.Close() | |
| @@ -682,14 +699,15 @@ func attemptFor(r *Registry, endpoint string) uint64 { | |||
| 682 | 699 | func TestRegistryRestoresPersistedSnapshotUnderFreshEpoch(t *testing.T) { | |
| 683 | 700 | reg := registration(t, "example.test", 1) | |
| 684 | 701 | sessions := []catalogue.RemoteCatalogSession{{ | |
| 685 | - | Name: "work", | |
| 686 | - | State: catalogue.RemoteCatalogSessionUp, | |
| 687 | - | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Name: "shell"}}, | |
| 702 | + | LifecycleID: domain.SessionLifecycleID{1}, | |
| 703 | + | Name: "work", | |
| 704 | + | State: catalogue.RemoteCatalogSessionUp, | |
| 705 | + | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Name: "shell"}}, | |
| 688 | 706 | }} | |
| 689 | 707 | stored := ports.BrokerSnapshot{ | |
| 690 | 708 | Epoch: 7, | |
| 691 | 709 | Revision: 9, | |
| 692 | - | Hosts: []ports.RemoteHostSnapshot{{ | |
| 710 | + | Daemons: []ports.BrokerDaemonObservation{{ | |
| 693 | 711 | Endpoint: reg.Endpoint, | |
| 694 | 712 | Registration: reg, | |
| 695 | 713 | Availability: domain.RemoteAvailabilityReachable, | |
| @@ -709,10 +727,10 @@ func TestRegistryRestoresPersistedSnapshotUnderFreshEpoch(t *testing.T) { | |||
| 709 | 727 | // A restored broker process starts a fresh revision series: revisions | |
| 710 | 728 | // never compare across epochs. | |
| 711 | 729 | require.Equal(t, ports.BrokerRevision(1), snapshot.Revision) | |
| 712 | - | require.Len(t, snapshot.Hosts, 1) | |
| 730 | + | require.Len(t, snapshot.Daemons, 1) | |
| 713 | 731 | require.Empty(t, snapshot.Removed) | |
| 714 | 732 | ||
| 715 | - | host := snapshot.Hosts[0] | |
| 733 | + | host := snapshot.Daemons[0] | |
| 716 | 734 | require.True(t, host.Registration.Equal(reg)) | |
| 717 | 735 | require.Equal(t, domain.RemoteAvailabilityReachable, host.Availability) | |
| 718 | 736 | require.Equal(t, time.Unix(80, 0), host.NextDue) | |
| @@ -753,7 +771,7 @@ func TestRegistryRestoreRejectsInvalidInputs(t *testing.T) { | |||
| 753 | 771 | require.Error(t, err) | |
| 754 | 772 | ||
| 755 | 773 | // A durable record without an epoch is inconsistent, never silently empty. | |
| 756 | - | _, err = NewRegistry(1, &testStore{loaded: ports.BrokerSnapshot{Epoch: 0, Hosts: []ports.RemoteHostSnapshot{{Endpoint: reg.Endpoint, Registration: reg}}}}, newTestProbe(1), clock, nil) | |
| 774 | + | _, err = NewRegistry(1, &testStore{loaded: ports.BrokerSnapshot{Epoch: 0, Daemons: []ports.BrokerDaemonObservation{{Endpoint: reg.Endpoint, Registration: reg}}}}, newTestProbe(1), clock, nil) | |
| 757 | 775 | require.Error(t, err) | |
| 758 | 776 | _, err = NewRegistry(1, &testStore{loaded: ports.BrokerSnapshot{Epoch: 0, Removed: []ports.BrokerHostTombstone{{Endpoint: reg.Endpoint, Registration: reg, RetiredRevision: 3}}}}, newTestProbe(1), clock, nil) | |
| 759 | 777 | require.Error(t, err) | |
| @@ -763,7 +781,7 @@ func TestRegistryRestoreRejectsInvalidInputs(t *testing.T) { | |||
| 763 | 781 | empty, err := NewRegistry(1, &testStore{}, newTestProbe(1), clock, nil) | |
| 764 | 782 | require.NoError(t, err) | |
| 765 | 783 | require.Equal(t, ports.BrokerRevision(1), empty.Snapshot().Revision) | |
| 766 | - | require.Empty(t, empty.Snapshot().Hosts) | |
| 784 | + | require.Empty(t, empty.Snapshot().Daemons) | |
| 767 | 785 | } | |
| 768 | 786 | ||
| 769 | 787 | func TestRegistryRestoreRejectsPersistedSnapshotFromItsOwnEpoch(t *testing.T) { | |
| @@ -777,7 +795,7 @@ func TestRegistryRestoreRejectsPersistedSnapshotFromItsOwnEpoch(t *testing.T) { | |||
| 777 | 795 | currentEpoch := ports.BrokerSnapshot{ | |
| 778 | 796 | Epoch: 7, | |
| 779 | 797 | Revision: 9, | |
| 780 | - | Hosts: []ports.RemoteHostSnapshot{{ | |
| 798 | + | Daemons: []ports.BrokerDaemonObservation{{ | |
| 781 | 799 | Endpoint: reg.Endpoint, | |
| 782 | 800 | Registration: reg, | |
| 783 | 801 | Availability: domain.RemoteAvailabilityReachable, | |
| @@ -799,26 +817,26 @@ func TestRegistryRestoreRejectsPersistedSnapshotFromItsOwnEpoch(t *testing.T) { | |||
| 799 | 817 | // A different epoch is still adopted normally. | |
| 800 | 818 | registry, err := NewRegistry(8, &testStore{loaded: currentEpoch}, newTestProbe(1), clock, nil) | |
| 801 | 819 | require.NoError(t, err) | |
| 802 | - | require.Len(t, registry.Snapshot().Hosts, 1) | |
| 820 | + | require.Len(t, registry.Snapshot().Daemons, 1) | |
| 803 | 821 | } | |
| 804 | 822 | ||
| 805 | 823 | func TestRegistrySetHostsRejectsInvalidRegistrations(t *testing.T) { | |
| 806 | 824 | registry := newTestRegistry(t, 1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0))) | |
| 807 | 825 | ||
| 808 | 826 | reg := registration(t, "example.test", 1) | |
| 809 | - | require.Error(t, registry.setHosts([]domain.RemoteRegistration{reg, reg})) | |
| 810 | - | require.Error(t, registry.setHosts([]domain.RemoteRegistration{{Endpoint: reg.Endpoint}})) | |
| 811 | - | require.Empty(t, registry.Snapshot().Hosts) | |
| 827 | + | require.Error(t, registry.setHosts(hostRecords(reg, reg))) | |
| 828 | + | require.Error(t, registry.setHosts(hostRecords(domain.RemoteRegistration{Endpoint: reg.Endpoint}))) | |
| 829 | + | require.Empty(t, registry.Snapshot().Daemons) | |
| 812 | 830 | } | |
| 813 | 831 | ||
| 814 | 832 | func TestRegistrySetHostsRejectsHostLimitWithoutMutation(t *testing.T) { | |
| 815 | 833 | registry := newTestRegistry(t, 1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0))) | |
| 816 | 834 | reg := registration(t, "example.test", 1) | |
| 817 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 835 | + | require.NoError(t, registry.setHosts(hostRecords(reg))) | |
| 818 | 836 | ||
| 819 | - | oversized := make([]domain.RemoteRegistration, 0, ports.BrokerMaxHosts+1) | |
| 837 | + | oversized := make([]ports.BrokerHostRecord, 0, ports.BrokerMaxHosts+1) | |
| 820 | 838 | for index := 0; index <= ports.BrokerMaxHosts; index++ { | |
| 821 | - | oversized = append(oversized, registration(t, fmt.Sprintf("host-%d.test", index), byte(index+1))) | |
| 839 | + | oversized = append(oversized, hostRecord(registration(t, fmt.Sprintf("host-%d.test", index), byte(index+1)))) | |
| 822 | 840 | } | |
| 823 | 841 | before := registry.Snapshot() | |
| 824 | 842 | require.Error(t, registry.setHosts(oversized)) | |
| @@ -827,16 +845,16 @@ func TestRegistrySetHostsRejectsHostLimitWithoutMutation(t *testing.T) { | |||
| 827 | 845 | ||
| 828 | 846 | // The boundary itself is accepted. | |
| 829 | 847 | require.NoError(t, registry.setHosts(oversized[:ports.BrokerMaxHosts])) | |
| 830 | - | require.Len(t, registry.Snapshot().Hosts, ports.BrokerMaxHosts) | |
| 848 | + | require.Len(t, registry.Snapshot().Daemons, ports.BrokerMaxHosts) | |
| 831 | 849 | } | |
| 832 | 850 | ||
| 833 | - | func TestRegistryUnchangedSetHostsIsNoOp(t *testing.T) { | |
| 851 | + | func TestRegistryUnchangedSetHostsIsNoOpAndReorderRepublishes(t *testing.T) { | |
| 834 | 852 | clock := newManualClock(time.Unix(100, 0)) | |
| 835 | 853 | probe := newTestProbe(2) | |
| 836 | 854 | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 837 | 855 | first := registration(t, "example.test", 1) | |
| 838 | 856 | second := registration(t, "other.test", 2) | |
| 839 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{first, second})) | |
| 857 | + | require.NoError(t, registry.setHosts(hostRecords(first, second))) | |
| 840 | 858 | startRegistry(t, registry) | |
| 841 | 859 | ||
| 842 | 860 | // Every configured host is observed exactly once. | |
| @@ -847,37 +865,128 @@ func TestRegistryUnchangedSetHostsIsNoOp(t *testing.T) { | |||
| 847 | 865 | } | |
| 848 | 866 | require.Len(t, calls, 2) | |
| 849 | 867 | before := registry.Snapshot() | |
| 850 | - | for _, host := range before.Hosts { | |
| 868 | + | for _, host := range before.Daemons { | |
| 851 | 869 | require.True(t, host.Checking) | |
| 852 | 870 | } | |
| 853 | 871 | ||
| 854 | - | // Identical membership, including in a different order, never republishes | |
| 855 | - | // and never restarts an in-flight observation. | |
| 856 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{second, first})) | |
| 857 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{first, second})) | |
| 872 | + | // Identical membership in the same order never republishes and never | |
| 873 | + | // restarts an in-flight observation. | |
| 874 | + | require.NoError(t, registry.setHosts(hostRecords(first, second))) | |
| 858 | 875 | require.Equal(t, before, registry.Snapshot()) | |
| 859 | 876 | requireNoProbeWithin(t, probe, probeGrace) | |
| 860 | 877 | ||
| 878 | + | // A reordering of identical records is a membership change: publication | |
| 879 | + | // order and the presentation rank stamped from it follow durable membership | |
| 880 | + | // authority, so the registry republishes both without cancelling the | |
| 881 | + | // in-flight observation. | |
| 882 | + | require.NoError(t, registry.setHosts(hostRecords(second, first))) | |
| 883 | + | reordered := registry.Snapshot() | |
| 884 | + | require.Greater(t, reordered.Revision, before.Revision) | |
| 885 | + | require.Equal(t, []string{"other.test", "example.test"}, []string{reordered.Daemons[0].Endpoint, reordered.Daemons[1].Endpoint}) | |
| 886 | + | require.Equal(t, 0, reordered.Daemons[0].Rank) | |
| 887 | + | require.Equal(t, 1, reordered.Daemons[1].Rank) | |
| 888 | + | for _, host := range reordered.Daemons { | |
| 889 | + | require.True(t, host.Checking) | |
| 890 | + | } | |
| 891 | + | requireNoProbeWithin(t, probe, probeGrace) | |
| 892 | + | ||
| 893 | + | // Restoring the configured order restores the published order and ranks. | |
| 894 | + | require.NoError(t, registry.setHosts(hostRecords(first, second))) | |
| 895 | + | require.Equal(t, before.Daemons, registry.Snapshot().Daemons) | |
| 896 | + | requireNoProbeWithin(t, probe, probeGrace) | |
| 897 | + | ||
| 861 | 898 | // The in-flight observations still complete normally. | |
| 862 | 899 | for endpoint, call := range calls { | |
| 863 | - | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}} | |
| 864 | - | waitHost(t, registry, endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 900 | + | call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}} | |
| 901 | + | waitHost(t, registry, endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 865 | 902 | return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable | |
| 866 | 903 | }) | |
| 867 | 904 | } | |
| 868 | 905 | } | |
| 869 | 906 | ||
| 907 | + | // TestRegistrySanitizesDerivedDisplayOrigin pins the boundary between routing | |
| 908 | + | // authority and presentation: a target the routing validator accepts may carry | |
| 909 | + | // bidi controls or line separators, and a hint the registry derives from it must | |
| 910 | + | // never render them. The sanitized hint is published, satisfies the contract | |
| 911 | + | // gate, and leaves the host observable. | |
| 912 | + | func TestRegistrySanitizesDerivedDisplayOrigin(t *testing.T) { | |
| 913 | + | tests := []struct { | |
| 914 | + | name string | |
| 915 | + | endpoint string | |
| 916 | + | want string | |
| 917 | + | }{ | |
| 918 | + | {name: "login prefix strips the bidi rune from the origin", endpoint: "user@ho\u202est.test", want: "host.test"}, | |
| 919 | + | {name: "bare target drops the bidi rune", endpoint: "ho\u202est.test", want: "host.test"}, | |
| 920 | + | {name: "right-to-left mark dropped", endpoint: "user@ho\u200fst.test", want: "host.test"}, | |
| 921 | + | {name: "ordinary target unchanged", endpoint: "user@host.test", want: "host.test"}, | |
| 922 | + | } | |
| 923 | + | for _, tc := range tests { | |
| 924 | + | t.Run(tc.name, func(t *testing.T) { | |
| 925 | + | clock := newManualClock(time.Unix(100, 0)) | |
| 926 | + | probe := newTestProbe(1) | |
| 927 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 928 | + | require.NoError(t, registry.setHosts(hostRecords(registration(t, tc.endpoint, 1)))) | |
| 929 | + | ||
| 930 | + | host := registry.Snapshot().Daemons[0] | |
| 931 | + | require.Equal(t, tc.endpoint, host.Endpoint, "the routing target stays authoritative") | |
| 932 | + | require.Equal(t, tc.want, host.DisplayOrigin) | |
| 933 | + | require.NoError(t, host.Validate()) | |
| 934 | + | require.NoError(t, registry.Snapshot().Validate()) | |
| 935 | + | ||
| 936 | + | // Sanitizing the hint never makes the host unobservable. | |
| 937 | + | startRegistry(t, registry) | |
| 938 | + | call := receiveCall(t, probe) | |
| 939 | + | call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}} | |
| 940 | + | waitHost(t, registry, tc.endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 941 | + | return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable | |
| 942 | + | }) | |
| 943 | + | }) | |
| 944 | + | } | |
| 945 | + | } | |
| 946 | + | ||
| 947 | + | // TestRegistryRefusesUnpublishableMembership pins the fail-closed membership | |
| 948 | + | // boundary: a target whose derived hint holds only refused runes has no | |
| 949 | + | // displayable origin, and membership that cannot be published is refused where | |
| 950 | + | // authority is owned instead of freezing every later publication. | |
| 951 | + | func TestRegistryRefusesUnpublishableMembership(t *testing.T) { | |
| 952 | + | registry := newTestRegistry(t, 1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0))) | |
| 953 | + | err := registry.setHosts(hostRecords(registration(t, "user@\u202e", 1))) | |
| 954 | + | require.ErrorContains(t, err, "not publishable") | |
| 955 | + | require.Empty(t, registry.Snapshot().Daemons) | |
| 956 | + | } | |
| 957 | + | ||
| 958 | + | // TestRegistryRefusesInvalidPublication pins the last-resort publication guard: | |
| 959 | + | // a projection that reached the live set unpublished is never published, because | |
| 960 | + | // a snapshot the wire refuses aborts every client connection on subscribe, and a | |
| 961 | + | // refused publication never advances the revision series. | |
| 962 | + | func TestRegistryRefusesInvalidPublication(t *testing.T) { | |
| 963 | + | registry := newTestRegistry(t, 1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0))) | |
| 964 | + | require.NoError(t, registry.setHosts(hostRecords(registration(t, "example.test", 1)))) | |
| 965 | + | before := registry.Snapshot() | |
| 966 | + | ||
| 967 | + | // Inject behind the membership boundary, which now refuses such a record. | |
| 968 | + | registry.mu.Lock() | |
| 969 | + | broken := before.Daemons[0].Clone() | |
| 970 | + | broken.DisplayOrigin = "" | |
| 971 | + | registry.hosts[broken.Endpoint] = broken | |
| 972 | + | registry.publishLocked(false) | |
| 973 | + | registry.mu.Unlock() | |
| 974 | + | ||
| 975 | + | require.Equal(t, before, registry.Snapshot()) | |
| 976 | + | } | |
| 977 | + | ||
| 870 | 978 | func TestRegistrySnapshotIsImmutable(t *testing.T) { | |
| 871 | 979 | clock := newManualClock(time.Unix(100, 0)) | |
| 872 | 980 | probe := newTestProbe(1) | |
| 873 | 981 | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 874 | 982 | reg := registration(t, "example.test", 1) | |
| 875 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 983 | + | require.NoError(t, registry.setHosts(hostRecords(reg))) | |
| 876 | 984 | startRegistry(t, registry) | |
| 877 | 985 | ||
| 878 | 986 | call := receiveCall(t, probe) | |
| 879 | - | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | |
| 880 | - | Availability: domain.RemoteAvailabilityReachable, | |
| 987 | + | call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{ | |
| 988 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 989 | + | InventoryKnown: true, | |
| 881 | 990 | Sessions: []catalogue.RemoteCatalogSession{{ | |
| 882 | 991 | LifecycleID: domain.SessionLifecycleID{1}, | |
| 883 | 992 | Name: "work", | |
| @@ -885,7 +994,7 @@ func TestRegistrySnapshotIsImmutable(t *testing.T) { | |||
| 885 | 994 | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Name: "shell"}}, | |
| 886 | 995 | }}, | |
| 887 | 996 | }} | |
| 888 | - | observed := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 997 | + | observed := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 889 | 998 | return !host.Checking && len(host.Sessions) == 1 | |
| 890 | 999 | }) | |
| 891 | 1000 | require.Equal(t, "work", observed.Sessions[0].Name) | |
| @@ -895,22 +1004,23 @@ func TestRegistrySnapshotIsImmutable(t *testing.T) { | |||
| 895 | 1004 | observed.Sessions[0].Name = "mutated" | |
| 896 | 1005 | observed.Sessions[0].Tabs[0].Name = "mutated" | |
| 897 | 1006 | current := registry.Snapshot() | |
| 898 | - | require.Equal(t, reg.Endpoint, current.Hosts[0].Endpoint) | |
| 899 | - | require.Equal(t, "work", current.Hosts[0].Sessions[0].Name) | |
| 900 | - | require.Equal(t, "shell", current.Hosts[0].Sessions[0].Tabs[0].Name) | |
| 1007 | + | require.Equal(t, reg.Endpoint, current.Daemons[0].Endpoint) | |
| 1008 | + | require.Equal(t, "work", current.Daemons[0].Sessions[0].Name) | |
| 1009 | + | require.Equal(t, "shell", current.Daemons[0].Sessions[0].Tabs[0].Name) | |
| 901 | 1010 | ||
| 902 | 1011 | // A later publication never mutates a snapshot already handed out. | |
| 903 | 1012 | earlier := registry.Snapshot() | |
| 904 | 1013 | registry.RequestProbe(reg.Endpoint) | |
| 905 | 1014 | next := receiveCall(t, probe) | |
| 906 | - | next.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | |
| 907 | - | Availability: domain.RemoteAvailabilityReachable, | |
| 908 | - | Sessions: []catalogue.RemoteCatalogSession{hostSession("other")}, | |
| 1015 | + | next.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{ | |
| 1016 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 1017 | + | InventoryKnown: true, | |
| 1018 | + | Sessions: []catalogue.RemoteCatalogSession{hostSession("other")}, | |
| 909 | 1019 | }} | |
| 910 | - | waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 1020 | + | waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 911 | 1021 | return !host.Checking && len(host.Sessions) == 1 && host.Sessions[0].Name == "other" | |
| 912 | 1022 | }) | |
| 913 | - | require.Equal(t, "work", earlier.Hosts[0].Sessions[0].Name) | |
| 1023 | + | require.Equal(t, "work", earlier.Daemons[0].Sessions[0].Name) | |
| 914 | 1024 | } | |
| 915 | 1025 | ||
| 916 | 1026 | func TestRegistrySchedulesFreshObservationAfterSuccess(t *testing.T) { | |
| @@ -919,13 +1029,13 @@ func TestRegistrySchedulesFreshObservationAfterSuccess(t *testing.T) { | |||
| 919 | 1029 | probe := newTestProbe(1) | |
| 920 | 1030 | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 921 | 1031 | reg := registration(t, "example.test", 1) | |
| 922 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1032 | + | require.NoError(t, registry.setHosts(hostRecords(reg))) | |
| 923 | 1033 | startRegistry(t, registry) | |
| 924 | 1034 | ||
| 925 | 1035 | call := receiveCall(t, probe) | |
| 926 | - | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}} | |
| 1036 | + | call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}} | |
| 927 | 1037 | ||
| 928 | - | host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 1038 | + | host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 929 | 1039 | return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable | |
| 930 | 1040 | }) | |
| 931 | 1041 | require.Equal(t, start, host.LastAttempt) | |
| @@ -948,12 +1058,12 @@ func TestRegistryDemandOverridesFreshSchedule(t *testing.T) { | |||
| 948 | 1058 | probe := newTestProbe(1) | |
| 949 | 1059 | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 950 | 1060 | reg := registration(t, "example.test", 1) | |
| 951 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1061 | + | require.NoError(t, registry.setHosts(hostRecords(reg))) | |
| 952 | 1062 | startRegistry(t, registry) | |
| 953 | 1063 | ||
| 954 | 1064 | call := receiveCall(t, probe) | |
| 955 | - | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}} | |
| 956 | - | waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 1065 | + | call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}} | |
| 1066 | + | waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 957 | 1067 | return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable | |
| 958 | 1068 | }) | |
| 959 | 1069 | ||
| @@ -970,7 +1080,7 @@ func TestRegistryCoalescesConcurrentDemand(t *testing.T) { | |||
| 970 | 1080 | probe := newTestProbe(2) | |
| 971 | 1081 | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 972 | 1082 | reg := registration(t, "example.test", 1) | |
| 973 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1083 | + | require.NoError(t, registry.setHosts(hostRecords(reg))) | |
| 974 | 1084 | startRegistry(t, registry) | |
| 975 | 1085 | ||
| 976 | 1086 | call := receiveCall(t, probe) | |
| @@ -982,8 +1092,8 @@ func TestRegistryCoalescesConcurrentDemand(t *testing.T) { | |||
| 982 | 1092 | t.Fatalf("concurrent demand started a duplicate probe for %q", duplicate.registration.Endpoint) | |
| 983 | 1093 | default: | |
| 984 | 1094 | } | |
| 985 | - | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}} | |
| 986 | - | host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 1095 | + | call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}} | |
| 1096 | + | host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 987 | 1097 | return host.Availability == domain.RemoteAvailabilityReachable && host.LastSuccess.Equal(start) | |
| 988 | 1098 | }) | |
| 989 | 1099 | require.Equal(t, start.Add(defaultFreshFor), host.NextDue) | |
| @@ -991,8 +1101,8 @@ func TestRegistryCoalescesConcurrentDemand(t *testing.T) { | |||
| 991 | 1101 | // The single coalesced follow-up is admitted once the first completes. | |
| 992 | 1102 | followUp := receiveCall(t, probe) | |
| 993 | 1103 | require.True(t, followUp.registration.Equal(reg)) | |
| 994 | - | followUp.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}} | |
| 995 | - | waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 1104 | + | followUp.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}} | |
| 1105 | + | waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 996 | 1106 | return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable | |
| 997 | 1107 | }) | |
| 998 | 1108 | } | |
| @@ -1002,7 +1112,7 @@ func TestRegistryIgnoresDemandForUnknownEndpoint(t *testing.T) { | |||
| 1002 | 1112 | probe := newTestProbe(1) | |
| 1003 | 1113 | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 1004 | 1114 | reg := registration(t, "example.test", 1) | |
| 1005 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1115 | + | require.NoError(t, registry.setHosts(hostRecords(reg))) | |
| 1006 | 1116 | registry.RequestProbe("unknown.test") | |
| 1007 | 1117 | startRegistry(t, registry) | |
| 1008 | 1118 | ||
| @@ -1019,11 +1129,11 @@ func TestRegistryPublishesReachabilityAndPersists(t *testing.T) { | |||
| 1019 | 1129 | store := &testStore{} | |
| 1020 | 1130 | registry := newTestRegistry(t, 3, store, probe, clock) | |
| 1021 | 1131 | reg := registration(t, "example.test", 1) | |
| 1022 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1132 | + | require.NoError(t, registry.setHosts(hostRecords(reg))) | |
| 1023 | 1133 | startRegistry(t, registry) | |
| 1024 | 1134 | ||
| 1025 | 1135 | call := receiveCall(t, probe) | |
| 1026 | - | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | |
| 1136 | + | call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{ | |
| 1027 | 1137 | Availability: domain.RemoteAvailabilityReachable, | |
| 1028 | 1138 | InventoryKnown: true, | |
| 1029 | 1139 | Sessions: []catalogue.RemoteCatalogSession{hostSession("work")}, | |
| @@ -1034,13 +1144,13 @@ func TestRegistryPublishesReachabilityAndPersists(t *testing.T) { | |||
| 1034 | 1144 | }) | |
| 1035 | 1145 | ||
| 1036 | 1146 | stored := waitStored(t, store, func(stored ports.BrokerSnapshot) bool { | |
| 1037 | - | return stored.Revision >= snapshot.Revision && len(stored.Hosts) == 1 | |
| 1147 | + | return stored.Revision >= snapshot.Revision && len(stored.Daemons) == 1 | |
| 1038 | 1148 | }) | |
| 1039 | 1149 | require.Equal(t, ports.BrokerEpoch(3), stored.Epoch) | |
| 1040 | - | require.Len(t, stored.Hosts, 1) | |
| 1041 | - | require.Equal(t, "work", stored.Hosts[0].Sessions[0].Name) | |
| 1042 | - | require.Equal(t, reg.Endpoint, stored.Hosts[0].Endpoint) | |
| 1043 | - | require.False(t, stored.Hosts[0].Checking) | |
| 1150 | + | require.Len(t, stored.Daemons, 1) | |
| 1151 | + | require.Equal(t, "work", stored.Daemons[0].Sessions[0].Name) | |
| 1152 | + | require.Equal(t, reg.Endpoint, stored.Daemons[0].Endpoint) | |
| 1153 | + | require.False(t, stored.Daemons[0].Checking) | |
| 1044 | 1154 | } | |
| 1045 | 1155 | ||
| 1046 | 1156 | func TestRegistryNonReachableOutcomesUseTypedFailureBackoff(t *testing.T) { | |
| @@ -1054,31 +1164,31 @@ func TestRegistryNonReachableOutcomesUseTypedFailureBackoff(t *testing.T) { | |||
| 1054 | 1164 | }{ | |
| 1055 | 1165 | { | |
| 1056 | 1166 | name: "unreachable without error", | |
| 1057 | - | answer: probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityUnreachable}}, | |
| 1167 | + | answer: probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}}, | |
| 1058 | 1168 | wantAvailability: domain.RemoteAvailabilityUnreachable, | |
| 1059 | 1169 | wantKind: domain.RemoteFailureTransport, | |
| 1060 | 1170 | }, | |
| 1061 | 1171 | { | |
| 1062 | 1172 | name: "unknown outcome is not reachability", | |
| 1063 | - | answer: probeAnswer{snapshot: ports.RemoteHostSnapshot{}}, | |
| 1173 | + | answer: probeAnswer{snapshot: ports.BrokerDaemonObservation{}}, | |
| 1064 | 1174 | wantAvailability: domain.RemoteAvailabilityUnreachable, | |
| 1065 | 1175 | wantKind: domain.RemoteFailureTransport, | |
| 1066 | 1176 | }, | |
| 1067 | 1177 | { | |
| 1068 | 1178 | name: "incompatible", | |
| 1069 | - | answer: probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityIncompatible}}, | |
| 1179 | + | answer: probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityIncompatible}}, | |
| 1070 | 1180 | wantAvailability: domain.RemoteAvailabilityIncompatible, | |
| 1071 | 1181 | wantKind: domain.RemoteFailureIncompatible, | |
| 1072 | 1182 | }, | |
| 1073 | 1183 | { | |
| 1074 | 1184 | name: "authentication", | |
| 1075 | - | answer: probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityAuthFailed}}, | |
| 1185 | + | answer: probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityAuthFailed}}, | |
| 1076 | 1186 | wantAvailability: domain.RemoteAvailabilityAuthFailed, | |
| 1077 | 1187 | wantKind: domain.RemoteFailureAuthentication, | |
| 1078 | 1188 | }, | |
| 1079 | 1189 | { | |
| 1080 | 1190 | name: "invalid response", | |
| 1081 | - | answer: probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityInvalidResponse}}, | |
| 1191 | + | answer: probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityInvalidResponse}}, | |
| 1082 | 1192 | wantAvailability: domain.RemoteAvailabilityInvalidResponse, | |
| 1083 | 1193 | wantKind: domain.RemoteFailureInvalidResponse, | |
| 1084 | 1194 | }, | |
| @@ -1104,12 +1214,12 @@ func TestRegistryNonReachableOutcomesUseTypedFailureBackoff(t *testing.T) { | |||
| 1104 | 1214 | probe := newTestProbe(1) | |
| 1105 | 1215 | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 1106 | 1216 | reg := registration(t, "example.test", 1) | |
| 1107 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1217 | + | require.NoError(t, registry.setHosts(hostRecords(reg))) | |
| 1108 | 1218 | startRegistry(t, registry) | |
| 1109 | 1219 | ||
| 1110 | 1220 | call := receiveCall(t, probe) | |
| 1111 | 1221 | call.result <- tt.answer | |
| 1112 | - | host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 1222 | + | host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 1113 | 1223 | return !host.Checking && host.ConsecutiveFailures == 1 | |
| 1114 | 1224 | }) | |
| 1115 | 1225 | require.Equal(t, tt.wantAvailability, host.Availability) | |
| @@ -1135,18 +1245,19 @@ func TestRegistryClassifiesOversizedProbeProjectionAsInvalidResponse(t *testing. | |||
| 1135 | 1245 | probe := newTestProbe(1) | |
| 1136 | 1246 | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 1137 | 1247 | reg := registration(t, "example.test", 1) | |
| 1138 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1248 | + | require.NoError(t, registry.setHosts(hostRecords(reg))) | |
| 1139 | 1249 | startRegistry(t, registry) | |
| 1140 | 1250 | sub := registry.Subscribe() | |
| 1141 | 1251 | defer sub.Close() | |
| 1142 | 1252 | ||
| 1143 | 1253 | // A reachable observation establishes the last-known inventory. | |
| 1144 | 1254 | call := receiveCall(t, probe) | |
| 1145 | - | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | |
| 1146 | - | Availability: domain.RemoteAvailabilityReachable, | |
| 1147 | - | Sessions: []catalogue.RemoteCatalogSession{hostSession("work")}, | |
| 1255 | + | call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{ | |
| 1256 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 1257 | + | InventoryKnown: true, | |
| 1258 | + | Sessions: []catalogue.RemoteCatalogSession{hostSession("work")}, | |
| 1148 | 1259 | }} | |
| 1149 | - | waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 1260 | + | waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 1150 | 1261 | return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable | |
| 1151 | 1262 | }) | |
| 1152 | 1263 | ||
| @@ -1157,12 +1268,13 @@ func TestRegistryClassifiesOversizedProbeProjectionAsInvalidResponse(t *testing. | |||
| 1157 | 1268 | // never pass unnoticed. | |
| 1158 | 1269 | clock.Advance(defaultFreshFor) | |
| 1159 | 1270 | call = receiveCall(t, probe) | |
| 1160 | - | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | |
| 1161 | - | Availability: domain.RemoteAvailabilityReachable, | |
| 1162 | - | Sessions: hostSessions(ports.BrokerMaxSessionsPerHost + 1), | |
| 1271 | + | call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{ | |
| 1272 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 1273 | + | InventoryKnown: true, | |
| 1274 | + | Sessions: hostSessions(ports.BrokerMaxSessionsPerHost + 1), | |
| 1163 | 1275 | }} | |
| 1164 | 1276 | deadline := time.After(time.Second) | |
| 1165 | - | var host ports.RemoteHostSnapshot | |
| 1277 | + | var host ports.BrokerDaemonObservation | |
| 1166 | 1278 | for { | |
| 1167 | 1279 | snapshot := registry.Snapshot() | |
| 1168 | 1280 | require.NoError(t, snapshot.Validate()) | |
| @@ -1192,24 +1304,25 @@ func TestRegistryAcceptsProbeProjectionAtSessionBound(t *testing.T) { | |||
| 1192 | 1304 | probe := newTestProbe(1) | |
| 1193 | 1305 | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 1194 | 1306 | reg := registration(t, "example.test", 1) | |
| 1195 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1307 | + | require.NoError(t, registry.setHosts(hostRecords(reg))) | |
| 1196 | 1308 | startRegistry(t, registry) | |
| 1197 | 1309 | ||
| 1198 | 1310 | // The bound itself is valid: exactly BrokerMaxSessionsPerHost sessions are | |
| 1199 | 1311 | // accepted and published, and the resulting snapshot validates. | |
| 1200 | 1312 | call := receiveCall(t, probe) | |
| 1201 | - | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | |
| 1202 | - | Availability: domain.RemoteAvailabilityReachable, | |
| 1203 | - | Sessions: hostSessions(ports.BrokerMaxSessionsPerHost), | |
| 1313 | + | call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{ | |
| 1314 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 1315 | + | InventoryKnown: true, | |
| 1316 | + | Sessions: hostSessions(ports.BrokerMaxSessionsPerHost), | |
| 1204 | 1317 | }} | |
| 1205 | - | host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 1318 | + | host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 1206 | 1319 | return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable | |
| 1207 | 1320 | }) | |
| 1208 | 1321 | require.Len(t, host.Sessions, ports.BrokerMaxSessionsPerHost) | |
| 1209 | 1322 | require.Zero(t, host.ConsecutiveFailures) | |
| 1210 | 1323 | snapshot := registry.Snapshot() | |
| 1211 | 1324 | require.NoError(t, snapshot.Validate()) | |
| 1212 | - | require.Len(t, snapshot.Hosts[0].Sessions, ports.BrokerMaxSessionsPerHost) | |
| 1325 | + | require.Len(t, snapshot.Daemons[0].Sessions, ports.BrokerMaxSessionsPerHost) | |
| 1213 | 1326 | } | |
| 1214 | 1327 | ||
| 1215 | 1328 | // TestRegistryRejectsNonDurableObservation proves the registry and the durable | |
| @@ -1222,15 +1335,16 @@ func TestRegistryRejectsNonDurableObservation(t *testing.T) { | |||
| 1222 | 1335 | probe := newTestProbe(1) | |
| 1223 | 1336 | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 1224 | 1337 | reg := registration(t, "example.test", 1) | |
| 1225 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1338 | + | require.NoError(t, registry.setHosts(hostRecords(reg))) | |
| 1226 | 1339 | startRegistry(t, registry) | |
| 1227 | 1340 | ||
| 1228 | 1341 | call := receiveCall(t, probe) | |
| 1229 | - | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | |
| 1230 | - | Availability: domain.RemoteAvailabilityReachable, | |
| 1231 | - | Sessions: []catalogue.RemoteCatalogSession{hostSession("work")}, | |
| 1342 | + | call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{ | |
| 1343 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 1344 | + | InventoryKnown: true, | |
| 1345 | + | Sessions: []catalogue.RemoteCatalogSession{hostSession("work")}, | |
| 1232 | 1346 | }} | |
| 1233 | - | waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 1347 | + | waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 1234 | 1348 | return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable | |
| 1235 | 1349 | }) | |
| 1236 | 1350 | ||
| @@ -1238,11 +1352,12 @@ func TestRegistryRejectsNonDurableObservation(t *testing.T) { | |||
| 1238 | 1352 | // a zero lifecycle identity and an absent tab list) is a malformed response. | |
| 1239 | 1353 | clock.Advance(defaultFreshFor) | |
| 1240 | 1354 | call = receiveCall(t, probe) | |
| 1241 | - | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | |
| 1242 | - | Availability: domain.RemoteAvailabilityReachable, | |
| 1243 | - | Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: catalogue.RemoteCatalogSessionUp}}, | |
| 1355 | + | call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{ | |
| 1356 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 1357 | + | InventoryKnown: true, | |
| 1358 | + | Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: catalogue.RemoteCatalogSessionUp}}, | |
| 1244 | 1359 | }} | |
| 1245 | - | host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 1360 | + | host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 1246 | 1361 | return !host.Checking && host.ConsecutiveFailures == 1 | |
| 1247 | 1362 | }) | |
| 1248 | 1363 | require.Equal(t, domain.RemoteAvailabilityInvalidResponse, host.Availability) | |
| @@ -1252,7 +1367,7 @@ func TestRegistryRejectsNonDurableObservation(t *testing.T) { | |||
| 1252 | 1367 | require.Equal(t, start, host.LastSuccess) | |
| 1253 | 1368 | snapshot := registry.Snapshot() | |
| 1254 | 1369 | require.NoError(t, snapshot.Validate()) | |
| 1255 | - | require.NoError(t, ports.ValidateDurableHostProjection(snapshot.Hosts[0])) | |
| 1370 | + | require.NoError(t, ports.ValidateDurableHostProjection(snapshot.Daemons[0])) | |
| 1256 | 1371 | } | |
| 1257 | 1372 | ||
| 1258 | 1373 | // TestRegistryRevisionOverflowFailsClosed pins the revision series: zero is not | |
| @@ -1266,7 +1381,7 @@ func TestRegistryRevisionOverflowFailsClosed(t *testing.T) { | |||
| 1266 | 1381 | registry, err := NewRegistry(1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0)), logger) | |
| 1267 | 1382 | require.NoError(t, err) | |
| 1268 | 1383 | first := registration(t, "example.test", 1) | |
| 1269 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{first})) | |
| 1384 | + | require.NoError(t, registry.setHosts(hostRecords(first))) | |
| 1270 | 1385 | before := registry.Snapshot() | |
| 1271 | 1386 | require.NoError(t, before.Validate()) | |
| 1272 | 1387 | require.Equal(t, ports.BrokerRevision(2), before.Revision) | |
| @@ -1277,7 +1392,7 @@ func TestRegistryRevisionOverflowFailsClosed(t *testing.T) { | |||
| 1277 | 1392 | ||
| 1278 | 1393 | // Both mutation paths refuse the change before touching the store or the | |
| 1279 | 1394 | // projection, so neither durable authority nor the publication series moves. | |
| 1280 | - | require.ErrorIs(t, registry.setHosts([]domain.RemoteRegistration{registration(t, "other.test", 2)}), ports.ErrBrokerRevisionExhausted) | |
| 1395 | + | require.ErrorIs(t, registry.setHosts(hostRecords(registration(t, "other.test", 2))), ports.ErrBrokerRevisionExhausted) | |
| 1281 | 1396 | require.ErrorIs(t, registry.ReplaceHosts([]ports.BrokerHostRecord{{Registration: first, Pinned: true, Policy: poolPolicy()}}), ports.ErrBrokerRevisionExhausted) | |
| 1282 | 1397 | require.Equal(t, authority, registry.authority) | |
| 1283 | 1398 | ||
| @@ -1292,7 +1407,7 @@ func TestRegistryRevisionOverflowFailsClosed(t *testing.T) { | |||
| 1292 | 1407 | require.NoError(t, after.Validate()) | |
| 1293 | 1408 | require.Greater(t, after.Revision, ports.BrokerRevision(0)) | |
| 1294 | 1409 | require.Equal(t, ^ports.BrokerRevision(0), registry.revision) | |
| 1295 | - | require.Equal(t, first, after.Hosts[0].Registration) | |
| 1410 | + | require.Equal(t, first, after.Daemons[0].Registration) | |
| 1296 | 1411 | // The refusal is observable: an exhausted series logs instead of failing | |
| 1297 | 1412 | // silently behind a frozen snapshot. | |
| 1298 | 1413 | require.Contains(t, logs.String(), "revision series exhausted") | |
| @@ -1304,16 +1419,17 @@ func TestRegistryPreservesLastSuccessAndOwnsFailureEpisode(t *testing.T) { | |||
| 1304 | 1419 | probe := newTestProbe(1) | |
| 1305 | 1420 | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 1306 | 1421 | reg := registration(t, "example.test", 1) | |
| 1307 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1422 | + | require.NoError(t, registry.setHosts(hostRecords(reg))) | |
| 1308 | 1423 | startRegistry(t, registry) | |
| 1309 | 1424 | ||
| 1310 | 1425 | // A successful observation owns the success time and opens no episode. | |
| 1311 | 1426 | call := receiveCall(t, probe) | |
| 1312 | - | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | |
| 1313 | - | Availability: domain.RemoteAvailabilityReachable, | |
| 1314 | - | Sessions: []catalogue.RemoteCatalogSession{hostSession("work")}, | |
| 1427 | + | call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{ | |
| 1428 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 1429 | + | InventoryKnown: true, | |
| 1430 | + | Sessions: []catalogue.RemoteCatalogSession{hostSession("work")}, | |
| 1315 | 1431 | }} | |
| 1316 | - | host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 1432 | + | host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 1317 | 1433 | return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable | |
| 1318 | 1434 | }) | |
| 1319 | 1435 | require.Equal(t, start, host.LastSuccess) | |
| @@ -1322,11 +1438,12 @@ func TestRegistryPreservesLastSuccessAndOwnsFailureEpisode(t *testing.T) { | |||
| 1322 | 1438 | // A failure preserves the last success and opens exactly one episode. | |
| 1323 | 1439 | clock.Advance(defaultFreshFor) | |
| 1324 | 1440 | call = receiveCall(t, probe) | |
| 1325 | - | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | |
| 1326 | - | Availability: domain.RemoteAvailabilityIncompatible, | |
| 1327 | - | Sessions: []catalogue.RemoteCatalogSession{{Name: "stale", State: catalogue.RemoteCatalogSessionUp}}, | |
| 1441 | + | call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{ | |
| 1442 | + | Availability: domain.RemoteAvailabilityIncompatible, | |
| 1443 | + | InventoryKnown: true, | |
| 1444 | + | Sessions: []catalogue.RemoteCatalogSession{{Name: "stale", State: catalogue.RemoteCatalogSessionUp}}, | |
| 1328 | 1445 | }} | |
| 1329 | - | host = waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 1446 | + | host = waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 1330 | 1447 | return !host.Checking && host.ConsecutiveFailures == 1 | |
| 1331 | 1448 | }) | |
| 1332 | 1449 | require.Equal(t, start, host.LastSuccess) | |
| @@ -1340,7 +1457,7 @@ func TestRegistryPreservesLastSuccessAndOwnsFailureEpisode(t *testing.T) { | |||
| 1340 | 1457 | clock.Advance(defaultRetryBase) | |
| 1341 | 1458 | call = receiveCall(t, probe) | |
| 1342 | 1459 | call.result <- probeAnswer{err: errors.New("offline")} | |
| 1343 | - | host = waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 1460 | + | host = waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 1344 | 1461 | return !host.Checking && host.ConsecutiveFailures == 2 | |
| 1345 | 1462 | }) | |
| 1346 | 1463 | require.Equal(t, start, host.LastSuccess) | |
| @@ -1350,8 +1467,8 @@ func TestRegistryPreservesLastSuccessAndOwnsFailureEpisode(t *testing.T) { | |||
| 1350 | 1467 | clock.Advance(2 * defaultRetryBase) | |
| 1351 | 1468 | recovered := clock.Now() | |
| 1352 | 1469 | call = receiveCall(t, probe) | |
| 1353 | - | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}} | |
| 1354 | - | host = waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 1470 | + | call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}} | |
| 1471 | + | host = waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 1355 | 1472 | return !host.Checking && host.ConsecutiveFailures == 0 | |
| 1356 | 1473 | }) | |
| 1357 | 1474 | require.Equal(t, recovered, host.LastSuccess) | |
| @@ -1366,7 +1483,7 @@ func TestRegistryBackoffIsExponentialAndCapped(t *testing.T) { | |||
| 1366 | 1483 | probe := newTestProbe(1) | |
| 1367 | 1484 | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 1368 | 1485 | reg := registration(t, "example.test", 1) | |
| 1369 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1486 | + | require.NoError(t, registry.setHosts(hostRecords(reg))) | |
| 1370 | 1487 | startRegistry(t, registry) | |
| 1371 | 1488 | ||
| 1372 | 1489 | probeErr := errors.New("offline") | |
| @@ -1385,7 +1502,7 @@ func TestRegistryBackoffIsExponentialAndCapped(t *testing.T) { | |||
| 1385 | 1502 | call := receiveCall(t, probe) | |
| 1386 | 1503 | at := clock.Now() | |
| 1387 | 1504 | call.result <- probeAnswer{err: probeErr} | |
| 1388 | - | host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 1505 | + | host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 1389 | 1506 | return !host.Checking && host.ConsecutiveFailures == uint(index+1) | |
| 1390 | 1507 | }) | |
| 1391 | 1508 | require.Equal(t, domain.RemoteAvailabilityUnreachable, host.Availability) | |
| @@ -1410,12 +1527,12 @@ func TestRegistryJitterAppliesToRetryAndHealthyRefresh(t *testing.T) { | |||
| 1410 | 1527 | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 1411 | 1528 | registry.jitter = func(base time.Duration, _ string, _ uint64) time.Duration { return base / 2 } | |
| 1412 | 1529 | reg := registration(t, "example.test", 1) | |
| 1413 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1530 | + | require.NoError(t, registry.setHosts(hostRecords(reg))) | |
| 1414 | 1531 | startRegistry(t, registry) | |
| 1415 | 1532 | ||
| 1416 | 1533 | call := receiveCall(t, probe) | |
| 1417 | 1534 | call.result <- probeAnswer{err: errors.New("offline")} | |
| 1418 | - | host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 1535 | + | host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 1419 | 1536 | return !host.Checking && host.Availability == domain.RemoteAvailabilityUnreachable | |
| 1420 | 1537 | }) | |
| 1421 | 1538 | require.Equal(t, start.Add(defaultRetryBase/2), host.NextDue) | |
| @@ -1423,8 +1540,8 @@ func TestRegistryJitterAppliesToRetryAndHealthyRefresh(t *testing.T) { | |||
| 1423 | 1540 | clock.Advance(defaultRetryBase / 2) | |
| 1424 | 1541 | next := receiveCall(t, probe) | |
| 1425 | 1542 | successAt := clock.Now() | |
| 1426 | - | next.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}} | |
| 1427 | - | host = waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 1543 | + | next.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}} | |
| 1544 | + | host = waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 1428 | 1545 | return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable | |
| 1429 | 1546 | }) | |
| 1430 | 1547 | // The healthy refresh is jittered too: a fleet of hosts never converges | |
| @@ -1457,9 +1574,10 @@ func TestRegistryJitterIsDeterministicAndBounded(t *testing.T) { | |||
| 1457 | 1574 | ||
| 1458 | 1575 | func TestRegistryFencesStaleCompletionAcrossRegistrationChanges(t *testing.T) { | |
| 1459 | 1576 | replacementAnswer := func(name string) probeAnswer { | |
| 1460 | - | return probeAnswer{snapshot: ports.RemoteHostSnapshot{ | |
| 1461 | - | Availability: domain.RemoteAvailabilityReachable, | |
| 1462 | - | Sessions: []catalogue.RemoteCatalogSession{hostSession(name)}, | |
| 1577 | + | return probeAnswer{snapshot: ports.BrokerDaemonObservation{ | |
| 1578 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 1579 | + | InventoryKnown: true, | |
| 1580 | + | Sessions: []catalogue.RemoteCatalogSession{hostSession(name)}, | |
| 1463 | 1581 | }} | |
| 1464 | 1582 | } | |
| 1465 | 1583 | assertFenced := func(t *testing.T, current, replacement domain.RemoteRegistration) { | |
| @@ -1467,18 +1585,18 @@ func TestRegistryFencesStaleCompletionAcrossRegistrationChanges(t *testing.T) { | |||
| 1467 | 1585 | clock := newManualClock(time.Unix(100, 0)) | |
| 1468 | 1586 | probe := newTestProbe(2) | |
| 1469 | 1587 | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 1470 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{current})) | |
| 1588 | + | require.NoError(t, registry.setHosts(hostRecords(current))) | |
| 1471 | 1589 | startRegistry(t, registry) | |
| 1472 | 1590 | currentCall := receiveCall(t, probe) | |
| 1473 | 1591 | require.True(t, currentCall.registration.Equal(current)) | |
| 1474 | 1592 | ||
| 1475 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{replacement})) | |
| 1593 | + | require.NoError(t, registry.setHosts(hostRecords(replacement))) | |
| 1476 | 1594 | replacementCall := receiveCall(t, probe) | |
| 1477 | 1595 | require.True(t, replacementCall.registration.Equal(replacement)) | |
| 1478 | 1596 | ||
| 1479 | 1597 | // The replacement completes and becomes authoritative... | |
| 1480 | 1598 | replacementCall.result <- replacementAnswer("replacement") | |
| 1481 | - | applied := waitHost(t, registry, replacement.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 1599 | + | applied := waitHost(t, registry, replacement.Endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 1482 | 1600 | return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable && len(host.Sessions) == 1 | |
| 1483 | 1601 | }) | |
| 1484 | 1602 | require.True(t, applied.Registration.Equal(replacement)) | |
| @@ -1495,7 +1613,7 @@ func TestRegistryFencesStaleCompletionAcrossRegistrationChanges(t *testing.T) { | |||
| 1495 | 1613 | next := receiveCall(t, probe) | |
| 1496 | 1614 | require.True(t, next.registration.Equal(replacement)) | |
| 1497 | 1615 | next.result <- replacementAnswer("next") | |
| 1498 | - | waitHost(t, registry, replacement.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 1616 | + | waitHost(t, registry, replacement.Endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 1499 | 1617 | return !host.Checking && len(host.Sessions) == 1 && host.Sessions[0].Name == "next" | |
| 1500 | 1618 | }) | |
| 1501 | 1619 | } | |
| @@ -1516,7 +1634,7 @@ func TestRegistryFencesRetiredAttemptsAcrossRemovalAndIdenticalReAdd(t *testing. | |||
| 1516 | 1634 | probe := newTestProbe(2) | |
| 1517 | 1635 | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 1518 | 1636 | reg := registration(t, "example.test", 1) | |
| 1519 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1637 | + | require.NoError(t, registry.setHosts(hostRecords(reg))) | |
| 1520 | 1638 | ||
| 1521 | 1639 | ctx, cancel := context.WithCancel(context.Background()) | |
| 1522 | 1640 | defer cancel() | |
| @@ -1532,20 +1650,20 @@ func TestRegistryFencesRetiredAttemptsAcrossRemovalAndIdenticalReAdd(t *testing. | |||
| 1532 | 1650 | // Removal retires a bounded tombstone and drops the in-flight token. | |
| 1533 | 1651 | require.NoError(t, registry.setHosts(nil)) | |
| 1534 | 1652 | removed := registry.Snapshot() | |
| 1535 | - | require.Empty(t, removed.Hosts) | |
| 1653 | + | require.Empty(t, removed.Daemons) | |
| 1536 | 1654 | require.Len(t, removed.Removed, 1) | |
| 1537 | 1655 | require.Equal(t, removed.Revision, removed.Removed[0].RetiredRevision) | |
| 1538 | 1656 | require.True(t, removed.Removed[0].Fences(reg)) | |
| 1539 | 1657 | require.NoError(t, removed.Validate()) | |
| 1540 | 1658 | ||
| 1541 | 1659 | // The retired attempt can never revive a removed endpoint. | |
| 1542 | - | registry.apply(probeResult{endpoint: reg.Endpoint, registration: reg, attempt: retired, snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}, at: clock.Now()}) | |
| 1543 | - | require.Empty(t, registry.Snapshot().Hosts) | |
| 1660 | + | registry.apply(probeResult{endpoint: reg.Endpoint, registration: reg, attempt: retired, snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}, at: clock.Now()}) | |
| 1661 | + | require.Empty(t, registry.Snapshot().Daemons) | |
| 1544 | 1662 | require.Len(t, registry.Snapshot().Removed, 1) | |
| 1545 | 1663 | ||
| 1546 | 1664 | // Re-adding the identical registration supersedes the tombstone, so only | |
| 1547 | 1665 | // the attempt token can fence the retired completion. | |
| 1548 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1666 | + | require.NoError(t, registry.setHosts(hostRecords(reg))) | |
| 1549 | 1667 | require.Empty(t, registry.Snapshot().Removed) | |
| 1550 | 1668 | dispatch() | |
| 1551 | 1669 | freshCall := receiveCall(t, probe) | |
| @@ -1553,13 +1671,13 @@ func TestRegistryFencesRetiredAttemptsAcrossRemovalAndIdenticalReAdd(t *testing. | |||
| 1553 | 1671 | fresh := attemptFor(registry, reg.Endpoint) | |
| 1554 | 1672 | require.Greater(t, fresh, retired) | |
| 1555 | 1673 | ||
| 1556 | - | registry.apply(probeResult{endpoint: reg.Endpoint, registration: reg, attempt: retired, snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}, at: clock.Now()}) | |
| 1674 | + | registry.apply(probeResult{endpoint: reg.Endpoint, registration: reg, attempt: retired, snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}, at: clock.Now()}) | |
| 1557 | 1675 | stale, ok := registry.Snapshot().Find(reg.Endpoint) | |
| 1558 | 1676 | require.True(t, ok) | |
| 1559 | 1677 | require.True(t, stale.Checking) | |
| 1560 | 1678 | require.Equal(t, domain.RemoteAvailabilityUnknown, stale.Availability) | |
| 1561 | 1679 | ||
| 1562 | - | registry.apply(probeResult{endpoint: reg.Endpoint, registration: reg, attempt: fresh, snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}, at: clock.Now()}) | |
| 1680 | + | registry.apply(probeResult{endpoint: reg.Endpoint, registration: reg, attempt: fresh, snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}, at: clock.Now()}) | |
| 1563 | 1681 | applied, ok := registry.Snapshot().Find(reg.Endpoint) | |
| 1564 | 1682 | require.True(t, ok) | |
| 1565 | 1683 | require.False(t, applied.Checking) | |
| @@ -1571,9 +1689,9 @@ func TestRegistryRetiresInflightAttemptsOnReplacementAndRemoval(t *testing.T) { | |||
| 1571 | 1689 | replacement := registration(t, "example.test", 2) | |
| 1572 | 1690 | tests := []struct { | |
| 1573 | 1691 | name string | |
| 1574 | - | next []domain.RemoteRegistration | |
| 1692 | + | next []ports.BrokerHostRecord | |
| 1575 | 1693 | }{ | |
| 1576 | - | {name: "replacement", next: []domain.RemoteRegistration{replacement}}, | |
| 1694 | + | {name: "replacement", next: []ports.BrokerHostRecord{hostRecord(replacement)}}, | |
| 1577 | 1695 | {name: "removal", next: nil}, | |
| 1578 | 1696 | } | |
| 1579 | 1697 | for _, tt := range tests { | |
| @@ -1581,7 +1699,7 @@ func TestRegistryRetiresInflightAttemptsOnReplacementAndRemoval(t *testing.T) { | |||
| 1581 | 1699 | clock := newManualClock(time.Unix(100, 0)) | |
| 1582 | 1700 | probe := newTestProbe(2) | |
| 1583 | 1701 | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 1584 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{current})) | |
| 1702 | + | require.NoError(t, registry.setHosts(hostRecords(current))) | |
| 1585 | 1703 | startRegistry(t, registry) | |
| 1586 | 1704 | ||
| 1587 | 1705 | call := receiveCall(t, probe) | |
| @@ -1609,7 +1727,7 @@ func TestRegistryChurnKeepsOneActiveProbePerEndpoint(t *testing.T) { | |||
| 1609 | 1727 | ||
| 1610 | 1728 | first := registration(t, "example.test", 1) | |
| 1611 | 1729 | second := registration(t, "example.test", 2) | |
| 1612 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{first})) | |
| 1730 | + | require.NoError(t, registry.setHosts(hostRecords(first))) | |
| 1613 | 1731 | ||
| 1614 | 1732 | ctx, cancel := context.WithCancel(context.Background()) | |
| 1615 | 1733 | defer cancel() | |
| @@ -1635,7 +1753,7 @@ func TestRegistryChurnKeepsOneActiveProbePerEndpoint(t *testing.T) { | |||
| 1635 | 1753 | if round%2 == 0 { | |
| 1636 | 1754 | next = second | |
| 1637 | 1755 | } | |
| 1638 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{next})) | |
| 1756 | + | require.NoError(t, registry.setHosts(hostRecords(next))) | |
| 1639 | 1757 | select { | |
| 1640 | 1758 | case <-probe.startedSig: | |
| 1641 | 1759 | case <-time.After(time.Second): | |
| @@ -1657,7 +1775,7 @@ func TestRegistryChurnKeepsOneActiveProbePerEndpoint(t *testing.T) { | |||
| 1657 | 1775 | if (worker+round)%2 == 0 { | |
| 1658 | 1776 | next = second | |
| 1659 | 1777 | } | |
| 1660 | - | if err := registry.setHosts([]domain.RemoteRegistration{next}); err != nil { | |
| 1778 | + | if err := registry.setHosts(hostRecords(next)); err != nil { | |
| 1661 | 1779 | failures[worker] = err | |
| 1662 | 1780 | return | |
| 1663 | 1781 | } | |
| @@ -1700,9 +1818,9 @@ func TestRegistryPublishesBoundedTombstones(t *testing.T) { | |||
| 1700 | 1818 | // Two full membership rounds with distinct endpoints: without pruning the | |
| 1701 | 1819 | // retired set would double. | |
| 1702 | 1820 | for round := 0; round < 2; round++ { | |
| 1703 | - | registrations := make([]domain.RemoteRegistration, 0, ports.BrokerMaxHosts) | |
| 1821 | + | registrations := make([]ports.BrokerHostRecord, 0, ports.BrokerMaxHosts) | |
| 1704 | 1822 | for index := 0; index < ports.BrokerMaxHosts; index++ { | |
| 1705 | - | registrations = append(registrations, registration(t, fmt.Sprintf("round-%d-host-%d.test", round, index), byte(round*ports.BrokerMaxHosts+index+1))) | |
| 1823 | + | registrations = append(registrations, hostRecord(registration(t, fmt.Sprintf("round-%d-host-%d.test", round, index), byte(round*ports.BrokerMaxHosts+index+1)))) | |
| 1706 | 1824 | } | |
| 1707 | 1825 | require.NoError(t, registry.setHosts(registrations)) | |
| 1708 | 1826 | require.NoError(t, registry.setHosts(nil)) | |
| @@ -1730,7 +1848,7 @@ func TestRegistryPersistenceRunsOffLockAndCoalescesWrites(t *testing.T) { | |||
| 1730 | 1848 | defer registry.store.close() | |
| 1731 | 1849 | ||
| 1732 | 1850 | reg := registration(t, "example.test", 1) | |
| 1733 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1851 | + | require.NoError(t, registry.setHosts(hostRecords(reg))) | |
| 1734 | 1852 | // The writer is now blocked inside the first durable write. | |
| 1735 | 1853 | first := <-store.entered | |
| 1736 | 1854 | require.Equal(t, ports.BrokerRevision(2), first) | |
| @@ -1743,7 +1861,7 @@ func TestRegistryPersistenceRunsOffLockAndCoalescesWrites(t *testing.T) { | |||
| 1743 | 1861 | defer close(published) | |
| 1744 | 1862 | for generation := domain.RemoteGeneration(2); generation <= 6; generation++ { | |
| 1745 | 1863 | reg.Generation = generation | |
| 1746 | - | if err := registry.setHosts([]domain.RemoteRegistration{reg}); err != nil { | |
| 1864 | + | if err := registry.setHosts(hostRecords(reg)); err != nil { | |
| 1747 | 1865 | setErr = err | |
| 1748 | 1866 | return | |
| 1749 | 1867 | } | |
| @@ -1778,10 +1896,10 @@ func TestRegistryReplaceHostsBlockedCASKeepsSnapshotLockFree(t *testing.T) { | |||
| 1778 | 1896 | require.NoError(t, err) | |
| 1779 | 1897 | ||
| 1780 | 1898 | reg := registration(t, "example.test", 1) | |
| 1781 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1899 | + | require.NoError(t, registry.setHosts(hostRecords(reg))) | |
| 1782 | 1900 | startRegistry(t, registry) | |
| 1783 | 1901 | call := receiveCall(t, probe) | |
| 1784 | - | require.True(t, waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 1902 | + | require.True(t, waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 1785 | 1903 | return host.Checking | |
| 1786 | 1904 | }).Checking) | |
| 1787 | 1905 | ||
| @@ -1801,8 +1919,8 @@ func TestRegistryReplaceHostsBlockedCASKeepsSnapshotLockFree(t *testing.T) { | |||
| 1801 | 1919 | select { | |
| 1802 | 1920 | case snapshot := <-observed: | |
| 1803 | 1921 | require.NoError(t, snapshot.Validate()) | |
| 1804 | - | require.Len(t, snapshot.Hosts, 1) | |
| 1805 | - | require.Equal(t, reg, snapshot.Hosts[0].Registration) | |
| 1922 | + | require.Len(t, snapshot.Daemons, 1) | |
| 1923 | + | require.Equal(t, reg, snapshot.Daemons[0].Registration) | |
| 1806 | 1924 | case <-time.After(time.Second): | |
| 1807 | 1925 | t.Fatal("Snapshot blocked behind the durable membership CAS") | |
| 1808 | 1926 | } | |
| @@ -1811,7 +1929,7 @@ func TestRegistryReplaceHostsBlockedCASKeepsSnapshotLockFree(t *testing.T) { | |||
| 1811 | 1929 | // parked: applying the result needs the registry lock, so the in-flight | |
| 1812 | 1930 | // projection must stay exactly as published until membership commits. | |
| 1813 | 1931 | registry.hint() | |
| 1814 | - | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}} | |
| 1932 | + | call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}} | |
| 1815 | 1933 | pending, ok := registry.Snapshot().Find(reg.Endpoint) | |
| 1816 | 1934 | require.True(t, ok) | |
| 1817 | 1935 | require.True(t, pending.Checking) | |
| @@ -1834,7 +1952,7 @@ window: | |||
| 1834 | 1952 | // applies the observation and resumes scheduling the next one. | |
| 1835 | 1953 | store.releaseCAS() | |
| 1836 | 1954 | require.NoError(t, <-replaced) | |
| 1837 | - | host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 1955 | + | host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 1838 | 1956 | return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable | |
| 1839 | 1957 | }) | |
| 1840 | 1958 | require.Equal(t, clock.Now(), host.LastSuccess) | |
| @@ -1853,19 +1971,19 @@ func TestRegistryStoreErrorsAreObservableAndDoNotStallPublication(t *testing.T) | |||
| 1853 | 1971 | defer registry.store.close() | |
| 1854 | 1972 | ||
| 1855 | 1973 | reg := registration(t, "example.test", 1) | |
| 1856 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1974 | + | require.NoError(t, registry.setHosts(hostRecords(reg))) | |
| 1857 | 1975 | waitForLog(t, logs, "durable snapshot write failed") | |
| 1858 | 1976 | require.Contains(t, logs.String(), storeErr.Error()) | |
| 1859 | 1977 | ||
| 1860 | 1978 | // A failed write is not fatal and never blocks the next publication. | |
| 1861 | 1979 | store.setStoreErr(nil) | |
| 1862 | 1980 | reg.Generation++ | |
| 1863 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1981 | + | require.NoError(t, registry.setHosts(hostRecords(reg))) | |
| 1864 | 1982 | stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool { | |
| 1865 | 1983 | return snapshot.Revision == registry.Snapshot().Revision | |
| 1866 | 1984 | }) | |
| 1867 | - | require.Len(t, stored.Hosts, 1) | |
| 1868 | - | require.True(t, stored.Hosts[0].Registration.Equal(reg)) | |
| 1985 | + | require.Len(t, stored.Daemons, 1) | |
| 1986 | + | require.True(t, stored.Daemons[0].Registration.Equal(reg)) | |
| 1869 | 1987 | } | |
| 1870 | 1988 | ||
| 1871 | 1989 | func TestRegistryPersistedCopyClearsChecking(t *testing.T) { | |
| @@ -1874,26 +1992,26 @@ func TestRegistryPersistedCopyClearsChecking(t *testing.T) { | |||
| 1874 | 1992 | store := &testStore{} | |
| 1875 | 1993 | registry := newTestRegistry(t, 3, store, probe, clock) | |
| 1876 | 1994 | reg := registration(t, "example.test", 1) | |
| 1877 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1995 | + | require.NoError(t, registry.setHosts(hostRecords(reg))) | |
| 1878 | 1996 | startRegistry(t, registry) | |
| 1879 | 1997 | ||
| 1880 | 1998 | // The observation is in flight: the published projection carries Checking, | |
| 1881 | 1999 | // the durable copy never does. | |
| 1882 | - | require.True(t, waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 2000 | + | require.True(t, waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 1883 | 2001 | return host.Checking | |
| 1884 | 2002 | }).Checking) | |
| 1885 | 2003 | stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool { | |
| 1886 | - | return len(snapshot.Hosts) == 1 | |
| 2004 | + | return len(snapshot.Daemons) == 1 | |
| 1887 | 2005 | }) | |
| 1888 | - | require.False(t, stored.Hosts[0].Checking) | |
| 2006 | + | require.False(t, stored.Daemons[0].Checking) | |
| 1889 | 2007 | ||
| 1890 | 2008 | // Completing the observation persists the settled projection. | |
| 1891 | 2009 | call := receiveCall(t, probe) | |
| 1892 | - | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}} | |
| 2010 | + | call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}} | |
| 1893 | 2011 | settled := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool { | |
| 1894 | - | return len(snapshot.Hosts) == 1 && snapshot.Hosts[0].Availability == domain.RemoteAvailabilityReachable | |
| 2012 | + | return len(snapshot.Daemons) == 1 && snapshot.Daemons[0].Availability == domain.RemoteAvailabilityReachable | |
| 1895 | 2013 | }) | |
| 1896 | - | require.False(t, settled.Hosts[0].Checking) | |
| 2014 | + | require.False(t, settled.Daemons[0].Checking) | |
| 1897 | 2015 | } | |
| 1898 | 2016 | ||
| 1899 | 2017 | // TestRegistryNeverPersistsRetirementTombstones proves retirement tombstones | |
| @@ -1907,16 +2025,16 @@ func TestRegistryNeverPersistsRetirementTombstones(t *testing.T) { | |||
| 1907 | 2025 | defer registry.store.close() | |
| 1908 | 2026 | ||
| 1909 | 2027 | reg := registration(t, "retired.test", 1) | |
| 1910 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 2028 | + | require.NoError(t, registry.setHosts(hostRecords(reg))) | |
| 1911 | 2029 | require.NoError(t, registry.setHosts(nil)) | |
| 1912 | 2030 | ||
| 1913 | 2031 | // Removal publishes a bounded tombstone and drops the live host in memory. | |
| 1914 | - | require.Empty(t, registry.Snapshot().Hosts) | |
| 2032 | + | require.Empty(t, registry.Snapshot().Daemons) | |
| 1915 | 2033 | require.Len(t, registry.Snapshot().Removed, 1) | |
| 1916 | 2034 | ||
| 1917 | 2035 | // The durable copy drops the tombstone before the store ever sees it. | |
| 1918 | 2036 | stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool { | |
| 1919 | - | return len(snapshot.Hosts) == 0 && snapshot.Revision == registry.Snapshot().Revision | |
| 2037 | + | return len(snapshot.Daemons) == 0 && snapshot.Revision == registry.Snapshot().Revision | |
| 1920 | 2038 | }) | |
| 1921 | 2039 | require.Empty(t, stored.Removed) | |
| 1922 | 2040 | ||
| @@ -1924,7 +2042,7 @@ func TestRegistryNeverPersistsRetirementTombstones(t *testing.T) { | |||
| 1924 | 2042 | // stale tombstone. | |
| 1925 | 2043 | reopened, err := NewRegistry(2, &testStore{loaded: stored}, newTestProbe(1), clock, nil) | |
| 1926 | 2044 | require.NoError(t, err) | |
| 1927 | - | require.Empty(t, reopened.Snapshot().Hosts) | |
| 2045 | + | require.Empty(t, reopened.Snapshot().Daemons) | |
| 1928 | 2046 | require.Empty(t, reopened.Snapshot().Removed) | |
| 1929 | 2047 | } | |
| 1930 | 2048 | ||
| @@ -1937,7 +2055,7 @@ func TestRegistryBoundsSlowSubscribersAndStopsOnCancellation(t *testing.T) { | |||
| 1937 | 2055 | reg := registration(t, "example.test", 1) | |
| 1938 | 2056 | for generation := domain.RemoteGeneration(1); generation <= 10; generation++ { | |
| 1939 | 2057 | reg.Generation = generation | |
| 1940 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 2058 | + | require.NoError(t, registry.setHosts(hostRecords(reg))) | |
| 1941 | 2059 | } | |
| 1942 | 2060 | // A subscriber that never drains is bounded to one pending wake and | |
| 1943 | 2061 | // never blocks publication. | |
| @@ -1949,7 +2067,7 @@ func TestRegistryBoundsSlowSubscribersAndStopsOnCancellation(t *testing.T) { | |||
| 1949 | 2067 | sub.Close() | |
| 1950 | 2068 | sub.Close() | |
| 1951 | 2069 | reg.Generation++ | |
| 1952 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 2070 | + | require.NoError(t, registry.setHosts(hostRecords(reg))) | |
| 1953 | 2071 | select { | |
| 1954 | 2072 | case <-sub.Changed(): | |
| 1955 | 2073 | t.Fatal("closed subscription received a notification") | |
| @@ -1972,7 +2090,7 @@ func TestRegistryBoundsSlowSubscribersAndStopsOnCancellation(t *testing.T) { | |||
| 1972 | 2090 | t.Fatal("in-flight probe did not observe cancellation") | |
| 1973 | 2091 | } | |
| 1974 | 2092 | // Cleanup settles the projection: no host stays in flight. | |
| 1975 | - | host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 2093 | + | host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 1976 | 2094 | return !host.Checking | |
| 1977 | 2095 | }) | |
| 1978 | 2096 | require.Equal(t, domain.RemoteAvailabilityUnknown, host.Availability) | |
| @@ -1986,14 +2104,14 @@ func TestRegistryRunIsSingleShotAndCleansUpInFlightState(t *testing.T) { | |||
| 1986 | 2104 | registry, err := NewRegistry(1, store, probe, clock, logger) | |
| 1987 | 2105 | require.NoError(t, err) | |
| 1988 | 2106 | reg := registration(t, "example.test", 1) | |
| 1989 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 2107 | + | require.NoError(t, registry.setHosts(hostRecords(reg))) | |
| 1990 | 2108 | ||
| 1991 | 2109 | ctx, cancel := context.WithCancel(context.Background()) | |
| 1992 | 2110 | done := make(chan struct{}) | |
| 1993 | 2111 | go func() { defer close(done); registry.Run(ctx) }() | |
| 1994 | 2112 | ||
| 1995 | 2113 | call := receiveCall(t, probe) | |
| 1996 | - | require.True(t, waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 2114 | + | require.True(t, waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 1997 | 2115 | return host.Checking | |
| 1998 | 2116 | }).Checking) | |
| 1999 | 2117 | ||
| @@ -2014,22 +2132,22 @@ func TestRegistryRunIsSingleShotAndCleansUpInFlightState(t *testing.T) { | |||
| 2014 | 2132 | t.Fatal("registry did not stop after cancellation") | |
| 2015 | 2133 | } | |
| 2016 | 2134 | // Cleanup publishes the settled projection: nothing stays in flight. | |
| 2017 | - | require.False(t, waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 2135 | + | require.False(t, waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 2018 | 2136 | return !host.Checking | |
| 2019 | 2137 | }).Checking) | |
| 2020 | 2138 | ||
| 2021 | 2139 | // The retired completion can never be applied once the run is over. | |
| 2022 | - | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}} | |
| 2140 | + | call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}} | |
| 2023 | 2141 | settled := registry.Snapshot() | |
| 2024 | - | require.Equal(t, domain.RemoteAvailabilityUnknown, settled.Hosts[0].Availability) | |
| 2025 | - | require.Zero(t, settled.Hosts[0].ConsecutiveFailures) | |
| 2142 | + | require.Equal(t, domain.RemoteAvailabilityUnknown, settled.Daemons[0].Availability) | |
| 2143 | + | require.Zero(t, settled.Daemons[0].ConsecutiveFailures) | |
| 2026 | 2144 | ||
| 2027 | 2145 | // Persistence is flushed before shutdown and no write outlives it. | |
| 2028 | 2146 | stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool { | |
| 2029 | 2147 | return snapshot.Revision >= settled.Revision | |
| 2030 | 2148 | }) | |
| 2031 | 2149 | require.Equal(t, settled.Revision, stored.Revision) | |
| 2032 | - | require.False(t, stored.Hosts[0].Checking) | |
| 2150 | + | require.False(t, stored.Daemons[0].Checking) | |
| 2033 | 2151 | writes := store.writeCount() | |
| 2034 | 2152 | require.NoError(t, registry.setHosts(nil)) | |
| 2035 | 2153 | require.Equal(t, writes, store.writeCount()) | |
| @@ -2064,18 +2182,186 @@ func TestRegistryRevisionsIncreaseMonotonicallyWithinEpoch(t *testing.T) { | |||
| 2064 | 2182 | ||
| 2065 | 2183 | first := registration(t, "a.test", 1) | |
| 2066 | 2184 | second := registration(t, "b.test", 2) | |
| 2067 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{first})) | |
| 2185 | + | require.NoError(t, registry.setHosts(hostRecords(first))) | |
| 2068 | 2186 | observe() | |
| 2069 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{first, second})) | |
| 2187 | + | require.NoError(t, registry.setHosts(hostRecords(first, second))) | |
| 2070 | 2188 | observe() | |
| 2071 | 2189 | ||
| 2072 | 2190 | startRegistry(t, registry) | |
| 2073 | 2191 | for range 2 { | |
| 2074 | 2192 | call := receiveCall(t, probe) | |
| 2075 | - | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}} | |
| 2076 | - | waitHost(t, registry, call.registration.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 2193 | + | call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}} | |
| 2194 | + | waitHost(t, registry, call.registration.Endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 2077 | 2195 | return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable | |
| 2078 | 2196 | }) | |
| 2079 | 2197 | observe() | |
| 2080 | 2198 | } | |
| 2081 | 2199 | } | |
| 2200 | + | ||
| 2201 | + | // TestRegistryProbeCannotOverrideConfiguredAuthority proves the ownership rule: | |
| 2202 | + | // a probe supplies observed state only. Configured authority (local flag, | |
| 2203 | + | // endpoint, registration, policy, display origin, rank) is stamped by the | |
| 2204 | + | // registry from its own host records and a hostile probe result can never | |
| 2205 | + | // override it. | |
| 2206 | + | func TestRegistryProbeCannotOverrideConfiguredAuthority(t *testing.T) { | |
| 2207 | + | clock := newManualClock(time.Unix(100, 0)) | |
| 2208 | + | probe := newTestProbe(1) | |
| 2209 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 2210 | + | reg := registration(t, "user@host:22", 1) | |
| 2211 | + | require.NoError(t, registry.setHosts(hostRecords(reg))) | |
| 2212 | + | startRegistry(t, registry) | |
| 2213 | + | ||
| 2214 | + | call := receiveCall(t, probe) | |
| 2215 | + | require.True(t, call.registration.Equal(reg)) | |
| 2216 | + | ||
| 2217 | + | // Every authority field is invented by the probe, including a local flag, | |
| 2218 | + | // a foreign endpoint and registration, a different policy, and display/rank | |
| 2219 | + | // hints. None of it may reach the published projection. | |
| 2220 | + | hostilePolicy := poolPolicy() | |
| 2221 | + | hostilePolicy.Trust = "attacker" | |
| 2222 | + | call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{ | |
| 2223 | + | Local: true, | |
| 2224 | + | Endpoint: "attacker@evil", | |
| 2225 | + | DisplayOrigin: "evil", | |
| 2226 | + | Rank: 42, | |
| 2227 | + | Registration: registration(t, "attacker@evil", 9), | |
| 2228 | + | Policy: hostilePolicy, | |
| 2229 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 2230 | + | }} | |
| 2231 | + | host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 2232 | + | return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable | |
| 2233 | + | }) | |
| 2234 | + | require.False(t, host.Local) | |
| 2235 | + | require.Equal(t, reg.Endpoint, host.Endpoint) | |
| 2236 | + | require.Equal(t, domain.RemoteDisplayOrigin(reg.Endpoint), host.DisplayOrigin) | |
| 2237 | + | require.Zero(t, host.Rank) | |
| 2238 | + | require.True(t, host.Registration.Equal(reg)) | |
| 2239 | + | require.Equal(t, poolPolicy(), host.Policy) | |
| 2240 | + | require.NotEqual(t, hostilePolicy, host.Policy) | |
| 2241 | + | require.Len(t, registry.Snapshot().Daemons, 1) | |
| 2242 | + | } | |
| 2243 | + | ||
| 2244 | + | // TestRegistryPublishesObservedDaemonIdentity proves observed identity, | |
| 2245 | + | // incarnation, protocol version, and capabilities flow from a probe result into | |
| 2246 | + | // the published snapshot; configured authority is stamped alongside them. | |
| 2247 | + | func TestRegistryPublishesObservedDaemonIdentity(t *testing.T) { | |
| 2248 | + | clock := newManualClock(time.Unix(100, 0)) | |
| 2249 | + | probe := newTestProbe(1) | |
| 2250 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 2251 | + | reg := registration(t, "user@host:22", 1) | |
| 2252 | + | require.NoError(t, registry.setHosts(hostRecords(reg))) | |
| 2253 | + | startRegistry(t, registry) | |
| 2254 | + | ||
| 2255 | + | call := receiveCall(t, probe) | |
| 2256 | + | call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{ | |
| 2257 | + | Identity: "authed-daemon", | |
| 2258 | + | Incarnation: ports.BrokerDaemonIncarnation{7, 11}, | |
| 2259 | + | ProtocolVersion: protocol.Version, | |
| 2260 | + | Capabilities: protocol.CapabilityResume | protocol.CapabilityUDP, | |
| 2261 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 2262 | + | }} | |
| 2263 | + | host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 2264 | + | return !host.Checking && host.Identity != "" | |
| 2265 | + | }) | |
| 2266 | + | require.Equal(t, ports.BrokerDaemonIdentity("authed-daemon"), host.Identity) | |
| 2267 | + | require.Equal(t, ports.BrokerDaemonIncarnation{7, 11}, host.Incarnation) | |
| 2268 | + | require.Equal(t, protocol.Version, host.ProtocolVersion) | |
| 2269 | + | require.Equal(t, protocol.CapabilityResume|protocol.CapabilityUDP, host.Capabilities) | |
| 2270 | + | require.Equal(t, poolPolicy(), host.Policy) | |
| 2271 | + | require.NoError(t, registry.Snapshot().Validate()) | |
| 2272 | + | } | |
| 2273 | + | ||
| 2274 | + | // TestRegistryRejectsObservedIdentityWithoutProtocolVersion pins the partial | |
| 2275 | + | // identity rule: an observation that reports identity and incarnation without a | |
| 2276 | + | // protocol version fails ports validation and is classified as an invalid | |
| 2277 | + | // response, so it never publishes a half-observed daemon. | |
| 2278 | + | func TestRegistryRejectsObservedIdentityWithoutProtocolVersion(t *testing.T) { | |
| 2279 | + | // The contract itself rejects partial identity. | |
| 2280 | + | require.Error(t, (ports.BrokerDaemonObservation{ | |
| 2281 | + | DisplayOrigin: "host", | |
| 2282 | + | Policy: poolPolicy(), | |
| 2283 | + | Identity: "authed-daemon", | |
| 2284 | + | Incarnation: ports.BrokerDaemonIncarnation{7}, | |
| 2285 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 2286 | + | }).Validate()) | |
| 2287 | + | ||
| 2288 | + | clock := newManualClock(time.Unix(100, 0)) | |
| 2289 | + | probe := newTestProbe(1) | |
| 2290 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 2291 | + | reg := registration(t, "user@host:22", 1) | |
| 2292 | + | require.NoError(t, registry.setHosts(hostRecords(reg))) | |
| 2293 | + | startRegistry(t, registry) | |
| 2294 | + | ||
| 2295 | + | call := receiveCall(t, probe) | |
| 2296 | + | call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{ | |
| 2297 | + | Identity: "authed-daemon", | |
| 2298 | + | Incarnation: ports.BrokerDaemonIncarnation{7}, | |
| 2299 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 2300 | + | }} | |
| 2301 | + | host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { | |
| 2302 | + | return !host.Checking && host.ConsecutiveFailures == 1 | |
| 2303 | + | }) | |
| 2304 | + | require.Equal(t, domain.RemoteAvailabilityInvalidResponse, host.Availability) | |
| 2305 | + | require.Equal(t, domain.RemoteFailureInvalidResponse, host.LastFailure.Kind) | |
| 2306 | + | require.Empty(t, host.Identity, "partial identity must never be published") | |
| 2307 | + | require.True(t, host.Incarnation.IsZero()) | |
| 2308 | + | } | |
| 2309 | + | ||
| 2310 | + | // TestRegistryPublishesRemoteDaemonsInRegistrationOrder pins the publication | |
| 2311 | + | // invariant: remote daemons appear in durable membership registration order (no | |
| 2312 | + | // local producer exists yet, so the snapshot carries remotes only). | |
| 2313 | + | func TestRegistryPublishesRemoteDaemonsInRegistrationOrder(t *testing.T) { | |
| 2314 | + | registry := newTestRegistry(t, 1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0))) | |
| 2315 | + | first := registration(t, "a.test", 1) | |
| 2316 | + | second := registration(t, "b.test", 2) | |
| 2317 | + | third := registration(t, "c.test", 3) | |
| 2318 | + | require.NoError(t, registry.setHosts(hostRecords(first, second))) | |
| 2319 | + | require.Equal(t, []string{"a.test", "b.test"}, daemonEndpoints(registry.Snapshot())) | |
| 2320 | + | ||
| 2321 | + | // Membership changes publish in the new registration order, not sorted and | |
| 2322 | + | // not map order. | |
| 2323 | + | require.NoError(t, registry.setHosts(hostRecords(third, second, first))) | |
| 2324 | + | require.Equal(t, []string{"c.test", "b.test", "a.test"}, daemonEndpoints(registry.Snapshot())) | |
| 2325 | + | } | |
| 2326 | + | ||
| 2327 | + | func daemonEndpoints(snapshot ports.BrokerSnapshot) []string { | |
| 2328 | + | endpoints := make([]string, 0, len(snapshot.Daemons)) | |
| 2329 | + | for _, daemon := range snapshot.Daemons { | |
| 2330 | + | endpoints = append(endpoints, daemon.Endpoint) | |
| 2331 | + | } | |
| 2332 | + | return endpoints | |
| 2333 | + | } | |
| 2334 | + | ||
| 2335 | + | // TestDurableProjectionDropsLocalObservationAndPolicy pins the durable format | |
| 2336 | + | // decision: the persistable copy carries remote observations only (a local | |
| 2337 | + | // daemon observation is process-local) and omits policy, because membership is | |
| 2338 | + | // the single policy authority and the loader re-stamps it. | |
| 2339 | + | func TestDurableProjectionDropsLocalObservationAndPolicy(t *testing.T) { | |
| 2340 | + | reg := registration(t, "user@host:22", 1) | |
| 2341 | + | snapshot := ports.BrokerSnapshot{Epoch: 1, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |
| 2342 | + | {Local: true, DisplayOrigin: "local", Policy: poolPolicy(), Availability: domain.RemoteAvailabilityReachable}, | |
| 2343 | + | {Endpoint: reg.Endpoint, DisplayOrigin: domain.RemoteDisplayOrigin(reg.Endpoint), Registration: reg, Policy: poolPolicy(), Availability: domain.RemoteAvailabilityReachable}, | |
| 2344 | + | }} | |
| 2345 | + | out := durable(snapshot) | |
| 2346 | + | require.Len(t, out.Daemons, 1) | |
| 2347 | + | require.False(t, out.Daemons[0].Local) | |
| 2348 | + | require.Equal(t, reg.Endpoint, out.Daemons[0].Endpoint) | |
| 2349 | + | require.Equal(t, ports.BrokerPolicy{}, out.Daemons[0].Policy) | |
| 2350 | + | require.Nil(t, out.Removed) | |
| 2351 | + | // The published snapshot the caller handed in is untouched. | |
| 2352 | + | require.Len(t, snapshot.Daemons, 2) | |
| 2353 | + | require.Equal(t, poolPolicy(), snapshot.Daemons[0].Policy) | |
| 2354 | + | } | |
| 2355 | + | ||
| 2356 | + | // TestHostDisplayOriginIsBounded pins the presentation-hint bound: a derived | |
| 2357 | + | // origin is clamped rune-safely at the ports limit so an over-long configured | |
| 2358 | + | // endpoint still yields a valid publication instead of an unobservable host. | |
| 2359 | + | func TestHostDisplayOriginIsBounded(t *testing.T) { | |
| 2360 | + | require.Equal(t, "host:22", hostDisplayOrigin("user@host:22")) | |
| 2361 | + | require.Equal(t, "plain.test", hostDisplayOrigin("plain.test")) | |
| 2362 | + | ||
| 2363 | + | origin := hostDisplayOrigin("user@" + strings.Repeat("€", 100)) | |
| 2364 | + | require.LessOrEqual(t, len(origin), ports.BrokerMaxDisplayOriginBytes) | |
| 2365 | + | require.True(t, utf8.ValidString(origin)) | |
| 2366 | + | require.NotEmpty(t, origin) | |
| 2367 | + | } | |
M internal/usecase/broker/service_test.go +7 -7
| @@ -110,8 +110,8 @@ func TestAuthorityAdmissionContextNotRetained(t *testing.T) { | |||
| 110 | 110 | // The admission context is discarded: the connection keeps serving after the | |
| 111 | 111 | // caller's setup deadline expires. | |
| 112 | 112 | cancel() | |
| 113 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{registration(t, "user@host:22", 1)})) | |
| 114 | - | require.Len(t, service.Snapshot().Hosts, 1) | |
| 113 | + | require.NoError(t, registry.setHosts(hostRecords(registration(t, "user@host:22", 1)))) | |
| 114 | + | require.Len(t, service.Snapshot().Daemons, 1) | |
| 115 | 115 | ||
| 116 | 116 | stream, err := service.OpenStream(context.Background(), poolRequest(service.ConnectionID(), 1)) | |
| 117 | 117 | require.NoError(t, err) | |
| @@ -251,14 +251,14 @@ func TestServiceSnapshotDelegates(t *testing.T) { | |||
| 251 | 251 | require.NoError(t, err) | |
| 252 | 252 | defer sub.Close() | |
| 253 | 253 | ||
| 254 | - | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{registration(t, "user@host:22", 1)})) | |
| 254 | + | require.NoError(t, registry.setHosts(hostRecords(registration(t, "user@host:22", 1)))) | |
| 255 | 255 | select { | |
| 256 | 256 | case <-sub.Changed(): | |
| 257 | 257 | case <-time.After(time.Second): | |
| 258 | 258 | t.Fatal("the connection subscription must observe a registry publication") | |
| 259 | 259 | } | |
| 260 | 260 | require.Equal(t, registry.Snapshot(), service.Snapshot()) | |
| 261 | - | require.Len(t, service.Snapshot().Hosts, 1) | |
| 261 | + | require.Len(t, service.Snapshot().Daemons, 1) | |
| 262 | 262 | } | |
| 263 | 263 | ||
| 264 | 264 | func TestServiceOperationLeaseLifetime(t *testing.T) { | |
| @@ -443,7 +443,7 @@ func TestServiceOfflineMembershipRefusal(t *testing.T) { | |||
| 443 | 443 | require.False(t, removed) | |
| 444 | 444 | require.ErrorIs(t, err, ErrOfflineMembership) | |
| 445 | 445 | ||
| 446 | - | require.Empty(t, registry.Snapshot().Hosts, "a refused mutation must not touch authority") | |
| 446 | + | require.Empty(t, registry.Snapshot().Daemons, "a refused mutation must not touch authority") | |
| 447 | 447 | before := registry.Snapshot() | |
| 448 | 448 | service.RequestReconcile("new@host:22") | |
| 449 | 449 | require.Equal(t, before, registry.Snapshot(), "reconcile must be a no-op") | |
| @@ -456,7 +456,7 @@ func TestRegistryObservationDisabledRestoresAndPublishes(t *testing.T) { | |||
| 456 | 456 | store.loaded = ports.BrokerSnapshot{ | |
| 457 | 457 | Epoch: 9, | |
| 458 | 458 | Revision: 4, | |
| 459 | - | Hosts: []ports.RemoteHostSnapshot{{ | |
| 459 | + | Daemons: []ports.BrokerDaemonObservation{{ | |
| 460 | 460 | Endpoint: endpoint, | |
| 461 | 461 | Registration: registration(t, endpoint, 1), | |
| 462 | 462 | Availability: domain.RemoteAvailabilityReachable, | |
| @@ -482,7 +482,7 @@ func TestRegistryObservationDisabledIssuesNoProbesOrTimers(t *testing.T) { | |||
| 482 | 482 | probe := newTestProbe(4) | |
| 483 | 483 | r, err := NewRegistryWithConfig(1, store, probe, clock, nil, RegistryConfig{ObservationDisabled: true}) | |
| 484 | 484 | require.NoError(t, err) | |
| 485 | - | require.NoError(t, r.setHosts([]domain.RemoteRegistration{registration(t, "user@host:22", 1)})) | |
| 485 | + | require.NoError(t, r.setHosts(hostRecords(registration(t, "user@host:22", 1)))) | |
| 486 | 486 | ||
| 487 | 487 | ctx, cancel := context.WithCancel(context.Background()) | |
| 488 | 488 | done := make(chan struct{}) | |