feat(broker): expose mutable membership over IPC
- commit
- 68c0c3b2077fd87c974d3446da89976deac16043
- author
- brice · · 2026-09-20 04:31 UTC
- parent
- c9fb7d8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
32 files · +2948 -507
M docs/architecture.md +3 -3
| @@ -23,7 +23,7 @@ vev uses a hexagonal core with typed session messages at the client and daemon b | |||
| 23 | 23 | decode-failure classification. | |
| 24 | 24 | - `internal/adapters/brokerwire`: translates between typed broker messages | |
| 25 | 25 | and the separate broker Protobuf conversation (`schema/broker.proto`; | |
| 26 | - | client tags 101-110, server tags 201-209, disjoint from every session | |
| 26 | + | client tags 101-111, server tags 201-209, disjoint from every session | |
| 27 | 27 | tag, each direction its own closed `oneof`). It owns the stateless | |
| 28 | 28 | broker codec over `wire.ScanEnvelope`, the broker preamble (roles 3/4, | |
| 29 | 29 | exact `protocol.Version`, 1 MiB..16 MiB / 1..64 KiB ceilings, 4 KiB | |
| @@ -197,12 +197,12 @@ Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values. | |||
| 197 | 197 | `go tool buf generate`, and handle the new variant in `sessionwire`. | |
| 198 | 198 | Never edit generated `*.pb.go`; never add manual IDs or dispatch tables. | |
| 199 | 199 | - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`. | |
| 200 | - | - 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. | |
| 200 | + | - Bump `internal/protocol.Version` for negotiated wire layout changes (currently `56`). The preamble epoch (`wire.ProtocolEpoch`, QUIC ALPN `vev/1`) bumps only for an intentional clean break. | |
| 201 | 201 | ||
| 202 | 202 | ## Broker wire core (Plan 001 P3.1, not activated) | |
| 203 | 203 | ||
| 204 | 204 | `internal/adapters/brokerwire` owns the broker's separate Protobuf | |
| 205 | - | conversation end to end: client tags 101-110 and server tags 201-209 are | |
| 205 | + | conversation end to end: client tags 101-111 and server tags 201-209 are | |
| 206 | 206 | disjoint from every session tag, and each direction is its own closed | |
| 207 | 207 | directional `oneof` union. A broker connection opens with exactly one | |
| 208 | 208 | broker preamble (roles 3/4) that carries the same magic, epoch, exact | |
M docs/protocol.md +1 -1
| @@ -97,7 +97,7 @@ connection and correlated by `RequestID`. | |||
| 97 | 97 | ## Broker negotiation | |
| 98 | 98 | ||
| 99 | 99 | Broker connections are a separate conversation from the session protocol. | |
| 100 | - | `internal/adapters/brokerwire` owns it: client tags 101-110 and server tags | |
| 100 | + | `internal/adapters/brokerwire` owns it: client tags 101-111 and server tags | |
| 101 | 101 | 201-209 are disjoint from every session tag, and each direction is its own | |
| 102 | 102 | closed directional `oneof` union. A broker connection opens with exactly one | |
| 103 | 103 | broker preamble (`PreambleRequest` client → server, `PreambleResponse` server | |
M internal/adapters/brokeripc/bounded_setup_test.go +2 -6
| @@ -176,9 +176,7 @@ func TestClientConnectionOutlivesSetupContext(t *testing.T) { | |||
| 176 | 176 | time.Sleep(400 * time.Millisecond) | |
| 177 | 177 | require.ErrorIs(t, setupCtx.Err(), context.DeadlineExceeded, "the setup context must have expired") | |
| 178 | 178 | ||
| 179 | - | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 180 | - | defer cancel() | |
| 181 | - | require.NoError(t, client.AddHost(ctx, "after@setup:22"), "a registered connection must outlive its setup context") | |
| 179 | + | requireConnectionDelegates(t, client) | |
| 182 | 180 | } | |
| 183 | 181 | ||
| 184 | 182 | // TestServerRegistrationBudgetSettlesSilentPeer proves a same-user peer cannot | |
| @@ -279,9 +277,7 @@ func TestServerRegistrationBudgetLeavesRegisteredSessionHealthy(t *testing.T) { | |||
| 279 | 277 | ||
| 280 | 278 | // Outlive the accept-time registration budget. | |
| 281 | 279 | time.Sleep(400 * time.Millisecond) | |
| 282 | - | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 283 | - | defer cancel() | |
| 284 | - | require.NoError(t, client.AddHost(ctx, "after@budget:22")) | |
| 280 | + | requireConnectionDelegates(t, client) | |
| 285 | 281 | select { | |
| 286 | 282 | case <-core.done: | |
| 287 | 283 | t.Fatal("a session that registered in time must not be settled by the registration budget") | |
M internal/adapters/brokeripc/client.go +61 -28
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "github.com/bnema/vev/internal/adapters/brokerwire" | |
| 10 | 10 | "github.com/bnema/vev/internal/adapters/ipc" | |
| 11 | 11 | "github.com/bnema/vev/internal/adapters/streamframe" | |
| 12 | + | "github.com/bnema/vev/internal/domain" | |
| 12 | 13 | "github.com/bnema/vev/internal/ports" | |
| 13 | 14 | "github.com/bnema/vev/internal/protocol/wire" | |
| 14 | 15 | ) | |
| @@ -121,6 +122,7 @@ func dial(ctx context.Context, path string, cfg Config, verify ipc.PeerVerifier) | |||
| 121 | 122 | ctx: ctx, | |
| 122 | 123 | cancel: cancelSession, | |
| 123 | 124 | pending: make(map[ports.BrokerOperationID]chan operationResult), | |
| 125 | + | kinds: make(map[ports.BrokerOperationID]brokerwire.RegisterMutationKind), | |
| 124 | 126 | streams: make(map[ports.BrokerStreamID]*clientStream), | |
| 125 | 127 | done: make(chan struct{}), | |
| 126 | 128 | } | |
| @@ -192,9 +194,10 @@ func exchangeRegister(transport wire.BoundedTransport, ceilings brokerwire.Ceili | |||
| 192 | 194 | ||
| 193 | 195 | // operationResult is one reply to a mutating operation. | |
| 194 | 196 | type operationResult struct { | |
| 195 | - | outcome ports.BrokerMutationOutcome | |
| 196 | - | removed bool | |
| 197 | - | detail brokerwire.ErrorDetail | |
| 197 | + | outcome ports.BrokerMutationOutcome | |
| 198 | + | removed bool | |
| 199 | + | registration domain.RemoteRegistration | |
| 200 | + | detail brokerwire.ErrorDetail | |
| 198 | 201 | } | |
| 199 | 202 | ||
| 200 | 203 | // client implements ports.BrokerService over one broker IPC connection. | |
| @@ -217,6 +220,7 @@ type client struct { | |||
| 217 | 220 | nextStream ports.BrokerStreamID | |
| 218 | 221 | snapshot ports.BrokerSnapshot | |
| 219 | 222 | pending map[ports.BrokerOperationID]chan operationResult | |
| 223 | + | kinds map[ports.BrokerOperationID]brokerwire.RegisterMutationKind | |
| 220 | 224 | streams map[ports.BrokerStreamID]*clientStream | |
| 221 | 225 | closeOnce sync.Once | |
| 222 | 226 | done chan struct{} | |
| @@ -267,10 +271,21 @@ func (c *client) dispatch(message brokerwire.ServerMessage) error { | |||
| 267 | 271 | if !c.scopeMatches(m.Epoch, m.Connection) { | |
| 268 | 272 | return errors.Join(ErrScopeMismatch, ErrProtocol) | |
| 269 | 273 | } | |
| 270 | - | // The operation's waiter records the tracker completion exactly once; | |
| 271 | - | // a result with no waiter (an abandoned or replayed operation) is | |
| 272 | - | // dropped here. | |
| 273 | - | c.completeOperation(m.Operation, operationResult{outcome: m.Outcome, removed: m.Removed, detail: m.Error}) | |
| 274 | + | c.mu.Lock() | |
| 275 | + | _, ok := c.pending[m.Operation] | |
| 276 | + | kind := c.kinds[m.Operation] | |
| 277 | + | c.mu.Unlock() | |
| 278 | + | // An abandoned or replayed completion has no authority over current | |
| 279 | + | // state and is dropped. A completion for a live operation must satisfy | |
| 280 | + | // that request kind's payload invariant. | |
| 281 | + | if !ok { | |
| 282 | + | return nil | |
| 283 | + | } | |
| 284 | + | if err := m.ValidateForMutation(kind); err != nil { | |
| 285 | + | c.completeOperation(m.Operation, operationResult{outcome: ports.BrokerOutcomeUnknown, detail: errorDetail(err)}) | |
| 286 | + | return errors.Join(ErrMalformedFrame, err) | |
| 287 | + | } | |
| 288 | + | c.completeOperation(m.Operation, operationResult{outcome: m.Outcome, removed: m.Removed, registration: m.Registration, detail: m.Error}) | |
| 274 | 289 | return nil | |
| 275 | 290 | case brokerwire.StreamOpened: | |
| 276 | 291 | if !c.scopeMatches(m.Epoch, m.Connection) { | |
| @@ -361,17 +376,26 @@ func (c *client) publish(snapshot ports.BrokerSnapshot) { | |||
| 361 | 376 | } | |
| 362 | 377 | } | |
| 363 | 378 | ||
| 379 | + | // releaseOperation removes all client-side state for an operation. | |
| 380 | + | func (c *client) releaseOperation(operation ports.BrokerOperationID) { | |
| 381 | + | c.mu.Lock() | |
| 382 | + | delete(c.pending, operation) | |
| 383 | + | delete(c.kinds, operation) | |
| 384 | + | c.mu.Unlock() | |
| 385 | + | } | |
| 386 | + | ||
| 364 | 387 | // completeOperation hands one result to the operation's waiter, if any. | |
| 365 | 388 | func (c *client) completeOperation(operation ports.BrokerOperationID, result operationResult) { | |
| 366 | 389 | c.mu.Lock() | |
| 367 | - | ch := c.pending[operation] | |
| 390 | + | reply, ok := c.pending[operation] | |
| 368 | 391 | delete(c.pending, operation) | |
| 392 | + | delete(c.kinds, operation) | |
| 369 | 393 | c.mu.Unlock() | |
| 370 | - | if ch == nil { | |
| 394 | + | if !ok { | |
| 371 | 395 | return | |
| 372 | 396 | } | |
| 373 | 397 | select { | |
| 374 | - | case ch <- result: | |
| 398 | + | case reply <- result: | |
| 375 | 399 | default: | |
| 376 | 400 | } | |
| 377 | 401 | } | |
| @@ -533,30 +557,37 @@ func (c *client) CloseStream(connection ports.BrokerConnectionID, stream ports.B | |||
| 533 | 557 | return nil | |
| 534 | 558 | } | |
| 535 | 559 | ||
| 536 | - | // AddHost requests one host registration. A lost reply is reported as | |
| 537 | - | // outcome-unknown, never as an invitation to replay blindly. | |
| 538 | - | func (c *client) AddHost(ctx context.Context, target string) error { | |
| 539 | - | result, err := c.mutate(ctx, func(operation ports.BrokerOperationID) brokerwire.ClientMessage { | |
| 540 | - | return brokerwire.AddHost{Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation, Endpoint: target} | |
| 560 | + | // AddHost sends one exact endpoint and policy mutation. | |
| 561 | + | func (c *client) AddHost(ctx context.Context, endpoint string, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) { | |
| 562 | + | result, err := c.mutate(ctx, brokerwire.MutationKindAddHost, func(operation ports.BrokerOperationID) brokerwire.ClientMessage { | |
| 563 | + | return brokerwire.AddHost{Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation, Endpoint: endpoint, Policy: policy} | |
| 541 | 564 | }) | |
| 542 | 565 | if err != nil { | |
| 543 | - | return err | |
| 566 | + | return domain.RemoteRegistration{}, err | |
| 544 | 567 | } | |
| 545 | - | return mutationFailure(result) | |
| 568 | + | return result.registration, mutationFailure(result) | |
| 546 | 569 | } | |
| 547 | 570 | ||
| 548 | - | // RemoveHost requests one host removal and reports whether the host was present. | |
| 549 | - | func (c *client) RemoveHost(ctx context.Context, target string) (bool, error) { | |
| 550 | - | result, err := c.mutate(ctx, func(operation ports.BrokerOperationID) brokerwire.ClientMessage { | |
| 551 | - | return brokerwire.RemoveHost{Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation, Endpoint: target} | |
| 571 | + | // RemoveHost sends one exact-registration mutation. | |
| 572 | + | func (c *client) RemoveHost(ctx context.Context, expected domain.RemoteRegistration) (bool, error) { | |
| 573 | + | result, err := c.mutate(ctx, brokerwire.MutationKindRemoveHost, func(operation ports.BrokerOperationID) brokerwire.ClientMessage { | |
| 574 | + | return brokerwire.RemoveHost{Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation, Registration: expected} | |
| 552 | 575 | }) | |
| 553 | 576 | if err != nil { | |
| 554 | 577 | return false, err | |
| 555 | 578 | } | |
| 556 | - | if err := mutationFailure(result); err != nil { | |
| 557 | - | return false, err | |
| 579 | + | return result.removed, mutationFailure(result) | |
| 580 | + | } | |
| 581 | + | ||
| 582 | + | // UpdateHostPolicy sends one exact-registration replacement policy mutation. | |
| 583 | + | func (c *client) UpdateHostPolicy(ctx context.Context, expected domain.RemoteRegistration, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) { | |
| 584 | + | result, err := c.mutate(ctx, brokerwire.MutationKindUpdateHostPolicy, func(operation ports.BrokerOperationID) brokerwire.ClientMessage { | |
| 585 | + | return brokerwire.UpdateHostPolicy{Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation, Registration: expected, Policy: policy} | |
| 586 | + | }) | |
| 587 | + | if err != nil { | |
| 588 | + | return domain.RemoteRegistration{}, err | |
| 558 | 589 | } | |
| 559 | - | return result.removed, nil | |
| 590 | + | return result.registration, mutationFailure(result) | |
| 560 | 591 | } | |
| 561 | 592 | ||
| 562 | 593 | // RequestReconcile asks the broker to re-observe one endpoint. The port carries | |
| @@ -631,7 +662,7 @@ func (c *client) scopeRequest(request ports.BrokerOpenStreamRequest) (ports.Brok | |||
| 631 | 662 | // error and records a failed outcome; once the wire attempt was launched, every | |
| 632 | 663 | // failure is reported as outcome-unknown, so a caller refreshes authoritative | |
| 633 | 664 | // state instead of replaying a non-idempotent mutation blindly. | |
| 634 | - | func (c *client) mutate(ctx context.Context, build func(ports.BrokerOperationID) brokerwire.ClientMessage) (operationResult, error) { | |
| 665 | + | func (c *client) mutate(ctx context.Context, kind brokerwire.RegisterMutationKind, build func(ports.BrokerOperationID) brokerwire.ClientMessage) (operationResult, error) { | |
| 635 | 666 | operation, err := newOperationID() | |
| 636 | 667 | if err != nil { | |
| 637 | 668 | return operationResult{}, err | |
| @@ -652,14 +683,16 @@ func (c *client) mutate(ctx context.Context, build func(ports.BrokerOperationID) | |||
| 652 | 683 | return operationResult{}, admissionError(err) | |
| 653 | 684 | } | |
| 654 | 685 | c.pending[operation] = reply | |
| 686 | + | if c.kinds == nil { | |
| 687 | + | c.kinds = make(map[ports.BrokerOperationID]brokerwire.RegisterMutationKind) | |
| 688 | + | } | |
| 689 | + | c.kinds[operation] = kind | |
| 655 | 690 | c.mu.Unlock() | |
| 656 | 691 | // No result observed is exactly the outcome-unknown case; a request that | |
| 657 | 692 | // was never launched is recorded as failed instead. | |
| 658 | 693 | outcome := ports.BrokerOutcomeUnknown | |
| 659 | 694 | defer func() { | |
| 660 | - | c.mu.Lock() | |
| 661 | - | delete(c.pending, operation) | |
| 662 | - | c.mu.Unlock() | |
| 695 | + | c.releaseOperation(operation) | |
| 663 | 696 | _ = c.conn.CompleteOperation(operation, outcome) | |
| 664 | 697 | }() | |
| 665 | 698 | attempted, err := c.sendContext(ctx, build(operation)) | |
M internal/adapters/brokeripc/client_mutation_test.go +443 -61
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "github.com/stretchr/testify/require" | |
| 12 | 12 | ||
| 13 | 13 | "github.com/bnema/vev/internal/adapters/brokerwire" | |
| 14 | + | "github.com/bnema/vev/internal/domain" | |
| 14 | 15 | "github.com/bnema/vev/internal/ports" | |
| 15 | 16 | "github.com/bnema/vev/internal/protocol/wire" | |
| 16 | 17 | ) | |
| @@ -122,6 +123,7 @@ func newGatedClient(t *testing.T, cfg Config) (*client, *gatedTransport) { | |||
| 122 | 123 | ctx: ctx, | |
| 123 | 124 | cancel: cancel, | |
| 124 | 125 | pending: make(map[ports.BrokerOperationID]chan operationResult), | |
| 126 | + | kinds: make(map[ports.BrokerOperationID]brokerwire.RegisterMutationKind), | |
| 125 | 127 | streams: make(map[ports.BrokerStreamID]*clientStream), | |
| 126 | 128 | done: make(chan struct{}), | |
| 127 | 129 | } | |
| @@ -129,38 +131,104 @@ func newGatedClient(t *testing.T, cfg Config) (*client, *gatedTransport) { | |||
| 129 | 131 | return c, transport | |
| 130 | 132 | } | |
| 131 | 133 | ||
| 132 | - | // mutationOutcome is one AddHost or RemoveHost result. | |
| 134 | + | // mutationOutcome is one membership mutation result: the removal bool, the | |
| 135 | + | // authoritative registration (additions and policy updates), and the typed | |
| 136 | + | // error a caller observes. | |
| 133 | 137 | type mutationOutcome struct { | |
| 134 | - | removed bool | |
| 135 | - | err error | |
| 138 | + | removed bool | |
| 139 | + | registration domain.RemoteRegistration | |
| 140 | + | err error | |
| 136 | 141 | } | |
| 137 | 142 | ||
| 138 | - | // mutationCall adapts one mutating operation to the shared table shape. | |
| 143 | + | // mutationCall adapts one public ports.BrokerService membership method to the | |
| 144 | + | // shared table shape. Every entry drives the real public API, so the wire | |
| 145 | + | // request the carriage captures is exactly the one production sends. | |
| 139 | 146 | type mutationCall struct { | |
| 140 | 147 | name string | |
| 141 | 148 | endpoint string | |
| 142 | 149 | wantRemoved bool | |
| 143 | - | call func(ctx context.Context, c *client, endpoint string) (bool, error) | |
| 150 | + | kind brokerwire.RegisterMutationKind | |
| 151 | + | call func(ctx context.Context, c *client) (bool, domain.RemoteRegistration, error) | |
| 152 | + | // success builds the payload of one legal wire result for this request | |
| 153 | + | // kind; the test fills in the connection scope and operation identity. | |
| 154 | + | success func(operation ports.BrokerOperationID) brokerwire.OperationResult | |
| 144 | 155 | } | |
| 145 | 156 | ||
| 146 | 157 | var mutationCalls = []mutationCall{ | |
| 147 | 158 | { | |
| 148 | 159 | name: "AddHost", | |
| 149 | 160 | endpoint: "new@host:22", | |
| 150 | - | call: func(ctx context.Context, c *client, endpoint string) (bool, error) { | |
| 151 | - | return false, c.AddHost(ctx, endpoint) | |
| 161 | + | kind: brokerwire.MutationKindAddHost, | |
| 162 | + | call: func(ctx context.Context, c *client) (bool, domain.RemoteRegistration, error) { | |
| 163 | + | registration, err := c.AddHost(ctx, "new@host:22", testPolicy()) | |
| 164 | + | return false, registration, err | |
| 165 | + | }, | |
| 166 | + | success: func(operation ports.BrokerOperationID) brokerwire.OperationResult { | |
| 167 | + | return brokerwire.OperationResult{ | |
| 168 | + | Operation: operation, Outcome: ports.BrokerOutcomeOK, | |
| 169 | + | Registration: mutationRegistration("new@host:22"), | |
| 170 | + | } | |
| 152 | 171 | }, | |
| 153 | 172 | }, | |
| 154 | 173 | { | |
| 155 | 174 | name: "RemoveHost", | |
| 156 | 175 | endpoint: "known", | |
| 157 | 176 | wantRemoved: true, | |
| 158 | - | call: func(ctx context.Context, c *client, endpoint string) (bool, error) { | |
| 159 | - | return c.RemoveHost(ctx, endpoint) | |
| 177 | + | kind: brokerwire.MutationKindRemoveHost, | |
| 178 | + | call: func(ctx context.Context, c *client) (bool, domain.RemoteRegistration, error) { | |
| 179 | + | removed, err := c.RemoveHost(ctx, mutationRegistration("known")) | |
| 180 | + | return removed, domain.RemoteRegistration{}, err | |
| 181 | + | }, | |
| 182 | + | success: func(operation ports.BrokerOperationID) brokerwire.OperationResult { | |
| 183 | + | return brokerwire.OperationResult{Operation: operation, Outcome: ports.BrokerOutcomeOK, Removed: true} | |
| 184 | + | }, | |
| 185 | + | }, | |
| 186 | + | { | |
| 187 | + | name: "UpdateHostPolicy", | |
| 188 | + | endpoint: "known", | |
| 189 | + | kind: brokerwire.MutationKindUpdateHostPolicy, | |
| 190 | + | call: func(ctx context.Context, c *client) (bool, domain.RemoteRegistration, error) { | |
| 191 | + | registration, err := c.UpdateHostPolicy(ctx, mutationRegistration("known"), testPolicy()) | |
| 192 | + | return false, registration, err | |
| 193 | + | }, | |
| 194 | + | success: func(operation ports.BrokerOperationID) brokerwire.OperationResult { | |
| 195 | + | expected := mutationRegistration("known") | |
| 196 | + | expected.Generation++ | |
| 197 | + | return brokerwire.OperationResult{Operation: operation, Outcome: ports.BrokerOutcomeOK, Registration: expected} | |
| 160 | 198 | }, | |
| 161 | 199 | }, | |
| 162 | 200 | } | |
| 163 | 201 | ||
| 202 | + | // mutationRegistration builds one valid exact registration for a mutation | |
| 203 | + | // fixture. | |
| 204 | + | func mutationRegistration(endpoint string) domain.RemoteRegistration { | |
| 205 | + | registration, err := domain.NewRemoteRegistration(endpoint, [16]byte{0x11}) | |
| 206 | + | if err != nil { | |
| 207 | + | panic(err) | |
| 208 | + | } | |
| 209 | + | return registration | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // run drives one table entry through the public API. | |
| 213 | + | func (call mutationCall) run(ctx context.Context, c *client) (bool, domain.RemoteRegistration, error) { | |
| 214 | + | return call.call(ctx, c) | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | // deliverSuccess plays one legal success result for a captured request. | |
| 218 | + | func (call mutationCall) deliverSuccess(t *testing.T, c *client, operation ports.BrokerOperationID) { | |
| 219 | + | t.Helper() | |
| 220 | + | result := call.success(operation) | |
| 221 | + | result.Epoch = c.scope.Epoch | |
| 222 | + | result.Connection = c.scope.Connection | |
| 223 | + | require.NoError(t, c.dispatch(result)) | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // deliverResult plays one explicit broker reply for a captured request. | |
| 227 | + | func deliverResult(t *testing.T, c *client, result brokerwire.OperationResult) { | |
| 228 | + | t.Helper() | |
| 229 | + | require.NoError(t, c.dispatch(result)) | |
| 230 | + | } | |
| 231 | + | ||
| 164 | 232 | // awaitResult waits for one mutation call to return. | |
| 165 | 233 | func awaitResult(t *testing.T, results <-chan mutationOutcome) mutationOutcome { | |
| 166 | 234 | t.Helper() | |
| @@ -195,26 +263,60 @@ func operationOf(t *testing.T, message brokerwire.ClientMessage) ports.BrokerOpe | |||
| 195 | 263 | return m.Operation | |
| 196 | 264 | case brokerwire.RemoveHost: | |
| 197 | 265 | return m.Operation | |
| 266 | + | case brokerwire.UpdateHostPolicy: | |
| 267 | + | return m.Operation | |
| 198 | 268 | default: | |
| 199 | 269 | t.Fatalf("unexpected client message %T", message) | |
| 200 | 270 | return ports.BrokerOperationID{} | |
| 201 | 271 | } | |
| 202 | 272 | } | |
| 203 | 273 | ||
| 204 | - | // endpointOf extracts the target of one captured request. | |
| 274 | + | // endpointOf extracts the target of one captured request. An AddHost names | |
| 275 | + | // its endpoint directly; a RemoveHost or UpdateHostPolicy carries exact | |
| 276 | + | // registration authority, whose endpoint is the target. | |
| 205 | 277 | func endpointOf(t *testing.T, message brokerwire.ClientMessage) string { | |
| 206 | 278 | t.Helper() | |
| 207 | 279 | switch m := message.(type) { | |
| 208 | 280 | case brokerwire.AddHost: | |
| 209 | 281 | return m.Endpoint | |
| 210 | 282 | case brokerwire.RemoveHost: | |
| 211 | - | return m.Endpoint | |
| 283 | + | return m.Registration.Endpoint | |
| 284 | + | case brokerwire.UpdateHostPolicy: | |
| 285 | + | return m.Registration.Endpoint | |
| 212 | 286 | default: | |
| 213 | 287 | t.Fatalf("unexpected client message %T", message) | |
| 214 | 288 | return "" | |
| 215 | 289 | } | |
| 216 | 290 | } | |
| 217 | 291 | ||
| 292 | + | // registrationOf extracts the exact expected registration of one captured | |
| 293 | + | // request. An AddHost carries no expected authority, only its policy. | |
| 294 | + | func registrationOf(t *testing.T, message brokerwire.ClientMessage) (domain.RemoteRegistration, bool) { | |
| 295 | + | t.Helper() | |
| 296 | + | switch m := message.(type) { | |
| 297 | + | case brokerwire.RemoveHost: | |
| 298 | + | return m.Registration, true | |
| 299 | + | case brokerwire.UpdateHostPolicy: | |
| 300 | + | return m.Registration, true | |
| 301 | + | default: | |
| 302 | + | return domain.RemoteRegistration{}, false | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | // policyOf extracts the policy of one captured request. | |
| 307 | + | func policyOf(t *testing.T, message brokerwire.ClientMessage) ports.BrokerPolicy { | |
| 308 | + | t.Helper() | |
| 309 | + | switch m := message.(type) { | |
| 310 | + | case brokerwire.AddHost: | |
| 311 | + | return m.Policy | |
| 312 | + | case brokerwire.UpdateHostPolicy: | |
| 313 | + | return m.Policy | |
| 314 | + | default: | |
| 315 | + | t.Fatalf("client message %T carries no policy", message) | |
| 316 | + | return ports.BrokerPolicy{} | |
| 317 | + | } | |
| 318 | + | } | |
| 319 | + | ||
| 218 | 320 | // pendingOperations snapshots the client's in-flight waiters. | |
| 219 | 321 | func pendingOperations(c *client) []ports.BrokerOperationID { | |
| 220 | 322 | c.mu.Lock() | |
| @@ -226,19 +328,75 @@ func pendingOperations(c *client) []ports.BrokerOperationID { | |||
| 226 | 328 | return out | |
| 227 | 329 | } | |
| 228 | 330 | ||
| 229 | - | // deliverResult plays one broker reply for a captured request. | |
| 230 | - | func deliverResult(t *testing.T, c *client, operation ports.BrokerOperationID, outcome ports.BrokerMutationOutcome, removed bool) { | |
| 331 | + | func requireNoPendingMutationState(t *testing.T, c *client) { | |
| 231 | 332 | t.Helper() | |
| 232 | - | require.NoError(t, c.dispatch(brokerwire.OperationResult{ | |
| 233 | - | Epoch: c.scope.Epoch, Connection: c.scope.Connection, | |
| 234 | - | Operation: operation, Outcome: outcome, Removed: removed, | |
| 235 | - | })) | |
| 333 | + | c.mu.Lock() | |
| 334 | + | defer c.mu.Unlock() | |
| 335 | + | require.Empty(t, c.pending, "pending replies must be released") | |
| 336 | + | require.Empty(t, c.kinds, "pending mutation kinds must be released") | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | // TestPublicMembershipAPIDelegatesExactAuthority proves the public membership | |
| 340 | + | // surface reaches the carriage with the caller's exact authority and returns the | |
| 341 | + | // broker's authority verbatim: an addition returns the minted registration, a | |
| 342 | + | // policy update returns the generation-advanced registration, and a removal | |
| 343 | + | // returns the exact bool. Each call sends exactly one request carrying the | |
| 344 | + | // caller's policy or exact registration, and never re-derives one locally. | |
| 345 | + | func TestPublicMembershipAPIDelegatesExactAuthority(t *testing.T) { | |
| 346 | + | policy := testPolicy() | |
| 347 | + | cases := []struct { | |
| 348 | + | name string | |
| 349 | + | index int | |
| 350 | + | }{ | |
| 351 | + | {name: "AddHost", index: 0}, | |
| 352 | + | {name: "RemoveHost", index: 1}, | |
| 353 | + | {name: "UpdateHostPolicy", index: 2}, | |
| 354 | + | } | |
| 355 | + | for _, tc := range cases { | |
| 356 | + | t.Run(tc.name, func(t *testing.T) { | |
| 357 | + | call := mutationCalls[tc.index] | |
| 358 | + | c, g := newGatedClient(t, Config{}) | |
| 359 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 360 | + | defer cancel() | |
| 361 | + | ||
| 362 | + | results := make(chan mutationOutcome, 1) | |
| 363 | + | go func() { | |
| 364 | + | removed, registration, err := call.run(ctx, c) | |
| 365 | + | results <- mutationOutcome{removed: removed, registration: registration, err: err} | |
| 366 | + | }() | |
| 367 | + | ||
| 368 | + | request := awaitSend(t, g) | |
| 369 | + | operation := operationOf(t, request) | |
| 370 | + | require.Equal(t, call.endpoint, endpointOf(t, request), "the captured request is the caller's own") | |
| 371 | + | if expected, ok := registrationOf(t, request); ok { | |
| 372 | + | require.Equal(t, mutationRegistration(call.endpoint), expected, | |
| 373 | + | "the exact expected registration must travel unmodified") | |
| 374 | + | require.NotZero(t, expected.Generation, "a removal or update never drops its fencing generation") | |
| 375 | + | } | |
| 376 | + | if call.kind != brokerwire.MutationKindRemoveHost { | |
| 377 | + | require.Equal(t, policy, policyOf(t, request), "the caller's exact policy must travel") | |
| 378 | + | } | |
| 379 | + | kind, ok := brokerwire.MutationKindOf(request) | |
| 380 | + | require.True(t, ok, "the captured request must be a membership mutation") | |
| 381 | + | require.Equal(t, call.kind, kind, "the client must emit the request kind the table names") | |
| 382 | + | g.releaseSend(nil) | |
| 383 | + | call.deliverSuccess(t, c, operation) | |
| 384 | + | ||
| 385 | + | got := awaitResult(t, results) | |
| 386 | + | require.NoError(t, got.err) | |
| 387 | + | require.Equal(t, call.wantRemoved, got.removed) | |
| 388 | + | want := call.success(operation).Registration | |
| 389 | + | require.Equal(t, want, got.registration, "the client returns the broker's authority verbatim") | |
| 390 | + | require.Equal(t, 1, g.sendCount(), "one delegation is exactly one wire request") | |
| 391 | + | require.Empty(t, pendingOperations(c)) | |
| 392 | + | }) | |
| 393 | + | } | |
| 236 | 394 | } | |
| 237 | 395 | ||
| 238 | - | // TestMutationSendUncertaintyTaxonomy pins the decision table for both mutating | |
| 239 | - | // operations: a request that definitely never left is a definite error with zero | |
| 396 | + | // TestMutationSendUncertaintyTaxonomy pins the decision table for every mutating | |
| 397 | + | // operation: a request that definitely never left is a definite error with zero | |
| 240 | 398 | // sends; any failure observed after the attempt was launched is outcome-unknown | |
| 241 | - | // with exactly one send. | |
| 399 | + | // with exactly one send and no replay. | |
| 242 | 400 | func TestMutationSendUncertaintyTaxonomy(t *testing.T) { | |
| 243 | 401 | scenarios := []struct { | |
| 244 | 402 | name string | |
| @@ -272,6 +430,18 @@ func TestMutationSendUncertaintyTaxonomy(t *testing.T) { | |||
| 272 | 430 | wantSends: 1, | |
| 273 | 431 | wantUnknown: true, | |
| 274 | 432 | }, | |
| 433 | + | { | |
| 434 | + | name: "reply lost after the request was sent", | |
| 435 | + | drive: func(t *testing.T, c *client, g *gatedTransport, cancel context.CancelFunc) { | |
| 436 | + | _ = awaitSend(t, g) | |
| 437 | + | g.releaseSend(nil) | |
| 438 | + | // The attempt completed, so the call now waits for its reply; only | |
| 439 | + | // the connection's terminal outcome ends that wait. | |
| 440 | + | require.NoError(t, c.Close()) | |
| 441 | + | }, | |
| 442 | + | wantSends: 1, | |
| 443 | + | wantUnknown: true, | |
| 444 | + | }, | |
| 275 | 445 | { | |
| 276 | 446 | name: "Send fails after the request was captured", | |
| 277 | 447 | drive: func(t *testing.T, c *client, g *gatedTransport, cancel context.CancelFunc) { | |
| @@ -281,6 +451,15 @@ func TestMutationSendUncertaintyTaxonomy(t *testing.T) { | |||
| 281 | 451 | wantSends: 1, | |
| 282 | 452 | wantUnknown: true, | |
| 283 | 453 | }, | |
| 454 | + | { | |
| 455 | + | name: "carriage EOF after the request was captured", | |
| 456 | + | drive: func(t *testing.T, c *client, g *gatedTransport, cancel context.CancelFunc) { | |
| 457 | + | _ = awaitSend(t, g) | |
| 458 | + | g.releaseSend(io.EOF) | |
| 459 | + | }, | |
| 460 | + | wantSends: 1, | |
| 461 | + | wantUnknown: true, | |
| 462 | + | }, | |
| 284 | 463 | } | |
| 285 | 464 | for _, call := range mutationCalls { | |
| 286 | 465 | for _, tt := range scenarios { | |
| @@ -293,14 +472,16 @@ func TestMutationSendUncertaintyTaxonomy(t *testing.T) { | |||
| 293 | 472 | } | |
| 294 | 473 | results := make(chan mutationOutcome, 1) | |
| 295 | 474 | go func() { | |
| 296 | - | removed, err := call.call(ctx, c, call.endpoint) | |
| 297 | - | results <- mutationOutcome{removed: removed, err: err} | |
| 475 | + | removed, registration, err := call.run(ctx, c) | |
| 476 | + | results <- mutationOutcome{removed: removed, registration: registration, err: err} | |
| 298 | 477 | }() | |
| 299 | 478 | if tt.drive != nil { | |
| 300 | 479 | tt.drive(t, c, g, cancel) | |
| 301 | 480 | } | |
| 302 | 481 | got := awaitResult(t, results) | |
| 303 | 482 | require.False(t, got.removed, "no reply means the target is never reported removed") | |
| 483 | + | require.Equal(t, domain.RemoteRegistration{}, got.registration, | |
| 484 | + | "no reply means no registration authority is invented") | |
| 304 | 485 | if tt.wantUnknown { | |
| 305 | 486 | var failure ports.BrokerError | |
| 306 | 487 | require.ErrorAs(t, got.err, &failure) | |
| @@ -313,7 +494,7 @@ func TestMutationSendUncertaintyTaxonomy(t *testing.T) { | |||
| 313 | 494 | "a request that never left must not be reported as a broker outcome") | |
| 314 | 495 | } | |
| 315 | 496 | require.Equal(t, tt.wantSends, g.sendCount(), "the request must travel at most once") | |
| 316 | - | require.Empty(t, pendingOperations(c), "every mutation releases its pending slot") | |
| 497 | + | requireNoPendingMutationState(t, c) | |
| 317 | 498 | }) | |
| 318 | 499 | } | |
| 319 | 500 | } | |
| @@ -327,37 +508,27 @@ func TestMutationDefiniteFailureRecordsAndReleases(t *testing.T) { | |||
| 327 | 508 | ctx, cancel := context.WithCancel(context.Background()) | |
| 328 | 509 | cancel() | |
| 329 | 510 | ||
| 330 | - | // Driving mutate directly makes the operation identity observable even | |
| 331 | - | // though the request is never encoded for the wire. | |
| 332 | - | var operation ports.BrokerOperationID | |
| 333 | - | result, err := c.mutate(ctx, func(id ports.BrokerOperationID) brokerwire.ClientMessage { | |
| 334 | - | operation = id | |
| 335 | - | return brokerwire.AddHost{ | |
| 336 | - | Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: id, Endpoint: "pre@host:22", | |
| 337 | - | } | |
| 338 | - | }) | |
| 511 | + | // Driving the public API with an already-cancelled caller is the definite | |
| 512 | + | // no-dispatch case: no request is encoded for the wire. | |
| 513 | + | added, err := c.AddHost(ctx, "pre@host:22", testPolicy()) | |
| 339 | 514 | require.ErrorIs(t, err, context.Canceled) | |
| 340 | - | require.Equal(t, operationResult{}, result) | |
| 341 | - | require.NotZero(t, operation) | |
| 515 | + | require.Equal(t, domain.RemoteRegistration{}, added) | |
| 342 | 516 | require.Zero(t, g.sendCount(), "a pre-cancelled caller never enters the carriage") | |
| 343 | 517 | require.Empty(t, pendingOperations(c), "the definite failure releases its pending slot") | |
| 344 | 518 | ||
| 345 | - | // The identity is recorded with an outcome, not left in flight: admitting it | |
| 346 | - | // again is deduped, which is only true once the tracker was completed. | |
| 347 | - | require.ErrorIs(t, c.conn.AdmitOperation(operation), brokerwire.ErrOperationCompleted) | |
| 348 | - | ||
| 349 | 519 | // The bound of one proves the slot is genuinely free: a fresh mutation is | |
| 350 | 520 | // admitted and completes normally. | |
| 351 | 521 | fresh, cancelFresh := context.WithCancel(context.Background()) | |
| 352 | 522 | defer cancelFresh() | |
| 353 | 523 | results := make(chan mutationOutcome, 1) | |
| 354 | 524 | go func() { | |
| 355 | - | removed, callErr := c.RemoveHost(fresh, "known") | |
| 356 | - | results <- mutationOutcome{removed: removed, err: callErr} | |
| 525 | + | removed, registration, callErr := mutationCalls[1].run(fresh, c) | |
| 526 | + | results <- mutationOutcome{removed: removed, registration: registration, err: callErr} | |
| 357 | 527 | }() | |
| 358 | 528 | request := awaitSend(t, g) | |
| 529 | + | operation := operationOf(t, request) | |
| 359 | 530 | g.releaseSend(nil) | |
| 360 | - | deliverResult(t, c, operationOf(t, request), ports.BrokerOutcomeOK, true) | |
| 531 | + | mutationCalls[1].deliverSuccess(t, c, operation) | |
| 361 | 532 | got := awaitResult(t, results) | |
| 362 | 533 | require.NoError(t, got.err) | |
| 363 | 534 | require.True(t, got.removed) | |
| @@ -376,12 +547,13 @@ func TestMutationUnknownOutcomeReleasesWithoutResend(t *testing.T) { | |||
| 376 | 547 | ||
| 377 | 548 | results := make(chan mutationOutcome, 1) | |
| 378 | 549 | go func() { | |
| 379 | - | removed, err := call.call(ctx, c, call.endpoint) | |
| 380 | - | results <- mutationOutcome{removed: removed, err: err} | |
| 550 | + | removed, registration, err := call.run(ctx, c) | |
| 551 | + | results <- mutationOutcome{removed: removed, registration: registration, err: err} | |
| 381 | 552 | }() | |
| 382 | 553 | request := awaitSend(t, g) | |
| 383 | 554 | operation := operationOf(t, request) | |
| 384 | 555 | require.Equal(t, call.endpoint, endpointOf(t, request), "the captured request is the caller's own") | |
| 556 | + | g.releaseSend(nil) | |
| 385 | 557 | cancel() | |
| 386 | 558 | got := awaitResult(t, results) | |
| 387 | 559 | var failure ports.BrokerError | |
| @@ -389,19 +561,18 @@ func TestMutationUnknownOutcomeReleasesWithoutResend(t *testing.T) { | |||
| 389 | 561 | require.Equal(t, ports.BrokerErrorOutcomeUnknown, failure.Code) | |
| 390 | 562 | require.False(t, got.removed) | |
| 391 | 563 | require.Equal(t, 1, g.sendCount(), "an uncertain attempt is never resent") | |
| 392 | - | // The cancelled attempt is still held inside the carriage. Hand it its | |
| 393 | - | // result now, so the next mutation's attempt is the only one waiting | |
| 394 | - | // on the shared release channel. | |
| 395 | - | g.releaseSend(errTestCarriage) | |
| 396 | 564 | ||
| 397 | 565 | // The identity was completed once, so its pending slot is released | |
| 398 | 566 | // rather than left in flight. | |
| 399 | 567 | require.ErrorIs(t, c.conn.AdmitOperation(operation), brokerwire.ErrOperationCompleted) | |
| 400 | 568 | require.Empty(t, pendingOperations(c)) | |
| 401 | 569 | ||
| 402 | - | // A late result for the completed identity is dropped, and it never | |
| 403 | - | // provokes another wire attempt. | |
| 404 | - | deliverResult(t, c, operation, ports.BrokerOutcomeOK, true) | |
| 570 | + | // A late legal result for the completed identity is dropped, and it | |
| 571 | + | // never provokes another wire attempt. | |
| 572 | + | late := call.success(operation) | |
| 573 | + | late.Epoch = c.scope.Epoch | |
| 574 | + | late.Connection = c.scope.Connection | |
| 575 | + | deliverResult(t, c, late) | |
| 405 | 576 | require.Empty(t, pendingOperations(c)) | |
| 406 | 577 | require.Equal(t, 1, g.sendCount()) | |
| 407 | 578 | ||
| @@ -411,12 +582,12 @@ func TestMutationUnknownOutcomeReleasesWithoutResend(t *testing.T) { | |||
| 411 | 582 | defer cancelFresh() | |
| 412 | 583 | next := make(chan mutationOutcome, 1) | |
| 413 | 584 | go func() { | |
| 414 | - | removed, err := call.call(fresh, c, call.endpoint) | |
| 415 | - | next <- mutationOutcome{removed: removed, err: err} | |
| 585 | + | removed, registration, err := call.run(fresh, c) | |
| 586 | + | next <- mutationOutcome{removed: removed, registration: registration, err: err} | |
| 416 | 587 | }() | |
| 417 | 588 | second := awaitSend(t, g) | |
| 418 | 589 | g.releaseSend(nil) | |
| 419 | - | deliverResult(t, c, operationOf(t, second), ports.BrokerOutcomeOK, call.wantRemoved) | |
| 590 | + | call.deliverSuccess(t, c, operationOf(t, second)) | |
| 420 | 591 | done := awaitResult(t, next) | |
| 421 | 592 | require.NoError(t, done.err) | |
| 422 | 593 | require.Equal(t, call.wantRemoved, done.removed) | |
| @@ -426,7 +597,7 @@ func TestMutationUnknownOutcomeReleasesWithoutResend(t *testing.T) { | |||
| 426 | 597 | } | |
| 427 | 598 | ||
| 428 | 599 | // TestMutationDeliveredReplyIsDefinite proves an ordinary delivered reply stays | |
| 429 | - | // the definite outcome for both operations, and a repeated result for the same | |
| 600 | + | // the definite outcome for every operation, and a repeated result for the same | |
| 430 | 601 | // identity changes nothing. | |
| 431 | 602 | func TestMutationDeliveredReplyIsDefinite(t *testing.T) { | |
| 432 | 603 | for _, call := range mutationCalls { | |
| @@ -437,14 +608,14 @@ func TestMutationDeliveredReplyIsDefinite(t *testing.T) { | |||
| 437 | 608 | ||
| 438 | 609 | results := make(chan mutationOutcome, 1) | |
| 439 | 610 | go func() { | |
| 440 | - | removed, err := call.call(ctx, c, call.endpoint) | |
| 441 | - | results <- mutationOutcome{removed: removed, err: err} | |
| 611 | + | removed, registration, err := call.run(ctx, c) | |
| 612 | + | results <- mutationOutcome{removed: removed, registration: registration, err: err} | |
| 442 | 613 | }() | |
| 443 | 614 | request := awaitSend(t, g) | |
| 444 | 615 | operation := operationOf(t, request) | |
| 445 | 616 | require.Equal(t, call.endpoint, endpointOf(t, request)) | |
| 446 | 617 | g.releaseSend(nil) | |
| 447 | - | deliverResult(t, c, operation, ports.BrokerOutcomeOK, call.wantRemoved) | |
| 618 | + | call.deliverSuccess(t, c, operation) | |
| 448 | 619 | got := awaitResult(t, results) | |
| 449 | 620 | require.NoError(t, got.err) | |
| 450 | 621 | require.Equal(t, call.wantRemoved, got.removed) | |
| @@ -452,13 +623,224 @@ func TestMutationDeliveredReplyIsDefinite(t *testing.T) { | |||
| 452 | 623 | require.Empty(t, pendingOperations(c)) | |
| 453 | 624 | ||
| 454 | 625 | // A replayed result for an already settled identity is dropped. | |
| 455 | - | deliverResult(t, c, operation, ports.BrokerOutcomeUnknown, true) | |
| 626 | + | replay := call.success(operation) | |
| 627 | + | replay.Epoch = c.scope.Epoch | |
| 628 | + | replay.Connection = c.scope.Connection | |
| 629 | + | deliverResult(t, c, replay) | |
| 456 | 630 | require.Empty(t, pendingOperations(c)) | |
| 457 | 631 | require.Equal(t, 1, g.sendCount()) | |
| 458 | 632 | }) | |
| 459 | 633 | } | |
| 460 | 634 | } | |
| 461 | 635 | ||
| 636 | + | // TestMutationInvalidResultIsOutcomeUnknownAndAbortsProtocol proves every result | |
| 637 | + | // that cannot carry authority for its request is refused rather than applied: a | |
| 638 | + | // result of the wrong kind, a success missing its registration authority, a | |
| 639 | + | // removal success that also carries a registration, and a result with a | |
| 640 | + | // malformed error detail each complete the caller as outcome-unknown and settle | |
| 641 | + | // the connection with a malformed-frame protocol violation. | |
| 642 | + | func TestMutationInvalidResultIsOutcomeUnknownAndAbortsProtocol(t *testing.T) { | |
| 643 | + | cases := []struct { | |
| 644 | + | name string | |
| 645 | + | call mutationCall | |
| 646 | + | result func(c *client, operation ports.BrokerOperationID) brokerwire.OperationResult | |
| 647 | + | }{ | |
| 648 | + | { | |
| 649 | + | name: "wrong kind for an addition", | |
| 650 | + | call: mutationCalls[0], | |
| 651 | + | result: func(c *client, operation ports.BrokerOperationID) brokerwire.OperationResult { | |
| 652 | + | return brokerwire.OperationResult{ | |
| 653 | + | Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation, | |
| 654 | + | Outcome: ports.BrokerOutcomeOK, Removed: true, | |
| 655 | + | } | |
| 656 | + | }, | |
| 657 | + | }, | |
| 658 | + | { | |
| 659 | + | name: "addition success missing its registration", | |
| 660 | + | call: mutationCalls[0], | |
| 661 | + | result: func(c *client, operation ports.BrokerOperationID) brokerwire.OperationResult { | |
| 662 | + | return brokerwire.OperationResult{ | |
| 663 | + | Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation, | |
| 664 | + | Outcome: ports.BrokerOutcomeOK, | |
| 665 | + | } | |
| 666 | + | }, | |
| 667 | + | }, | |
| 668 | + | { | |
| 669 | + | name: "policy update success missing its registration", | |
| 670 | + | call: mutationCalls[2], | |
| 671 | + | result: func(c *client, operation ports.BrokerOperationID) brokerwire.OperationResult { | |
| 672 | + | return brokerwire.OperationResult{ | |
| 673 | + | Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation, | |
| 674 | + | Outcome: ports.BrokerOutcomeOK, | |
| 675 | + | } | |
| 676 | + | }, | |
| 677 | + | }, | |
| 678 | + | { | |
| 679 | + | name: "removal success carrying a registration", | |
| 680 | + | call: mutationCalls[1], | |
| 681 | + | result: func(c *client, operation ports.BrokerOperationID) brokerwire.OperationResult { | |
| 682 | + | return brokerwire.OperationResult{ | |
| 683 | + | Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation, | |
| 684 | + | Outcome: ports.BrokerOutcomeOK, Removed: true, Registration: mutationRegistration("known"), | |
| 685 | + | } | |
| 686 | + | }, | |
| 687 | + | }, | |
| 688 | + | { | |
| 689 | + | name: "malformed error detail", | |
| 690 | + | call: mutationCalls[0], | |
| 691 | + | result: func(c *client, operation ports.BrokerOperationID) brokerwire.OperationResult { | |
| 692 | + | return brokerwire.OperationResult{ | |
| 693 | + | Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation, | |
| 694 | + | Outcome: ports.BrokerOutcomeFailed, | |
| 695 | + | Error: brokerwire.ErrorDetail{Code: ports.BrokerErrorCode(99), Text: "bogus"}, HasError: true, | |
| 696 | + | } | |
| 697 | + | }, | |
| 698 | + | }, | |
| 699 | + | } | |
| 700 | + | for _, tc := range cases { | |
| 701 | + | t.Run(tc.name, func(t *testing.T) { | |
| 702 | + | c, g := newGatedClient(t, Config{}) | |
| 703 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 704 | + | defer cancel() | |
| 705 | + | ||
| 706 | + | results := make(chan mutationOutcome, 1) | |
| 707 | + | go func() { | |
| 708 | + | removed, registration, err := tc.call.run(ctx, c) | |
| 709 | + | results <- mutationOutcome{removed: removed, registration: registration, err: err} | |
| 710 | + | }() | |
| 711 | + | request := awaitSend(t, g) | |
| 712 | + | operation := operationOf(t, request) | |
| 713 | + | g.releaseSend(nil) | |
| 714 | + | ||
| 715 | + | dispatchErr := c.dispatch(tc.result(c, operation)) | |
| 716 | + | require.Error(t, dispatchErr, "an invalid result must settle the connection") | |
| 717 | + | require.ErrorIs(t, dispatchErr, ErrMalformedFrame, "an invalid result is a protocol violation") | |
| 718 | + | ||
| 719 | + | got := awaitResult(t, results) | |
| 720 | + | var failure ports.BrokerError | |
| 721 | + | require.ErrorAs(t, got.err, &failure) | |
| 722 | + | require.Equal(t, ports.BrokerErrorOutcomeUnknown, failure.Code, | |
| 723 | + | "a result that cannot be applied must never look like a definite outcome") | |
| 724 | + | require.False(t, got.removed) | |
| 725 | + | require.Equal(t, domain.RemoteRegistration{}, got.registration) | |
| 726 | + | require.Equal(t, 1, g.sendCount(), "an invalid result must never provoke a replay") | |
| 727 | + | require.Empty(t, pendingOperations(c)) | |
| 728 | + | }) | |
| 729 | + | } | |
| 730 | + | } | |
| 731 | + | ||
| 732 | + | // scriptedTransport is a client carriage whose single inbound frame can be | |
| 733 | + | // injected: the reader goroutine consumes it and then blocks until the test | |
| 734 | + | // closes the carriage. It lets a test drive the client's real run loop with one | |
| 735 | + | // malformed frame and observe how a pending mutation is settled. | |
| 736 | + | type scriptedTransport struct { | |
| 737 | + | ready chan wire.Envelope | |
| 738 | + | done chan struct{} | |
| 739 | + | once sync.Once | |
| 740 | + | ||
| 741 | + | mu sync.Mutex | |
| 742 | + | sends []wire.Envelope | |
| 743 | + | } | |
| 744 | + | ||
| 745 | + | func newScriptedTransport() *scriptedTransport { | |
| 746 | + | return &scriptedTransport{ready: make(chan wire.Envelope, 1), done: make(chan struct{})} | |
| 747 | + | } | |
| 748 | + | ||
| 749 | + | func (t *scriptedTransport) deliver(envelope wire.Envelope) { t.ready <- envelope } | |
| 750 | + | ||
| 751 | + | func (t *scriptedTransport) Send(envelope wire.Envelope) error { | |
| 752 | + | t.mu.Lock() | |
| 753 | + | t.sends = append(t.sends, envelope) | |
| 754 | + | t.mu.Unlock() | |
| 755 | + | return nil | |
| 756 | + | } | |
| 757 | + | ||
| 758 | + | func (t *scriptedTransport) RecvBounded(uint64) (wire.Envelope, error) { | |
| 759 | + | select { | |
| 760 | + | case envelope := <-t.ready: | |
| 761 | + | return envelope, nil | |
| 762 | + | case <-t.done: | |
| 763 | + | return wire.Envelope{}, io.EOF | |
| 764 | + | } | |
| 765 | + | } | |
| 766 | + | ||
| 767 | + | func (t *scriptedTransport) Recv() (wire.Envelope, error) { return t.RecvBounded(0) } | |
| 768 | + | ||
| 769 | + | func (t *scriptedTransport) Close() error { | |
| 770 | + | t.once.Do(func() { close(t.done) }) | |
| 771 | + | return nil | |
| 772 | + | } | |
| 773 | + | ||
| 774 | + | func (t *scriptedTransport) sendCount() int { | |
| 775 | + | t.mu.Lock() | |
| 776 | + | defer t.mu.Unlock() | |
| 777 | + | return len(t.sends) | |
| 778 | + | } | |
| 779 | + | ||
| 780 | + | // TestMalformedResultFrameSettlesPendingMutationAsUnknown proves a malformed | |
| 781 | + | // server frame the reader refuses settles the connection and is reported to a | |
| 782 | + | // pending mutation as outcome-unknown, never as a definite failure: the request | |
| 783 | + | // was already sent, so the caller must refresh rather than replay it. | |
| 784 | + | func TestMalformedResultFrameSettlesPendingMutationAsUnknown(t *testing.T) { | |
| 785 | + | transport := newScriptedTransport() | |
| 786 | + | scope := brokerwire.Scope{ | |
| 787 | + | Epoch: ports.BrokerEpoch(0x72), | |
| 788 | + | Connection: ports.BrokerConnectionID{0x72, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01}, | |
| 789 | + | } | |
| 790 | + | conn, err := brokerwire.NewConnection(scope) | |
| 791 | + | require.NoError(t, err) | |
| 792 | + | require.NoError(t, conn.SignalPreamble()) | |
| 793 | + | require.NoError(t, conn.Register()) | |
| 794 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 795 | + | defer cancel() | |
| 796 | + | c := &client{ | |
| 797 | + | transport: transport, | |
| 798 | + | ceilings: brokerwire.DefaultCeilings(), | |
| 799 | + | scope: scope, | |
| 800 | + | conn: conn, | |
| 801 | + | cfg: Config{}.withDefaults(), | |
| 802 | + | ctx: ctx, | |
| 803 | + | cancel: cancel, | |
| 804 | + | pending: make(map[ports.BrokerOperationID]chan operationResult), | |
| 805 | + | kinds: make(map[ports.BrokerOperationID]brokerwire.RegisterMutationKind), | |
| 806 | + | streams: make(map[ports.BrokerStreamID]*clientStream), | |
| 807 | + | done: make(chan struct{}), | |
| 808 | + | } | |
| 809 | + | t.Cleanup(func() { _ = c.Close() }) | |
| 810 | + | go c.run() | |
| 811 | + | ||
| 812 | + | results := make(chan mutationOutcome, 1) | |
| 813 | + | go func() { | |
| 814 | + | removed, err := c.RemoveHost(context.Background(), mutationRegistration("known")) | |
| 815 | + | results <- mutationOutcome{removed: removed, err: err} | |
| 816 | + | }() | |
| 817 | + | require.Eventually(t, func() bool { return transport.sendCount() == 1 }, 5*time.Second, time.Millisecond, | |
| 818 | + | "the mutation request must be launched before the malformed frame arrives") | |
| 819 | + | c.mu.Lock() | |
| 820 | + | pending := len(c.pending) | |
| 821 | + | c.mu.Unlock() | |
| 822 | + | require.Equal(t, 1, pending, "the mutation must still be pending when the malformed frame arrives") | |
| 823 | + | ||
| 824 | + | // One frame the strict scanner refuses: a payload that is not a valid server | |
| 825 | + | // envelope. The reader settles the connection on it. | |
| 826 | + | transport.deliver(wire.Envelope{Payload: []byte{0xFF, 0xFF, 0xFF}}) | |
| 827 | + | ||
| 828 | + | got := awaitResult(t, results) | |
| 829 | + | var failure ports.BrokerError | |
| 830 | + | require.ErrorAs(t, got.err, &failure) | |
| 831 | + | require.Equal(t, ports.BrokerErrorOutcomeUnknown, failure.Code, | |
| 832 | + | "a malformed frame after dispatch must never look like a definite outcome") | |
| 833 | + | require.False(t, got.removed) | |
| 834 | + | require.Equal(t, domain.RemoteRegistration{}, got.registration) | |
| 835 | + | require.Equal(t, 1, transport.sendCount(), "the request must have travelled exactly once") | |
| 836 | + | select { | |
| 837 | + | case <-c.Done(): | |
| 838 | + | case <-time.After(5 * time.Second): | |
| 839 | + | t.Fatal("the malformed frame must settle the connection") | |
| 840 | + | } | |
| 841 | + | require.ErrorIs(t, c.Err(), ErrMalformedFrame) | |
| 842 | + | } | |
| 843 | + | ||
| 462 | 844 | // TestSendContextPreflightClassification pins the private helper's taxonomy | |
| 463 | 845 | // directly: an unencodable frame, an already-cancelled caller, and an | |
| 464 | 846 | // already-terminal connection are definitely not dispatched; only a launched | |
| @@ -478,7 +860,7 @@ func TestSendContextPreflightClassification(t *testing.T) { | |||
| 478 | 860 | cancel() | |
| 479 | 861 | attempted, err := c.sendContext(ctx, brokerwire.AddHost{ | |
| 480 | 862 | Epoch: c.scope.Epoch, Connection: c.scope.Connection, | |
| 481 | - | Operation: ports.BrokerOperationID{0x01}, Endpoint: "pre@host:22", | |
| 863 | + | Operation: ports.BrokerOperationID{0x01}, Endpoint: "pre@host:22", Policy: testPolicy(), | |
| 482 | 864 | }) | |
| 483 | 865 | require.False(t, attempted) | |
| 484 | 866 | require.ErrorIs(t, err, context.Canceled) | |
| @@ -490,7 +872,7 @@ func TestSendContextPreflightClassification(t *testing.T) { | |||
| 490 | 872 | require.NoError(t, c.Close()) | |
| 491 | 873 | attempted, err := c.sendContext(context.Background(), brokerwire.AddHost{ | |
| 492 | 874 | Epoch: c.scope.Epoch, Connection: c.scope.Connection, | |
| 493 | - | Operation: ports.BrokerOperationID{0x01}, Endpoint: "pre@host:22", | |
| 875 | + | Operation: ports.BrokerOperationID{0x01}, Endpoint: "pre@host:22", Policy: testPolicy(), | |
| 494 | 876 | }) | |
| 495 | 877 | require.False(t, attempted) | |
| 496 | 878 | require.ErrorIs(t, err, ErrConnectionClosed) | |
| @@ -507,7 +889,7 @@ func TestSendContextPreflightClassification(t *testing.T) { | |||
| 507 | 889 | go func() { | |
| 508 | 890 | attempted, err := c.sendContext(context.Background(), brokerwire.RemoveHost{ | |
| 509 | 891 | Epoch: c.scope.Epoch, Connection: c.scope.Connection, | |
| 510 | - | Operation: ports.BrokerOperationID{0x02}, Endpoint: "known", | |
| 892 | + | Operation: ports.BrokerOperationID{0x02}, Registration: mutationRegistration("known"), | |
| 511 | 893 | }) | |
| 512 | 894 | done <- sendResult{attempted: attempted, err: err} | |
| 513 | 895 | }() | |
M internal/adapters/brokeripc/helpers_test.go +244 -16
| @@ -3,6 +3,7 @@ package brokeripc | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/binary" | |
| 6 | + | "errors" | |
| 6 | 7 | "fmt" | |
| 7 | 8 | "io" | |
| 8 | 9 | "os" | |
| @@ -45,6 +46,81 @@ func testPolicy() ports.BrokerPolicy { | |||
| 45 | 46 | } | |
| 46 | 47 | } | |
| 47 | 48 | ||
| 49 | + | // membershipProbeEndpoint is the endpoint every liveness probe adds through the | |
| 50 | + | // real membership delegation path. | |
| 51 | + | const membershipProbeEndpoint = "probe@host:22" | |
| 52 | + | ||
| 53 | + | // requireConnectionDelegates proves one connection still admits a full | |
| 54 | + | // membership round trip after a stream-local or subscriber-local event: the | |
| 55 | + | // probe adds one host through the same delegated path a real caller uses and | |
| 56 | + | // asserts the registration comes back for exactly the requested endpoint. | |
| 57 | + | func requireConnectionDelegates(t *testing.T, service ports.BrokerService) { | |
| 58 | + | t.Helper() | |
| 59 | + | require.NoError(t, connectionDelegates(context.Background(), service)) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // connectionDelegates is the error-returning form of requireConnectionDelegates, | |
| 63 | + | // safe to call from a test goroutine where FailNow is not. | |
| 64 | + | func connectionDelegates(ctx context.Context, service ports.BrokerService) error { | |
| 65 | + | ctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 66 | + | defer cancel() | |
| 67 | + | registration, err := service.AddHost(ctx, membershipProbeEndpoint, testPolicy()) | |
| 68 | + | if err != nil { | |
| 69 | + | return err | |
| 70 | + | } | |
| 71 | + | if registration.Endpoint != membershipProbeEndpoint { | |
| 72 | + | return fmt.Errorf("AddHost answered registration for %q, want %q", registration.Endpoint, membershipProbeEndpoint) | |
| 73 | + | } | |
| 74 | + | return nil | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // recvWithin reads one server message under an explicit bound, so a session that | |
| 78 | + | // never answers settles as a bounded test failure instead of hanging the package | |
| 79 | + | // on a read that can never complete. | |
| 80 | + | func (r *rawCarriage) recvWithin(t *testing.T, timeout time.Duration) brokerwire.ServerMessage { | |
| 81 | + | t.Helper() | |
| 82 | + | type result struct { | |
| 83 | + | message brokerwire.ServerMessage | |
| 84 | + | err error | |
| 85 | + | } | |
| 86 | + | done := make(chan result, 1) | |
| 87 | + | go func() { | |
| 88 | + | envelope, err := r.transport.RecvBounded(r.ceilings.MaxReceiveEnvelopeBytes) | |
| 89 | + | if err != nil { | |
| 90 | + | done <- result{nil, err} | |
| 91 | + | return | |
| 92 | + | } | |
| 93 | + | message, err := brokerwire.DecodeServer(envelope.Payload, r.ceilings.MaxReceiveEnvelopeBytes, r.ceilings.StreamChunkLimit) | |
| 94 | + | done <- result{message, err} | |
| 95 | + | }() | |
| 96 | + | select { | |
| 97 | + | case got := <-done: | |
| 98 | + | require.NoError(t, got.err, "the broker must answer with a decodable server frame") | |
| 99 | + | return got.message | |
| 100 | + | case <-time.After(timeout): | |
| 101 | + | t.Fatal("the broker did not answer within the bound") | |
| 102 | + | return nil | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // awaitReadError proves one raw read fails under an explicit bound, so a session | |
| 107 | + | // that never settles is a bounded test failure rather than a hang. | |
| 108 | + | func (r *rawCarriage) awaitReadError(t *testing.T, timeout time.Duration) error { | |
| 109 | + | t.Helper() | |
| 110 | + | done := make(chan error, 1) | |
| 111 | + | go func() { | |
| 112 | + | _, err := r.transport.RecvBounded(r.ceilings.MaxReceiveEnvelopeBytes) | |
| 113 | + | done <- err | |
| 114 | + | }() | |
| 115 | + | select { | |
| 116 | + | case err := <-done: | |
| 117 | + | return err | |
| 118 | + | case <-time.After(timeout): | |
| 119 | + | t.Fatal("the connection must settle within the bound") | |
| 120 | + | return nil | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 48 | 124 | // testDaemonAt builds one catalogue-valid remote daemon projection for index i. | |
| 49 | 125 | func testDaemonAt(i int) ports.BrokerDaemonObservation { | |
| 50 | 126 | endpoint := fmt.Sprintf("user%d@host%d:22", i, i) | |
| @@ -238,6 +314,32 @@ func (c *fakeLogicalConn) fail(err error) { | |||
| 238 | 314 | _ = c.Close() | |
| 239 | 315 | } | |
| 240 | 316 | ||
| 317 | + | // addHostCall records one delegated membership addition: the exact arguments | |
| 318 | + | // the admitted core received and the exact result it returned. Recording both | |
| 319 | + | // is what lets a test prove the adapter delegated once with the caller's own | |
| 320 | + | // authority instead of re-deriving a policy or registration on the way. | |
| 321 | + | type addHostCall struct { | |
| 322 | + | Endpoint string | |
| 323 | + | Policy ports.BrokerPolicy | |
| 324 | + | Result domain.RemoteRegistration | |
| 325 | + | Err error | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | // removeHostCall records one delegated exact-registration removal. | |
| 329 | + | type removeHostCall struct { | |
| 330 | + | Expected domain.RemoteRegistration | |
| 331 | + | Removed bool | |
| 332 | + | Err error | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | // updateHostPolicyCall records one delegated exact-registration policy update. | |
| 336 | + | type updateHostPolicyCall struct { | |
| 337 | + | Expected domain.RemoteRegistration | |
| 338 | + | Policy ports.BrokerPolicy | |
| 339 | + | Result domain.RemoteRegistration | |
| 340 | + | Err error | |
| 341 | + | } | |
| 342 | + | ||
| 241 | 343 | // fakeCore is one admitted per-connection broker service. | |
| 242 | 344 | type fakeCore struct { | |
| 243 | 345 | mu sync.Mutex | |
| @@ -246,14 +348,25 @@ type fakeCore struct { | |||
| 246 | 348 | streams map[ports.BrokerStreamID]*fakeLogicalConn | |
| 247 | 349 | opens []ports.BrokerOpenStreamRequest | |
| 248 | 350 | closedIDs []ports.BrokerStreamID | |
| 249 | - | added []string | |
| 250 | - | removed []string | |
| 351 | + | added []addHostCall | |
| 352 | + | removed []removeHostCall | |
| 353 | + | updated []updateHostPolicyCall | |
| 251 | 354 | reconcile []string | |
| 252 | 355 | openErr error | |
| 253 | - | blockAdd chan struct{} | |
| 254 | 356 | closed bool | |
| 255 | 357 | done chan struct{} | |
| 256 | 358 | once sync.Once | |
| 359 | + | ||
| 360 | + | // Membership result hooks. Their zero values keep the deterministic | |
| 361 | + | // defaults that mirror the real registry: a fresh generation-1 registration | |
| 362 | + | // for a new endpoint, a generation advanced by exactly one for a policy | |
| 363 | + | // update, and removal reported from removeRemoved. | |
| 364 | + | addErr error | |
| 365 | + | removeErr error | |
| 366 | + | updateErr error | |
| 367 | + | addResult *domain.RemoteRegistration | |
| 368 | + | updateResult *domain.RemoteRegistration | |
| 369 | + | removeRemoved bool | |
| 257 | 370 | } | |
| 258 | 371 | ||
| 259 | 372 | func (c *fakeCore) ConnectionID() ports.BrokerConnectionID { return c.id } | |
| @@ -298,31 +411,146 @@ func (c *fakeCore) CloseStream(connection ports.BrokerConnectionID, stream ports | |||
| 298 | 411 | return conn.Close() | |
| 299 | 412 | } | |
| 300 | 413 | ||
| 301 | - | func (c *fakeCore) AddHost(ctx context.Context, target string) error { | |
| 302 | - | if c.blockAdd != nil { | |
| 303 | - | select { | |
| 304 | - | case <-c.blockAdd: | |
| 305 | - | case <-ctx.Done(): | |
| 306 | - | return ctx.Err() | |
| 414 | + | // mapMembershipError mirrors the admitted core's own mapping of a registry | |
| 415 | + | // refusal onto the closed broker error taxonomy. The real broker.Service already | |
| 416 | + | // returns these typed codes, so the fake applies the same rule: a caller of the | |
| 417 | + | // adapter must never see a bare registry sentinel as if it were an untyped | |
| 418 | + | // failure. | |
| 419 | + | func mapMembershipError(err error) error { | |
| 420 | + | if err == nil { | |
| 421 | + | return nil | |
| 422 | + | } | |
| 423 | + | code := ports.BrokerErrorCode(0) | |
| 424 | + | switch { | |
| 425 | + | case errors.Is(err, ports.ErrBrokerHostConflict): | |
| 426 | + | code = ports.BrokerErrorHostConflict | |
| 427 | + | case errors.Is(err, ports.ErrBrokerMembershipImmutable): | |
| 428 | + | code = ports.BrokerErrorMembershipImmutable | |
| 429 | + | default: | |
| 430 | + | var unknown ports.BrokerStoreOutcomeUnknownError | |
| 431 | + | var unknownPointer *ports.BrokerStoreOutcomeUnknownError | |
| 432 | + | if errors.As(err, &unknown) || errors.As(err, &unknownPointer) { | |
| 433 | + | code = ports.BrokerErrorOutcomeUnknown | |
| 307 | 434 | } | |
| 308 | 435 | } | |
| 309 | - | if err := ctx.Err(); err != nil { | |
| 436 | + | if code == 0 { | |
| 310 | 437 | return err | |
| 311 | 438 | } | |
| 439 | + | return ports.BrokerError{Code: code, Cause: err} | |
| 440 | + | } | |
| 441 | + | ||
| 442 | + | // AddHost records one delegated membership addition and returns its exact | |
| 443 | + | // result. The default is a fresh generation-1 registration for the requested | |
| 444 | + | // endpoint, exactly as the real registry mints one for a new endpoint. | |
| 445 | + | func (c *fakeCore) AddHost(ctx context.Context, endpoint string, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) { | |
| 446 | + | if err := ctx.Err(); err != nil { | |
| 447 | + | return domain.RemoteRegistration{}, err | |
| 448 | + | } | |
| 312 | 449 | c.mu.Lock() | |
| 313 | - | c.added = append(c.added, target) | |
| 314 | - | c.mu.Unlock() | |
| 315 | - | return nil | |
| 450 | + | defer c.mu.Unlock() | |
| 451 | + | registration := domain.RemoteRegistration{} | |
| 452 | + | var err error | |
| 453 | + | switch { | |
| 454 | + | case c.addErr != nil: | |
| 455 | + | err = mapMembershipError(c.addErr) | |
| 456 | + | c.addErr = nil | |
| 457 | + | case c.addResult != nil: | |
| 458 | + | registration = *c.addResult | |
| 459 | + | default: | |
| 460 | + | registration, err = domain.NewRemoteRegistration(endpoint, [16]byte{0x01}) | |
| 461 | + | } | |
| 462 | + | c.added = append(c.added, addHostCall{Endpoint: endpoint, Policy: policy, Result: registration, Err: err}) | |
| 463 | + | return registration, err | |
| 316 | 464 | } | |
| 317 | 465 | ||
| 318 | - | func (c *fakeCore) RemoveHost(ctx context.Context, target string) (bool, error) { | |
| 466 | + | // RemoveHost records one delegated exact-registration removal and reports | |
| 467 | + | // whether the host is present. The fake owns no membership state, so the | |
| 468 | + | // answer is the configured removeRemoved value for every exact registration. | |
| 469 | + | func (c *fakeCore) RemoveHost(ctx context.Context, expected domain.RemoteRegistration) (bool, error) { | |
| 319 | 470 | if err := ctx.Err(); err != nil { | |
| 320 | 471 | return false, err | |
| 321 | 472 | } | |
| 322 | 473 | c.mu.Lock() | |
| 323 | - | c.removed = append(c.removed, target) | |
| 474 | + | defer c.mu.Unlock() | |
| 475 | + | removed := false | |
| 476 | + | var err error | |
| 477 | + | if c.removeErr != nil { | |
| 478 | + | err = mapMembershipError(c.removeErr) | |
| 479 | + | c.removeErr = nil | |
| 480 | + | } else { | |
| 481 | + | removed = c.removeRemoved | |
| 482 | + | } | |
| 483 | + | c.removed = append(c.removed, removeHostCall{Expected: expected, Removed: removed, Err: err}) | |
| 484 | + | return removed, err | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | // UpdateHostPolicy records one delegated exact-registration policy update. The | |
| 488 | + | // default advances the generation by exactly one, mirroring the real registry's | |
| 489 | + | // replacement-policy path, and preserves the expected incarnation. | |
| 490 | + | func (c *fakeCore) UpdateHostPolicy(ctx context.Context, expected domain.RemoteRegistration, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) { | |
| 491 | + | if err := ctx.Err(); err != nil { | |
| 492 | + | return domain.RemoteRegistration{}, err | |
| 493 | + | } | |
| 494 | + | c.mu.Lock() | |
| 495 | + | defer c.mu.Unlock() | |
| 496 | + | registration := domain.RemoteRegistration{} | |
| 497 | + | var err error | |
| 498 | + | switch { | |
| 499 | + | case c.updateErr != nil: | |
| 500 | + | err = mapMembershipError(c.updateErr) | |
| 501 | + | c.updateErr = nil | |
| 502 | + | case c.updateResult != nil: | |
| 503 | + | registration = *c.updateResult | |
| 504 | + | default: | |
| 505 | + | registration = expected | |
| 506 | + | if registration.Generation != ^domain.RemoteGeneration(0) { | |
| 507 | + | registration.Generation++ | |
| 508 | + | } | |
| 509 | + | } | |
| 510 | + | c.updated = append(c.updated, updateHostPolicyCall{Expected: expected, Policy: policy, Result: registration, Err: err}) | |
| 511 | + | return registration, err | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | // addCalls snapshots the delegated additions. | |
| 515 | + | func (c *fakeCore) addCalls() []addHostCall { | |
| 516 | + | c.mu.Lock() | |
| 517 | + | defer c.mu.Unlock() | |
| 518 | + | return append([]addHostCall(nil), c.added...) | |
| 519 | + | } | |
| 520 | + | ||
| 521 | + | // removeCalls snapshots the delegated removals. | |
| 522 | + | func (c *fakeCore) removeCalls() []removeHostCall { | |
| 523 | + | c.mu.Lock() | |
| 524 | + | defer c.mu.Unlock() | |
| 525 | + | return append([]removeHostCall(nil), c.removed...) | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | // updateCalls snapshots the delegated policy updates. | |
| 529 | + | func (c *fakeCore) updateCalls() []updateHostPolicyCall { | |
| 530 | + | c.mu.Lock() | |
| 531 | + | defer c.mu.Unlock() | |
| 532 | + | return append([]updateHostPolicyCall(nil), c.updated...) | |
| 533 | + | } | |
| 534 | + | ||
| 535 | + | // refuseAddHost makes the next delegated addition fail with err. The refusal is | |
| 536 | + | // one-shot: the fake owns no membership state, so a later call must be free to | |
| 537 | + | // succeed rather than pin the whole connection as immutable forever. | |
| 538 | + | func (c *fakeCore) refuseAddHost(err error) { c.mu.Lock(); c.addErr = err; c.mu.Unlock() } | |
| 539 | + | ||
| 540 | + | // refuseRemoveHost makes the next delegated removal fail with err, one-shot. | |
| 541 | + | func (c *fakeCore) refuseRemoveHost(err error) { c.mu.Lock(); c.removeErr = err; c.mu.Unlock() } | |
| 542 | + | ||
| 543 | + | // reportRemoved configures the answer a delegated removal returns. | |
| 544 | + | func (c *fakeCore) reportRemoved(removed bool) { c.mu.Lock(); c.removeRemoved = removed; c.mu.Unlock() } | |
| 545 | + | ||
| 546 | + | // zeroAddRegistration makes a delegated addition succeed without returning any | |
| 547 | + | // registration authority, so a test can prove the server never publishes an | |
| 548 | + | // addition success that cannot be fenced. | |
| 549 | + | func (c *fakeCore) zeroAddRegistration() { | |
| 550 | + | c.mu.Lock() | |
| 551 | + | zero := domain.RemoteRegistration{} | |
| 552 | + | c.addResult = &zero | |
| 324 | 553 | c.mu.Unlock() | |
| 325 | - | return target == "known", nil | |
| 326 | 554 | } | |
| 327 | 555 | ||
| 328 | 556 | func (c *fakeCore) RequestReconcile(endpoint string) { | |
M internal/adapters/brokeripc/listen_test.go +2 -2
| @@ -453,8 +453,8 @@ func TestConcurrentDialAndClose(t *testing.T) { | |||
| 453 | 453 | t.Errorf("Dial: %v", err) | |
| 454 | 454 | return | |
| 455 | 455 | } | |
| 456 | - | if err := client.AddHost(ctx, "late@host:22"); err != nil { | |
| 457 | - | t.Errorf("AddHost: %v", err) | |
| 456 | + | if err := connectionDelegates(ctx, client); err != nil { | |
| 457 | + | t.Errorf("connection refused a membership round trip: %v", err) | |
| 458 | 458 | } | |
| 459 | 459 | _ = client.Close() | |
| 460 | 460 | }() | |
M internal/adapters/brokeripc/mutation.go +18 -5
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/vev/internal/adapters/brokerwire" | |
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | 9 | "github.com/bnema/vev/internal/ports" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| @@ -150,7 +151,7 @@ func (s *serverSession) wakePublisher() { | |||
| 150 | 151 | // bound and runs it in its own goroutine. A duplicate admission is dropped: | |
| 151 | 152 | // exactly one completion ever travels for one operation identity, and the peer's | |
| 152 | 153 | // own tracker already fences replays. | |
| 153 | - | func (s *serverSession) startMutation(operation ports.BrokerOperationID, run func(context.Context) (bool, error)) error { | |
| 154 | + | func (s *serverSession) startMutation(operation ports.BrokerOperationID, kind brokerwire.RegisterMutationKind, run func(context.Context) (domain.RemoteRegistration, bool, error)) error { | |
| 154 | 155 | if err := s.conn.AdmitOperation(operation); err != nil { | |
| 155 | 156 | switch { | |
| 156 | 157 | case errors.Is(err, brokerwire.ErrOperationCompleted), errors.Is(err, brokerwire.ErrOperationPending): | |
| @@ -165,21 +166,33 @@ func (s *serverSession) startMutation(operation ports.BrokerOperationID, run fun | |||
| 165 | 166 | s.wg.Add(1) | |
| 166 | 167 | go func() { | |
| 167 | 168 | defer s.wg.Done() | |
| 168 | - | removed, err := run(s.ctx) | |
| 169 | + | registration, removed, err := run(s.ctx) | |
| 169 | 170 | outcome := ports.BrokerOutcomeOK | |
| 170 | 171 | if err != nil { | |
| 171 | 172 | outcome = ports.BrokerOutcomeFailed | |
| 173 | + | var unknown ports.BrokerStoreOutcomeUnknownError | |
| 174 | + | var unknownPointer *ports.BrokerStoreOutcomeUnknownError | |
| 175 | + | if errors.As(err, &unknown) || errors.As(err, &unknownPointer) { | |
| 176 | + | outcome = ports.BrokerOutcomeUnknown | |
| 177 | + | } | |
| 178 | + | registration = domain.RemoteRegistration{} | |
| 179 | + | removed = false | |
| 172 | 180 | } | |
| 173 | 181 | _ = s.conn.CompleteOperation(operation, outcome) | |
| 174 | 182 | if s.ctx.Err() != nil { | |
| 175 | 183 | return | |
| 176 | 184 | } | |
| 177 | 185 | detail := errorDetail(err) | |
| 178 | - | if err := s.send(brokerwire.OperationResult{ | |
| 186 | + | result := brokerwire.OperationResult{ | |
| 179 | 187 | Epoch: s.epoch, Connection: s.scope.Connection, Operation: operation, | |
| 180 | - | Outcome: outcome, Removed: removed, | |
| 188 | + | Outcome: outcome, Removed: removed, Registration: registration, | |
| 181 | 189 | Error: detail, HasError: detail != (brokerwire.ErrorDetail{}), | |
| 182 | - | }); err != nil { | |
| 190 | + | } | |
| 191 | + | if err := result.ValidateForMutation(kind); err != nil { | |
| 192 | + | s.abort(errors.Join(ErrProtocol, err)) | |
| 193 | + | return | |
| 194 | + | } | |
| 195 | + | if err := s.send(result); err != nil { | |
| 183 | 196 | s.abort(err) | |
| 184 | 197 | } | |
| 185 | 198 | }() | |
M internal/adapters/brokeripc/protocol_test.go +6 -12
| @@ -43,14 +43,11 @@ func (r *rawCarriage) send(t *testing.T, message brokerwire.ClientMessage) { | |||
| 43 | 43 | require.NoError(t, r.transport.Send(wire.Envelope{Payload: payload})) | |
| 44 | 44 | } | |
| 45 | 45 | ||
| 46 | - | // recv reads and decodes one server message. | |
| 46 | + | // recv reads and decodes one server message under the standard test bound, so a | |
| 47 | + | // broken session fails as a bounded test error instead of hanging the package. | |
| 47 | 48 | func (r *rawCarriage) recv(t *testing.T) brokerwire.ServerMessage { | |
| 48 | 49 | t.Helper() | |
| 49 | - | envelope, err := r.transport.RecvBounded(r.ceilings.MaxReceiveEnvelopeBytes) | |
| 50 | - | require.NoError(t, err) | |
| 51 | - | message, err := brokerwire.DecodeServer(envelope.Payload, r.ceilings.MaxReceiveEnvelopeBytes, r.ceilings.StreamChunkLimit) | |
| 52 | - | require.NoError(t, err) | |
| 53 | - | return message | |
| 50 | + | return r.recvWithin(t, 5*time.Second) | |
| 54 | 51 | } | |
| 55 | 52 | ||
| 56 | 53 | // register completes the registration exchange and returns the assigned scope. | |
| @@ -127,8 +124,7 @@ func TestWrongDirectionFrameClosesConnection(t *testing.T) { | |||
| 127 | 124 | require.NoError(t, err) | |
| 128 | 125 | require.NoError(t, raw.transport.Send(wire.Envelope{Payload: payload})) | |
| 129 | 126 | ||
| 130 | - | _, err = raw.transport.RecvBounded(raw.ceilings.MaxReceiveEnvelopeBytes) | |
| 131 | - | require.Error(t, err, "a wrong-direction frame must settle the connection") | |
| 127 | + | require.Error(t, raw.awaitReadError(t, 5*time.Second), "a wrong-direction frame must settle the connection") | |
| 132 | 128 | ||
| 133 | 129 | // The listener keeps serving other clients. | |
| 134 | 130 | client, err := Dial(context.Background(), e.path, Config{HandshakeTimeout: time.Second}) | |
| @@ -144,8 +140,7 @@ func TestMalformedApplicationFrameClosesConnection(t *testing.T) { | |||
| 144 | 140 | raw.register(t) | |
| 145 | 141 | ||
| 146 | 142 | require.NoError(t, raw.transport.Send(wire.Envelope{Payload: []byte{0x78, 0x01}})) | |
| 147 | - | _, err := raw.transport.RecvBounded(raw.ceilings.MaxReceiveEnvelopeBytes) | |
| 148 | - | require.Error(t, err) | |
| 143 | + | require.Error(t, raw.awaitReadError(t, 5*time.Second)) | |
| 149 | 144 | ||
| 150 | 145 | client, err := Dial(context.Background(), e.path, Config{HandshakeTimeout: time.Second}) | |
| 151 | 146 | require.NoError(t, err) | |
| @@ -214,8 +209,7 @@ func TestDuplicateRegisterClosesConnection(t *testing.T) { | |||
| 214 | 209 | raw.register(t) | |
| 215 | 210 | ||
| 216 | 211 | raw.send(t, brokerwire.Register{}) | |
| 217 | - | _, err := raw.transport.RecvBounded(raw.ceilings.MaxReceiveEnvelopeBytes) | |
| 218 | - | require.Error(t, err) | |
| 212 | + | require.Error(t, raw.awaitReadError(t, 5*time.Second)) | |
| 219 | 213 | } | |
| 220 | 214 | ||
| 221 | 215 | // TestMismatchedScopeIsFenced proves a frame carrying a connection identity that | |
M internal/adapters/brokeripc/server.go +24 -9
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | 14 | ||
| 15 | 15 | "github.com/bnema/vev/internal/adapters/brokerwire" | |
| 16 | 16 | "github.com/bnema/vev/internal/adapters/ipc" | |
| 17 | + | "github.com/bnema/vev/internal/domain" | |
| 17 | 18 | "github.com/bnema/vev/internal/ports" | |
| 18 | 19 | "github.com/bnema/vev/internal/protocol/wire" | |
| 19 | 20 | ) | |
| @@ -705,16 +706,25 @@ func (s *serverSession) dispatch(message brokerwire.ClientMessage) error { | |||
| 705 | 706 | if !s.scopeMatches(m.Epoch, m.Connection) { | |
| 706 | 707 | return s.refuseScope() | |
| 707 | 708 | } | |
| 708 | - | return s.startMutation(m.Operation, func(ctx context.Context) (bool, error) { | |
| 709 | - | return false, s.core.AddHost(ctx, m.Endpoint) | |
| 709 | + | return s.startMutation(m.Operation, brokerwire.MutationKindAddHost, func(ctx context.Context) (domain.RemoteRegistration, bool, error) { | |
| 710 | + | registration, err := s.core.AddHost(ctx, m.Endpoint, m.Policy) | |
| 711 | + | return registration, false, err | |
| 710 | 712 | }) | |
| 711 | 713 | case brokerwire.RemoveHost: | |
| 712 | 714 | if !s.scopeMatches(m.Epoch, m.Connection) { | |
| 713 | 715 | return s.refuseScope() | |
| 714 | 716 | } | |
| 715 | - | return s.startMutation(m.Operation, func(ctx context.Context) (bool, error) { | |
| 716 | - | removed, err := s.core.RemoveHost(ctx, m.Endpoint) | |
| 717 | - | return removed, err | |
| 717 | + | return s.startMutation(m.Operation, brokerwire.MutationKindRemoveHost, func(ctx context.Context) (domain.RemoteRegistration, bool, error) { | |
| 718 | + | removed, err := s.core.RemoveHost(ctx, m.Registration) | |
| 719 | + | return domain.RemoteRegistration{}, removed, err | |
| 720 | + | }) | |
| 721 | + | case brokerwire.UpdateHostPolicy: | |
| 722 | + | if !s.scopeMatches(m.Epoch, m.Connection) { | |
| 723 | + | return s.refuseScope() | |
| 724 | + | } | |
| 725 | + | return s.startMutation(m.Operation, brokerwire.MutationKindUpdateHostPolicy, func(ctx context.Context) (domain.RemoteRegistration, bool, error) { | |
| 726 | + | registration, err := s.core.UpdateHostPolicy(ctx, m.Registration, m.Policy) | |
| 727 | + | return registration, false, err | |
| 718 | 728 | }) | |
| 719 | 729 | case brokerwire.Reconcile: | |
| 720 | 730 | if !s.scopeMatches(m.Epoch, m.Connection) { | |
| @@ -941,13 +951,18 @@ func (s *serverSession) CloseStream(connection ports.BrokerConnectionID, stream | |||
| 941 | 951 | } | |
| 942 | 952 | ||
| 943 | 953 | // AddHost delegates one mutating operation to the admitted core service. | |
| 944 | - | func (s *serverSession) AddHost(ctx context.Context, target string) error { | |
| 945 | - | return s.core.AddHost(ctx, target) | |
| 954 | + | func (s *serverSession) AddHost(ctx context.Context, endpoint string, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) { | |
| 955 | + | return s.core.AddHost(ctx, endpoint, policy) | |
| 946 | 956 | } | |
| 947 | 957 | ||
| 948 | 958 | // RemoveHost delegates one mutating operation to the admitted core service. | |
| 949 | - | func (s *serverSession) RemoveHost(ctx context.Context, target string) (bool, error) { | |
| 950 | - | return s.core.RemoveHost(ctx, target) | |
| 959 | + | func (s *serverSession) RemoveHost(ctx context.Context, expected domain.RemoteRegistration) (bool, error) { | |
| 960 | + | return s.core.RemoveHost(ctx, expected) | |
| 961 | + | } | |
| 962 | + | ||
| 963 | + | // UpdateHostPolicy delegates one mutating operation to the admitted core service. | |
| 964 | + | func (s *serverSession) UpdateHostPolicy(ctx context.Context, expected domain.RemoteRegistration, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) { | |
| 965 | + | return s.core.UpdateHostPolicy(ctx, expected, policy) | |
| 951 | 966 | } | |
| 952 | 967 | ||
| 953 | 968 | // RequestReconcile delegates one reconcile hint to the admitted core service. | |
M internal/adapters/brokeripc/session_test.go +253 -35
| @@ -362,9 +362,12 @@ func TestOpenStreamRefusedByCoreReachesClient(t *testing.T) { | |||
| 362 | 362 | require.ErrorAs(t, err, &failure) | |
| 363 | 363 | require.Equal(t, ports.BrokerErrorIncompatible, failure.Code) | |
| 364 | 364 | ||
| 365 | - | // The connection survives a stream-local refusal: a later operation still | |
| 366 | - | // completes. | |
| 367 | - | require.NoError(t, client.AddHost(ctx, "after@refusal:22")) | |
| 365 | + | // The connection survives a stream-local refusal: once the core admits | |
| 366 | + | // streams again, a later operation still completes. | |
| 367 | + | core.mu.Lock() | |
| 368 | + | core.openErr = nil | |
| 369 | + | core.mu.Unlock() | |
| 370 | + | requireConnectionDelegates(t, client) | |
| 368 | 371 | } | |
| 369 | 372 | ||
| 370 | 373 | // TestTerminalStreamFailureFailsOnlyThatStream proves one stream's terminal | |
| @@ -404,7 +407,7 @@ func TestTerminalStreamFailureFailsOnlyThatStream(t *testing.T) { | |||
| 404 | 407 | case <-time.After(5 * time.Second): | |
| 405 | 408 | t.Fatal("sibling stream stopped carrying traffic") | |
| 406 | 409 | } | |
| 407 | - | require.NoError(t, client.AddHost(ctx, "sibling@host:22")) | |
| 410 | + | requireConnectionDelegates(t, client) | |
| 408 | 411 | } | |
| 409 | 412 | ||
| 410 | 413 | // TestCloseStreamRetiresBothSides proves a client close reaches the broker core | |
| @@ -433,46 +436,263 @@ func TestCloseStreamRetiresBothSides(t *testing.T) { | |||
| 433 | 436 | require.NoError(t, stream.Close(), "closing an already retired stream is idempotent") | |
| 434 | 437 | } | |
| 435 | 438 | ||
| 436 | - | // TestMutatingOperationsComplete proves AddHost and RemoveHost complete with | |
| 437 | - | // typed results and reach the admitted core service. | |
| 438 | - | func TestMutatingOperationsComplete(t *testing.T) { | |
| 439 | + | // TestMembershipMutationsDelegateEndToEnd proves the whole membership contract | |
| 440 | + | // travels the real AF_UNIX carriage in both directions: AddHost returns the | |
| 441 | + | // registration the admitted core minted, UpdateHostPolicy returns the | |
| 442 | + | // generation-advanced registration for the exact authority the caller named, and | |
| 443 | + | // RemoveHost returns the core's exact bool. Each call delegates exactly once with | |
| 444 | + | // the caller's own endpoint, policy, and exact registration, so the adapter never | |
| 445 | + | // re-derives authority on the way. | |
| 446 | + | func TestMembershipMutationsDelegateEndToEnd(t *testing.T) { | |
| 439 | 447 | e := startEndpoint(t, Config{}) | |
| 440 | 448 | client, _ := e.pair() | |
| 441 | 449 | core := e.authority.last() | |
| 442 | 450 | ||
| 443 | 451 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 444 | 452 | defer cancel() | |
| 445 | - | require.NoError(t, client.AddHost(ctx, "new@host:22")) | |
| 446 | - | removed, err := client.RemoveHost(ctx, "known") | |
| 453 | + | ||
| 454 | + | added, err := client.AddHost(ctx, "new@host:22", testPolicy()) | |
| 447 | 455 | require.NoError(t, err) | |
| 448 | - | require.True(t, removed) | |
| 449 | - | removed, err = client.RemoveHost(ctx, "missing") | |
| 456 | + | require.Equal(t, "new@host:22", added.Endpoint) | |
| 457 | + | require.NotZero(t, added.Generation, "an addition answer is fenced authority") | |
| 458 | + | require.NoError(t, added.Validate()) | |
| 459 | + | ||
| 460 | + | updated, err := client.UpdateHostPolicy(ctx, added, testPolicy()) | |
| 450 | 461 | require.NoError(t, err) | |
| 451 | - | require.False(t, removed) | |
| 462 | + | require.Equal(t, added.Endpoint, updated.Endpoint) | |
| 463 | + | require.Equal(t, added.Incarnation, updated.Incarnation, "a policy update preserves identity") | |
| 464 | + | require.Equal(t, added.Generation+1, updated.Generation, | |
| 465 | + | "a policy update returns a registration advanced by exactly one generation") | |
| 452 | 466 | ||
| 453 | - | core.mu.Lock() | |
| 454 | - | defer core.mu.Unlock() | |
| 455 | - | require.Equal(t, []string{"new@host:22"}, core.added) | |
| 456 | - | require.Equal(t, []string{"known", "missing"}, core.removed) | |
| 467 | + | core.reportRemoved(true) | |
| 468 | + | removed, err := client.RemoveHost(ctx, updated) | |
| 469 | + | require.NoError(t, err) | |
| 470 | + | require.True(t, removed, "a present exact registration must report removal") | |
| 471 | + | ||
| 472 | + | core.reportRemoved(false) | |
| 473 | + | removed, err = client.RemoveHost(ctx, updated) | |
| 474 | + | require.NoError(t, err) | |
| 475 | + | require.False(t, removed, "an absent exact registration is an idempotent no-op, not an error") | |
| 476 | + | ||
| 477 | + | adds := core.addCalls() | |
| 478 | + | require.Len(t, adds, 1, "one addition is exactly one delegation") | |
| 479 | + | require.Equal(t, "new@host:22", adds[0].Endpoint) | |
| 480 | + | require.Equal(t, testPolicy(), adds[0].Policy, "the caller's exact policy must be delegated unmodified") | |
| 481 | + | require.Equal(t, added, adds[0].Result) | |
| 482 | + | ||
| 483 | + | updates := core.updateCalls() | |
| 484 | + | require.Len(t, updates, 1, "one policy update is exactly one delegation") | |
| 485 | + | require.Equal(t, added, updates[0].Expected, "the exact expected registration must be delegated unmodified") | |
| 486 | + | require.Equal(t, testPolicy(), updates[0].Policy, "the caller's exact replacement policy must be delegated") | |
| 487 | + | require.Equal(t, updated, updates[0].Result) | |
| 488 | + | ||
| 489 | + | removes := core.removeCalls() | |
| 490 | + | require.Len(t, removes, 2, "each removal is exactly one delegation") | |
| 491 | + | require.Equal(t, updated, removes[0].Expected, "the exact expected registration must be delegated") | |
| 492 | + | require.Equal(t, updated, removes[1].Expected) | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | // TestMembershipRefusalCodesEndToEnd proves the two membership refusal codes | |
| 496 | + | // travel end to end: a stale generation removal is host conflict (11) and an | |
| 497 | + | // immutable registry is membership-immutable (12). The refusal reaches the caller | |
| 498 | + | // as a typed ports.BrokerError carrying the exact code and the matching sentinel | |
| 499 | + | // as its cause, reports no authority, and never invents a registration or a | |
| 500 | + | // removal. | |
| 501 | + | func TestMembershipRefusalCodesEndToEnd(t *testing.T) { | |
| 502 | + | cases := []struct { | |
| 503 | + | name string | |
| 504 | + | configure func(core *fakeCore) | |
| 505 | + | call func(ctx context.Context, client ports.BrokerService) (domain.RemoteRegistration, bool, error) | |
| 506 | + | wantCode ports.BrokerErrorCode | |
| 507 | + | }{ | |
| 508 | + | { | |
| 509 | + | name: "stale generation removal is host conflict 11", | |
| 510 | + | configure: func(core *fakeCore) { core.refuseRemoveHost(ports.ErrBrokerHostConflict) }, | |
| 511 | + | call: func(ctx context.Context, client ports.BrokerService) (domain.RemoteRegistration, bool, error) { | |
| 512 | + | removed, err := client.RemoveHost(ctx, mutationRegistration("known")) | |
| 513 | + | return domain.RemoteRegistration{}, removed, err | |
| 514 | + | }, | |
| 515 | + | wantCode: ports.BrokerErrorHostConflict, | |
| 516 | + | }, | |
| 517 | + | { | |
| 518 | + | name: "immutable registry is membership immutable 12", | |
| 519 | + | configure: func(core *fakeCore) { core.refuseAddHost(ports.ErrBrokerMembershipImmutable) }, | |
| 520 | + | call: func(ctx context.Context, client ports.BrokerService) (domain.RemoteRegistration, bool, error) { | |
| 521 | + | registration, err := client.AddHost(ctx, "new@host:22", testPolicy()) | |
| 522 | + | return registration, false, err | |
| 523 | + | }, | |
| 524 | + | wantCode: ports.BrokerErrorMembershipImmutable, | |
| 525 | + | }, | |
| 526 | + | } | |
| 527 | + | require.Equal(t, ports.BrokerErrorCode(11), ports.BrokerErrorHostConflict) | |
| 528 | + | require.Equal(t, ports.BrokerErrorCode(12), ports.BrokerErrorMembershipImmutable) | |
| 529 | + | for _, tc := range cases { | |
| 530 | + | t.Run(tc.name, func(t *testing.T) { | |
| 531 | + | e := startEndpoint(t, Config{}) | |
| 532 | + | client, _ := e.pair() | |
| 533 | + | core := e.authority.last() | |
| 534 | + | tc.configure(core) | |
| 535 | + | ||
| 536 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 537 | + | defer cancel() | |
| 538 | + | registration, removed, err := tc.call(ctx, client) | |
| 539 | + | require.Equal(t, domain.RemoteRegistration{}, registration, "a refusal never invents authority") | |
| 540 | + | require.False(t, removed, "a refusal never reports the host gone") | |
| 541 | + | require.Error(t, err) | |
| 542 | + | var failure ports.BrokerError | |
| 543 | + | require.ErrorAs(t, err, &failure) | |
| 544 | + | require.Equal(t, tc.wantCode, failure.Code, "the exact refusal code must reach the caller") | |
| 545 | + | require.NotEqual(t, ports.BrokerErrorOutcomeUnknown, failure.Code, | |
| 546 | + | "a definite refusal is never mistaken for a refresh-required outcome") | |
| 547 | + | ||
| 548 | + | // The connection survived the refusal: a later delegation still works. | |
| 549 | + | requireConnectionDelegates(t, client) | |
| 550 | + | }) | |
| 551 | + | } | |
| 552 | + | } | |
| 553 | + | ||
| 554 | + | // TestMembershipWireRefusalFraming proves the refusal framing the server emits is | |
| 555 | + | // exactly the contract the wire validates: a failed result carrying its typed | |
| 556 | + | // error and no authority, under the exact code, with the operation identity the | |
| 557 | + | // caller used. It drives the raw carriage so the frames are observed as a peer | |
| 558 | + | // would receive them. | |
| 559 | + | func TestMembershipWireRefusalFraming(t *testing.T) { | |
| 560 | + | cases := []struct { | |
| 561 | + | name string | |
| 562 | + | configure func(core *fakeCore) | |
| 563 | + | send func(scope brokerwire.Scope) brokerwire.ClientMessage | |
| 564 | + | kind brokerwire.RegisterMutationKind | |
| 565 | + | wantCode ports.BrokerErrorCode | |
| 566 | + | }{ | |
| 567 | + | { | |
| 568 | + | name: "remove conflict", | |
| 569 | + | configure: func(core *fakeCore) { core.refuseRemoveHost(ports.ErrBrokerHostConflict) }, | |
| 570 | + | send: func(scope brokerwire.Scope) brokerwire.ClientMessage { | |
| 571 | + | return brokerwire.RemoveHost{ | |
| 572 | + | Epoch: scope.Epoch, Connection: scope.Connection, | |
| 573 | + | Operation: ports.BrokerOperationID{0x31}, Registration: mutationRegistration("known"), | |
| 574 | + | } | |
| 575 | + | }, | |
| 576 | + | kind: brokerwire.MutationKindRemoveHost, | |
| 577 | + | wantCode: ports.BrokerErrorHostConflict, | |
| 578 | + | }, | |
| 579 | + | { | |
| 580 | + | name: "immutable addition", | |
| 581 | + | configure: func(core *fakeCore) { core.refuseAddHost(ports.ErrBrokerMembershipImmutable) }, | |
| 582 | + | send: func(scope brokerwire.Scope) brokerwire.ClientMessage { | |
| 583 | + | return brokerwire.AddHost{ | |
| 584 | + | Epoch: scope.Epoch, Connection: scope.Connection, | |
| 585 | + | Operation: ports.BrokerOperationID{0x32}, Endpoint: "new@host:22", Policy: testPolicy(), | |
| 586 | + | } | |
| 587 | + | }, | |
| 588 | + | kind: brokerwire.MutationKindAddHost, | |
| 589 | + | wantCode: ports.BrokerErrorMembershipImmutable, | |
| 590 | + | }, | |
| 591 | + | } | |
| 592 | + | for _, tc := range cases { | |
| 593 | + | t.Run(tc.name, func(t *testing.T) { | |
| 594 | + | e := startEndpoint(t, Config{}) | |
| 595 | + | raw := rawDial(t, e, brokerwire.DefaultCeilings()) | |
| 596 | + | scope := raw.register(t) | |
| 597 | + | tc.configure(e.authority.last()) | |
| 598 | + | ||
| 599 | + | request := tc.send(scope) | |
| 600 | + | raw.send(t, request) | |
| 601 | + | result, ok := raw.recv(t).(brokerwire.OperationResult) | |
| 602 | + | require.True(t, ok, "a membership mutation must be answered with an OperationResult") | |
| 603 | + | require.Equal(t, scope.Connection, result.Connection) | |
| 604 | + | require.Equal(t, operationOf(t, request), result.Operation) | |
| 605 | + | require.Equal(t, ports.BrokerOutcomeFailed, result.Outcome) | |
| 606 | + | require.True(t, result.HasError, "a failure must carry its typed detail") | |
| 607 | + | require.Equal(t, tc.wantCode, result.Error.Code, "the exact refusal code must travel") | |
| 608 | + | require.False(t, result.Removed, "a failure carries no removal authority") | |
| 609 | + | require.Equal(t, domain.RemoteRegistration{}, result.Registration, | |
| 610 | + | "a failure carries no registration authority") | |
| 611 | + | // The emitted result satisfies the same rule the peer enforces for | |
| 612 | + | // this request kind. | |
| 613 | + | require.NoError(t, result.ValidateForMutation(tc.kind)) | |
| 614 | + | }) | |
| 615 | + | } | |
| 457 | 616 | } | |
| 458 | 617 | ||
| 459 | - | // TestLostOperationReplyIsOutcomeUnknown proves a mutating operation whose reply | |
| 460 | - | // never arrives is reported as outcome-unknown rather than retried or reported | |
| 461 | - | // as a plain failure. | |
| 462 | - | func TestLostOperationReplyIsOutcomeUnknown(t *testing.T) { | |
| 618 | + | // TestMembershipStoreOutcomeUnknownMapsToOutcomeUnknown proves an indeterminate | |
| 619 | + | // durable write is never presented as a definite refusal: both the value and the | |
| 620 | + | // pointer form of the store's outcome-unknown error become an outcome-unknown | |
| 621 | + | // result carrying the outcome-unknown code and no authority, and the caller | |
| 622 | + | // observes ports.BrokerErrorOutcomeUnknown so it refreshes rather than retries. | |
| 623 | + | func TestMembershipStoreOutcomeUnknownMapsToOutcomeUnknown(t *testing.T) { | |
| 624 | + | cause := errors.New("torn write after the commit point") | |
| 625 | + | cases := map[string]error{ | |
| 626 | + | "value": ports.BrokerStoreOutcomeUnknownError{Err: cause}, | |
| 627 | + | "pointer": &ports.BrokerStoreOutcomeUnknownError{Err: cause}, | |
| 628 | + | } | |
| 629 | + | for name, stored := range cases { | |
| 630 | + | t.Run(name, func(t *testing.T) { | |
| 631 | + | e := startEndpoint(t, Config{}) | |
| 632 | + | raw := rawDial(t, e, brokerwire.DefaultCeilings()) | |
| 633 | + | scope := raw.register(t) | |
| 634 | + | core := e.authority.last() | |
| 635 | + | core.refuseAddHost(stored) | |
| 636 | + | ||
| 637 | + | request := brokerwire.AddHost{ | |
| 638 | + | Epoch: scope.Epoch, Connection: scope.Connection, | |
| 639 | + | Operation: ports.BrokerOperationID{0x41}, Endpoint: "new@host:22", Policy: testPolicy(), | |
| 640 | + | } | |
| 641 | + | raw.send(t, request) | |
| 642 | + | result, ok := raw.recv(t).(brokerwire.OperationResult) | |
| 643 | + | require.True(t, ok) | |
| 644 | + | require.Equal(t, ports.BrokerOutcomeUnknown, result.Outcome, | |
| 645 | + | "an indeterminate write is outcome-unknown, never a definite failure") | |
| 646 | + | require.Equal(t, ports.BrokerErrorOutcomeUnknown, result.Error.Code) | |
| 647 | + | require.False(t, result.Removed) | |
| 648 | + | require.Equal(t, domain.RemoteRegistration{}, result.Registration) | |
| 649 | + | require.NoError(t, result.ValidateForMutation(brokerwire.MutationKindAddHost)) | |
| 650 | + | ||
| 651 | + | // The same indeterminate write reaches the public caller as the | |
| 652 | + | // refresh-required code, so it is never mistaken for a retryable | |
| 653 | + | // conflict or a plain failure. | |
| 654 | + | client, _ := e.pair() | |
| 655 | + | fresh := e.authority.last() | |
| 656 | + | fresh.refuseAddHost(stored) | |
| 657 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 658 | + | defer cancel() | |
| 659 | + | added, err := client.AddHost(ctx, "new@host:22", testPolicy()) | |
| 660 | + | require.Equal(t, domain.RemoteRegistration{}, added) | |
| 661 | + | var failure ports.BrokerError | |
| 662 | + | require.ErrorAs(t, err, &failure) | |
| 663 | + | require.Equal(t, ports.BrokerErrorOutcomeUnknown, failure.Code) | |
| 664 | + | }) | |
| 665 | + | } | |
| 666 | + | } | |
| 667 | + | ||
| 668 | + | // TestServerAbortsResultMissingRequiredAuthority proves the server never | |
| 669 | + | // publishes an addition success that cannot fence the mutation it answers: an | |
| 670 | + | // admitted core that returns no registration for a successful AddHost is a | |
| 671 | + | // protocol violation, so the session settles without answering the caller | |
| 672 | + | // instead of sending a result the peer would refuse. | |
| 673 | + | func TestServerAbortsResultMissingRequiredAuthority(t *testing.T) { | |
| 463 | 674 | e := startEndpoint(t, Config{}) | |
| 464 | - | client, _ := e.pair() | |
| 675 | + | raw := rawDial(t, e, brokerwire.DefaultCeilings()) | |
| 676 | + | scope := raw.register(t) | |
| 677 | + | session := e.accept() | |
| 465 | 678 | core := e.authority.last() | |
| 466 | - | core.blockAdd = make(chan struct{}) | |
| 467 | - | t.Cleanup(func() { close(core.blockAdd) }) | |
| 679 | + | core.zeroAddRegistration() | |
| 468 | 680 | ||
| 469 | - | ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) | |
| 470 | - | defer cancel() | |
| 471 | - | err := client.AddHost(ctx, "slow@host:22") | |
| 472 | - | require.Error(t, err) | |
| 473 | - | var failure ports.BrokerError | |
| 474 | - | require.ErrorAs(t, err, &failure) | |
| 475 | - | require.Equal(t, ports.BrokerErrorOutcomeUnknown, failure.Code) | |
| 681 | + | raw.send(t, brokerwire.AddHost{ | |
| 682 | + | Epoch: scope.Epoch, Connection: scope.Connection, | |
| 683 | + | Operation: ports.BrokerOperationID{0x51}, Endpoint: "new@host:22", Policy: testPolicy(), | |
| 684 | + | }) | |
| 685 | + | ||
| 686 | + | // No result travels; the connection settles as a protocol violation. | |
| 687 | + | err := raw.awaitReadError(t, 5*time.Second) | |
| 688 | + | require.Error(t, err, "a success without its required authority must settle the connection") | |
| 689 | + | awaitSessionDone(t, session.(*serverSession)) | |
| 690 | + | require.ErrorIs(t, session.(*serverSession).terminalErr(), ErrProtocol) | |
| 691 | + | require.Error(t, session.Close(), "a protocol violation is not an orderly end") | |
| 692 | + | ||
| 693 | + | // The addition was delegated exactly once; the adapter refused to publish an | |
| 694 | + | // unfenceable answer rather than re-delegating or inventing one. | |
| 695 | + | require.Len(t, core.addCalls(), 1) | |
| 476 | 696 | } | |
| 477 | 697 | ||
| 478 | 698 | // TestDisconnectCleanupReleasesEveryOwnedResource proves a client disconnect | |
| @@ -532,9 +752,7 @@ func TestStalledSubscriberDoesNotBlockOtherClients(t *testing.T) { | |||
| 532 | 752 | } | |
| 533 | 753 | require.Eventually(t, func() bool { return active.Snapshot().Revision == 20 }, 5*time.Second, 10*time.Millisecond) | |
| 534 | 754 | ||
| 535 | - | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 536 | - | defer cancel() | |
| 537 | - | require.NoError(t, active.AddHost(ctx, "active@host:22")) | |
| 755 | + | requireConnectionDelegates(t, active) | |
| 538 | 756 | } | |
| 539 | 757 | ||
| 540 | 758 | // TestClientStreamBackpressureSettlesOnlyThatStream proves a local consumer that | |
| @@ -573,7 +791,7 @@ func TestClientStreamBackpressureSettlesOnlyThatStream(t *testing.T) { | |||
| 573 | 791 | }, 5*time.Second, 10*time.Millisecond, "the broker must be told to retire the stalled stream") | |
| 574 | 792 | ||
| 575 | 793 | // The connection survives a stream-local stall. | |
| 576 | - | require.NoError(t, client.AddHost(ctx, "after@stall:22")) | |
| 794 | + | requireConnectionDelegates(t, client) | |
| 577 | 795 | } | |
| 578 | 796 | ||
| 579 | 797 | // TestOrderlyStreamCloseReportsNoError proves a local close is an orderly end: | |
| @@ -703,7 +921,7 @@ func TestSessionHandshakeFailureIsStreamLocal(t *testing.T) { | |||
| 703 | 921 | case <-time.After(5 * time.Second): | |
| 704 | 922 | t.Fatal("a malformed inner frame must settle its stream") | |
| 705 | 923 | } | |
| 706 | - | require.NoError(t, client.AddHost(ctx, "survivor@host:22")) | |
| 924 | + | requireConnectionDelegates(t, client) | |
| 707 | 925 | ||
| 708 | 926 | // The broker core sees the stream retired. | |
| 709 | 927 | require.Eventually(t, func() bool { | |
M internal/adapters/brokerwire/codec.go +115 -9
| @@ -175,6 +175,17 @@ func encodeClientEnvelope(message ClientMessage, maxChunkBytes uint64) (*wire.Br | |||
| 175 | 175 | return nil, ErrInvalidMessage | |
| 176 | 176 | } | |
| 177 | 177 | return encodeClientEnvelope(*m, maxChunkBytes) | |
| 178 | + | case UpdateHostPolicy: | |
| 179 | + | converted, err := updateHostPolicyToWire(m) | |
| 180 | + | if err != nil { | |
| 181 | + | return nil, err | |
| 182 | + | } | |
| 183 | + | return &wire.BrokerClientEnvelope{Payload: &wire.BrokerClientEnvelope_UpdateHostPolicy{UpdateHostPolicy: converted}}, nil | |
| 184 | + | case *UpdateHostPolicy: | |
| 185 | + | if m == nil { | |
| 186 | + | return nil, ErrInvalidMessage | |
| 187 | + | } | |
| 188 | + | return encodeClientEnvelope(*m, maxChunkBytes) | |
| 178 | 189 | case Reconcile: | |
| 179 | 190 | converted, err := reconcileToWire(m) | |
| 180 | 191 | if err != nil { | |
| @@ -251,6 +262,8 @@ func decodeClientEnvelope(envelope *wire.BrokerClientEnvelope, maxChunkBytes uin | |||
| 251 | 262 | return addHostFromWire(payload.AddHost) | |
| 252 | 263 | case *wire.BrokerClientEnvelope_RemoveHost: | |
| 253 | 264 | return removeHostFromWire(payload.RemoveHost) | |
| 265 | + | case *wire.BrokerClientEnvelope_UpdateHostPolicy: | |
| 266 | + | return updateHostPolicyFromWire(payload.UpdateHostPolicy) | |
| 254 | 267 | case *wire.BrokerClientEnvelope_Reconcile: | |
| 255 | 268 | return reconcileFromWire(payload.Reconcile) | |
| 256 | 269 | case *wire.BrokerClientEnvelope_OpenStream: | |
| @@ -284,10 +297,17 @@ func addHostToWire(m AddHost) (*wire.AddHost, error) { | |||
| 284 | 297 | if err := validateBrokerEndpoint(m.Endpoint); err != nil { | |
| 285 | 298 | return nil, err | |
| 286 | 299 | } | |
| 300 | + | // Policy is required authority: a zero or invalid policy is refused | |
| 301 | + | // here rather than travelling as an absent message the peer must | |
| 302 | + | // interpret. | |
| 303 | + | if err := m.Policy.Validate(); err != nil { | |
| 304 | + | return nil, ErrInvalidMessage | |
| 305 | + | } | |
| 287 | 306 | return &wire.AddHost{ | |
| 288 | 307 | Scope: scopeToWire(m.Epoch, m.Connection), | |
| 289 | 308 | OperationId: operationToWire(m.Operation), | |
| 290 | 309 | Endpoint: m.Endpoint, | |
| 310 | + | Policy: policyToWire(m.Policy), | |
| 291 | 311 | }, nil | |
| 292 | 312 | } | |
| 293 | 313 | ||
| @@ -307,7 +327,11 @@ func addHostFromWire(message *wire.AddHost) (AddHost, error) { | |||
| 307 | 327 | if err := validateBrokerEndpoint(message.GetEndpoint()); err != nil { | |
| 308 | 328 | return AddHost{}, err | |
| 309 | 329 | } | |
| 310 | - | return AddHost{Epoch: epoch, Connection: connection, Operation: operation, Endpoint: message.GetEndpoint()}, nil | |
| 330 | + | policy, err := policyFromWire(message.GetPolicy()) | |
| 331 | + | if err != nil { | |
| 332 | + | return AddHost{}, ErrInvalidMessage | |
| 333 | + | } | |
| 334 | + | return AddHost{Epoch: epoch, Connection: connection, Operation: operation, Endpoint: message.GetEndpoint(), Policy: policy}, nil | |
| 311 | 335 | } | |
| 312 | 336 | ||
| 313 | 337 | func removeHostToWire(m RemoveHost) (*wire.RemoveHost, error) { | |
| @@ -320,13 +344,15 @@ func removeHostToWire(m RemoveHost) (*wire.RemoveHost, error) { | |||
| 320 | 344 | if err := m.Operation.Validate(); err != nil { | |
| 321 | 345 | return nil, ErrInvalidMessage | |
| 322 | 346 | } | |
| 323 | - | if err := validateBrokerEndpoint(m.Endpoint); err != nil { | |
| 324 | - | return nil, err | |
| 347 | + | // Removal carries exact authority, never a bare endpoint: a zero or | |
| 348 | + | // partial registration could not fence a remove/re-add race. | |
| 349 | + | if err := m.Registration.Validate(); err != nil { | |
| 350 | + | return nil, ErrInvalidMessage | |
| 325 | 351 | } | |
| 326 | 352 | return &wire.RemoveHost{ | |
| 327 | - | Scope: scopeToWire(m.Epoch, m.Connection), | |
| 328 | - | OperationId: operationToWire(m.Operation), | |
| 329 | - | Endpoint: m.Endpoint, | |
| 353 | + | Scope: scopeToWire(m.Epoch, m.Connection), | |
| 354 | + | OperationId: operationToWire(m.Operation), | |
| 355 | + | Registration: registrationToWire(m.Registration), | |
| 330 | 356 | }, nil | |
| 331 | 357 | } | |
| 332 | 358 | ||
| @@ -343,10 +369,59 @@ func removeHostFromWire(message *wire.RemoveHost) (RemoveHost, error) { | |||
| 343 | 369 | if err != nil { | |
| 344 | 370 | return RemoveHost{}, ErrInvalidMessage | |
| 345 | 371 | } | |
| 346 | - | if err := validateBrokerEndpoint(message.GetEndpoint()); err != nil { | |
| 347 | - | return RemoveHost{}, err | |
| 372 | + | registration, err := registrationFromWire(message.GetRegistration()) | |
| 373 | + | if err != nil { | |
| 374 | + | return RemoveHost{}, ErrInvalidMessage | |
| 375 | + | } | |
| 376 | + | return RemoveHost{Epoch: epoch, Connection: connection, Operation: operation, Registration: registration}, nil | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | func updateHostPolicyToWire(m UpdateHostPolicy) (*wire.UpdateHostPolicy, error) { | |
| 380 | + | if m.Epoch == 0 { | |
| 381 | + | return nil, ErrInvalidMessage | |
| 382 | + | } | |
| 383 | + | if err := m.Connection.Validate(); err != nil { | |
| 384 | + | return nil, ErrInvalidMessage | |
| 385 | + | } | |
| 386 | + | if err := m.Operation.Validate(); err != nil { | |
| 387 | + | return nil, ErrInvalidMessage | |
| 388 | + | } | |
| 389 | + | if err := m.Registration.Validate(); err != nil { | |
| 390 | + | return nil, ErrInvalidMessage | |
| 391 | + | } | |
| 392 | + | if err := m.Policy.Validate(); err != nil { | |
| 393 | + | return nil, ErrInvalidMessage | |
| 394 | + | } | |
| 395 | + | return &wire.UpdateHostPolicy{ | |
| 396 | + | Scope: scopeToWire(m.Epoch, m.Connection), | |
| 397 | + | OperationId: operationToWire(m.Operation), | |
| 398 | + | Registration: registrationToWire(m.Registration), | |
| 399 | + | Policy: policyToWire(m.Policy), | |
| 400 | + | }, nil | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | func updateHostPolicyFromWire(message *wire.UpdateHostPolicy) (UpdateHostPolicy, error) { | |
| 404 | + | var out UpdateHostPolicy | |
| 405 | + | if message == nil { | |
| 406 | + | return out, ErrInvalidMessage | |
| 407 | + | } | |
| 408 | + | epoch, connection, err := scopeFromWire(message.GetScope()) | |
| 409 | + | if err != nil { | |
| 410 | + | return UpdateHostPolicy{}, ErrInvalidMessage | |
| 411 | + | } | |
| 412 | + | operation, err := operationFromWire(message.GetOperationId()) | |
| 413 | + | if err != nil { | |
| 414 | + | return UpdateHostPolicy{}, ErrInvalidMessage | |
| 415 | + | } | |
| 416 | + | registration, err := registrationFromWire(message.GetRegistration()) | |
| 417 | + | if err != nil { | |
| 418 | + | return UpdateHostPolicy{}, ErrInvalidMessage | |
| 419 | + | } | |
| 420 | + | policy, err := policyFromWire(message.GetPolicy()) | |
| 421 | + | if err != nil { | |
| 422 | + | return UpdateHostPolicy{}, ErrInvalidMessage | |
| 348 | 423 | } | |
| 349 | - | return RemoveHost{Epoch: epoch, Connection: connection, Operation: operation, Endpoint: message.GetEndpoint()}, nil | |
| 424 | + | return UpdateHostPolicy{Epoch: epoch, Connection: connection, Operation: operation, Registration: registration, Policy: policy}, nil | |
| 350 | 425 | } | |
| 351 | 426 | ||
| 352 | 427 | func reconcileToWire(m Reconcile) (*wire.Reconcile, error) { | |
| @@ -1220,10 +1295,29 @@ func operationResultToWire(m OperationResult) (*wire.OperationResult, error) { | |||
| 1220 | 1295 | if err := result.Validate(); err != nil { | |
| 1221 | 1296 | return nil, ErrInvalidMessage | |
| 1222 | 1297 | } | |
| 1298 | + | hasRegistration := m.Registration != (domain.RemoteRegistration{}) | |
| 1299 | + | // The presence rules that hold without knowing the request kind: removal | |
| 1300 | + | // and registration never travel together, a failed or outcome-unknown | |
| 1301 | + | // result carries neither authority and must carry its typed error, and a | |
| 1302 | + | // successful result carries no error. The per-kind rule (an | |
| 1303 | + | // AddHost/UpdateHostPolicy success needs a registration, a RemoveHost | |
| 1304 | + | // success must not) is enforced where the pending request is known; the | |
| 1305 | + | // stateless codec has no such state. | |
| 1306 | + | if err := validateResultAuthority(m.Outcome, m.Removed, hasRegistration, m.HasError); err != nil { | |
| 1307 | + | return nil, err | |
| 1308 | + | } | |
| 1309 | + | var registration *wire.RemoteRegistration | |
| 1310 | + | if hasRegistration { | |
| 1311 | + | if err := m.Registration.Validate(); err != nil { | |
| 1312 | + | return nil, ErrInvalidMessage | |
| 1313 | + | } | |
| 1314 | + | registration = registrationToWire(m.Registration) | |
| 1315 | + | } | |
| 1223 | 1316 | out := &wire.OperationResult{ | |
| 1224 | 1317 | Scope: scopeToWire(m.Epoch, m.Connection), | |
| 1225 | 1318 | OperationId: operationToWire(m.Operation), | |
| 1226 | 1319 | Outcome: outcome, Removed: m.Removed, | |
| 1320 | + | Registration: registration, | |
| 1227 | 1321 | } | |
| 1228 | 1322 | if m.HasError { | |
| 1229 | 1323 | out.Error = errorDetailToWire(m.Error) | |
| @@ -1255,9 +1349,18 @@ func operationResultFromWire(message *wire.OperationResult) (OperationResult, er | |||
| 1255 | 1349 | default: | |
| 1256 | 1350 | return OperationResult{}, ErrInvalidMessage | |
| 1257 | 1351 | } | |
| 1352 | + | var registration domain.RemoteRegistration | |
| 1353 | + | if message.GetRegistration() != nil { | |
| 1354 | + | converted, err := registrationFromWire(message.GetRegistration()) | |
| 1355 | + | if err != nil { | |
| 1356 | + | return OperationResult{}, ErrInvalidMessage | |
| 1357 | + | } | |
| 1358 | + | registration = converted | |
| 1359 | + | } | |
| 1258 | 1360 | result := OperationResult{ | |
| 1259 | 1361 | Epoch: epoch, Connection: connection, Operation: operation, | |
| 1260 | 1362 | Outcome: outcome, Removed: message.GetRemoved(), | |
| 1363 | + | Registration: registration, | |
| 1261 | 1364 | } | |
| 1262 | 1365 | if message.GetError() != nil { | |
| 1263 | 1366 | detail, err := errorDetailFromWire(message.GetError()) | |
| @@ -1267,6 +1370,9 @@ func operationResultFromWire(message *wire.OperationResult) (OperationResult, er | |||
| 1267 | 1370 | result.Error = detail | |
| 1268 | 1371 | result.HasError = true | |
| 1269 | 1372 | } | |
| 1373 | + | if err := validateResultAuthority(result.Outcome, result.Removed, registration != (domain.RemoteRegistration{}), result.HasError); err != nil { | |
| 1374 | + | return OperationResult{}, err | |
| 1375 | + | } | |
| 1270 | 1376 | semantic := ports.BrokerOperationResult{Operation: result.Operation, Outcome: result.Outcome} | |
| 1271 | 1377 | if result.HasError { | |
| 1272 | 1378 | semantic.Text = result.Error.Text | |
M internal/adapters/brokerwire/codec_test.go +112 -6
| @@ -351,14 +351,15 @@ func TestBrokerClientVariants(t *testing.T) { | |||
| 351 | 351 | {"subscribe", Subscribe{Epoch: 7, Connection: connection, Generation: 1}, 102}, | |
| 352 | 352 | {"resync", Resync{Epoch: 7, Connection: connection, Generation: 2}, 103}, | |
| 353 | 353 | {"unsubscribe", Unsubscribe{Epoch: 7, Connection: connection, Generation: 3}, 104}, | |
| 354 | - | {"add_host", AddHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22"}, 105}, | |
| 355 | - | {"remove_host", RemoveHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22"}, 106}, | |
| 354 | + | {"add_host", AddHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22", Policy: testPolicy()}, 105}, | |
| 355 | + | {"remove_host", RemoveHost{Epoch: 7, Connection: connection, Operation: operation, Registration: testRegistration()}, 106}, | |
| 356 | 356 | {"reconcile", Reconcile{Epoch: 7, Connection: connection, Registration: testRegistration()}, 107}, | |
| 357 | 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}, | |
| 358 | 358 | {"client_stream_data", ClientStreamData{Epoch: 7, Connection: connection, Stream: 3, Data: []byte("frame")}, 109}, | |
| 359 | 359 | {"close_stream", CloseStream{Epoch: 7, Connection: connection, Stream: 3}, 110}, | |
| 360 | + | {"update_host_policy", UpdateHostPolicy{Epoch: 7, Connection: connection, Operation: operation, Registration: testRegistration(), Policy: testPolicy()}, 111}, | |
| 360 | 361 | } | |
| 361 | - | require.Len(t, messages, 10) | |
| 362 | + | require.Len(t, messages, 11) | |
| 362 | 363 | for _, tc := range messages { | |
| 363 | 364 | t.Run(tc.name, func(t *testing.T) { | |
| 364 | 365 | raw := mustEncodeClient(t, tc.message) | |
| @@ -525,7 +526,9 @@ func TestBrokerServerVariants(t *testing.T) { | |||
| 525 | 526 | {"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 | 527 | {"snapshot_tombstone", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 5, Part: SnapshotTombstonePart{TombstoneIndex: 0, Registration: testRegistration(), RetiredRevision: 4}}, 202}, | |
| 527 | 528 | {"snapshot_end", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 6, Part: SnapshotEnd{}}, 202}, | |
| 528 | - | {"operation_result", OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeFailed, Removed: true, Error: testErrorDetail(), HasError: true}, 203}, | |
| 529 | + | {"operation_result", OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeFailed, Error: testErrorDetail(), HasError: true}, 203}, | |
| 530 | + | {"operation_result_add", OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeOK, Registration: testRegistration()}, 203}, | |
| 531 | + | {"operation_result_remove", OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeOK, Removed: true}, 203}, | |
| 529 | 532 | {"stream_opened", StreamOpened{Epoch: 7, Connection: connection, Stream: 3}, 204}, | |
| 530 | 533 | {"server_stream_data", ServerStreamData{Epoch: 7, Connection: connection, Stream: 3, Data: []byte("frame")}, 205}, | |
| 531 | 534 | {"stream_closed", StreamClosed{Epoch: 7, Connection: connection, Stream: 3, Error: testErrorDetail(), HasError: true}, 206}, | |
| @@ -811,7 +814,7 @@ func TestBrokerValidationNegatives(t *testing.T) { | |||
| 811 | 814 | // declared detail still round-trips exactly. | |
| 812 | 815 | _, err := EncodeServer(StreamClosed{Epoch: 7, Connection: connection, Stream: 3}, testEnvelopeCeiling, testChunkCeiling) | |
| 813 | 816 | require.NoError(t, err) | |
| 814 | - | _, err = EncodeServer(OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: ports.BrokerOutcomeOK}, testEnvelopeCeiling, testChunkCeiling) | |
| 817 | + | _, err = EncodeServer(OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: ports.BrokerOutcomeOK, Registration: testRegistration()}, testEnvelopeCeiling, testChunkCeiling) | |
| 815 | 818 | require.NoError(t, err) | |
| 816 | 819 | declared := OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: ports.BrokerOutcomeFailed, Error: detail, HasError: true} | |
| 817 | 820 | decoded, err := DecodeServer(mustEncodeServer(t, declared), testEnvelopeCeiling, testChunkCeiling) | |
| @@ -846,8 +849,103 @@ func TestBrokerValidationNegatives(t *testing.T) { | |||
| 846 | 849 | _, err := EncodeServer(BrokerErrorMessage{Epoch: 7, Connection: connection, Error: boundary}, testEnvelopeCeiling, testChunkCeiling) | |
| 847 | 850 | require.NoError(t, err) | |
| 848 | 851 | }) | |
| 852 | + | t.Run("membership request authority", func(t *testing.T) { | |
| 853 | + | base := AddHost{Epoch: 7, Connection: connection, Operation: testOperationID(1), Endpoint: "dev@host:22", Policy: testPolicy()} | |
| 854 | + | // An absent policy is not authority: it is refused exactly like an | |
| 855 | + | // invalid one rather than inherited from a default. | |
| 856 | + | missing := base | |
| 857 | + | missing.Policy = ports.BrokerPolicy{} | |
| 858 | + | _, err := EncodeClient(missing, testEnvelopeCeiling, testChunkCeiling) | |
| 859 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 860 | + | ||
| 861 | + | // A partial registration is not exact authority either. | |
| 862 | + | _, err = EncodeClient(RemoveHost{Epoch: 7, Connection: connection, Operation: testOperationID(1)}, testEnvelopeCeiling, testChunkCeiling) | |
| 863 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 864 | + | partial := testRegistration() | |
| 865 | + | partial.Incarnation = [16]byte{} | |
| 866 | + | _, err = EncodeClient(RemoveHost{Epoch: 7, Connection: connection, Operation: testOperationID(1), Registration: partial}, testEnvelopeCeiling, testChunkCeiling) | |
| 867 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 868 | + | partial = testRegistration() | |
| 869 | + | partial.Generation = 0 | |
| 870 | + | _, err = EncodeClient(RemoveHost{Epoch: 7, Connection: connection, Operation: testOperationID(1), Registration: partial}, testEnvelopeCeiling, testChunkCeiling) | |
| 871 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 872 | + | ||
| 873 | + | update := UpdateHostPolicy{Epoch: 7, Connection: connection, Operation: testOperationID(1), Registration: testRegistration(), Policy: testPolicy()} | |
| 874 | + | missingPolicy := update | |
| 875 | + | missingPolicy.Policy = ports.BrokerPolicy{} | |
| 876 | + | _, err = EncodeClient(missingPolicy, testEnvelopeCeiling, testChunkCeiling) | |
| 877 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 878 | + | missingRegistration := update | |
| 879 | + | missingRegistration.Registration = domain.RemoteRegistration{} | |
| 880 | + | _, err = EncodeClient(missingRegistration, testEnvelopeCeiling, testChunkCeiling) | |
| 881 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 882 | + | }) | |
| 883 | + | t.Run("membership result presence", func(t *testing.T) { | |
| 884 | + | // A result may never carry both removal and registration, and a | |
| 885 | + | // failed or outcome-unknown result may carry neither authority and | |
| 886 | + | // must carry its typed error. | |
| 887 | + | both := OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: ports.BrokerOutcomeOK, Removed: true, Registration: testRegistration()} | |
| 888 | + | _, err := EncodeServer(both, testEnvelopeCeiling, testChunkCeiling) | |
| 889 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 890 | + | for _, outcome := range []ports.BrokerMutationOutcome{ports.BrokerOutcomeFailed, ports.BrokerOutcomeUnknown} { | |
| 891 | + | _, err = EncodeServer(OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: outcome, Registration: testRegistration(), Error: testErrorDetail(), HasError: true}, testEnvelopeCeiling, testChunkCeiling) | |
| 892 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 893 | + | _, err = EncodeServer(OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: outcome, Removed: true, Error: testErrorDetail(), HasError: true}, testEnvelopeCeiling, testChunkCeiling) | |
| 894 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 895 | + | // Neither authority without an error is refused too: a failure | |
| 896 | + | // with no detail would read as an unexplained refusal. | |
| 897 | + | _, err = EncodeServer(OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: outcome}, testEnvelopeCeiling, testChunkCeiling) | |
| 898 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 899 | + | } | |
| 900 | + | // A success carries no error at all. | |
| 901 | + | _, err = EncodeServer(OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: ports.BrokerOutcomeOK, Registration: testRegistration(), Error: testErrorDetail(), HasError: true}, testEnvelopeCeiling, testChunkCeiling) | |
| 902 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 903 | + | // A partial registration is refused on the result path too. | |
| 904 | + | partial := testRegistration() | |
| 905 | + | partial.Incarnation = [16]byte{} | |
| 906 | + | _, err = EncodeServer(OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: ports.BrokerOutcomeOK, Registration: partial}, testEnvelopeCeiling, testChunkCeiling) | |
| 907 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 908 | + | }) | |
| 909 | + | t.Run("membership result authority by mutation kind", func(t *testing.T) { | |
| 910 | + | registration := testRegistration() | |
| 911 | + | add := OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: ports.BrokerOutcomeOK, Registration: registration} | |
| 912 | + | require.NoError(t, add.ValidateForMutation(MutationKindAddHost)) | |
| 913 | + | require.ErrorIs(t, add.ValidateForMutation(MutationKindRemoveHost), ErrInvalidMessage) | |
| 914 | + | require.NoError(t, add.ValidateForMutation(MutationKindUpdateHostPolicy)) | |
| 915 | + | ||
| 916 | + | remove := OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: ports.BrokerOutcomeOK, Removed: true} | |
| 917 | + | require.NoError(t, remove.ValidateForMutation(MutationKindRemoveHost)) | |
| 918 | + | require.ErrorIs(t, remove.ValidateForMutation(MutationKindAddHost), ErrInvalidMessage) | |
| 919 | + | require.ErrorIs(t, remove.ValidateForMutation(MutationKindUpdateHostPolicy), ErrInvalidMessage) | |
| 920 | + | ||
| 921 | + | failed := OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: ports.BrokerOutcomeFailed, Error: testErrorDetail(), HasError: true} | |
| 922 | + | for _, kind := range []RegisterMutationKind{MutationKindAddHost, MutationKindRemoveHost, MutationKindUpdateHostPolicy} { | |
| 923 | + | require.NoError(t, failed.ValidateForMutation(kind)) | |
| 924 | + | } | |
| 925 | + | // A kind outside the closed taxonomy is refused, never guessed. | |
| 926 | + | require.Error(t, add.ValidateForMutation(RegisterMutationKind(9))) | |
| 927 | + | // The request-side classifier names exactly the three mutations. | |
| 928 | + | for message, want := range map[ClientMessage]RegisterMutationKind{ | |
| 929 | + | AddHost{}: MutationKindAddHost, | |
| 930 | + | RemoveHost{}: MutationKindRemoveHost, | |
| 931 | + | UpdateHostPolicy{}: MutationKindUpdateHostPolicy, | |
| 932 | + | Subscribe{}: 0, | |
| 933 | + | } { | |
| 934 | + | got, ok := MutationKindOf(message) | |
| 935 | + | if want == 0 { | |
| 936 | + | require.False(t, ok) | |
| 937 | + | continue | |
| 938 | + | } | |
| 939 | + | require.True(t, ok) | |
| 940 | + | require.Equal(t, want, got) | |
| 941 | + | } | |
| 942 | + | require.Equal(t, "add_host", MutationKindAddHost.String()) | |
| 943 | + | require.Equal(t, "remove_host", MutationKindRemoveHost.String()) | |
| 944 | + | require.Equal(t, "update_host_policy", MutationKindUpdateHostPolicy.String()) | |
| 945 | + | require.Equal(t, "unknown", RegisterMutationKind(9).String()) | |
| 946 | + | }) | |
| 849 | 947 | t.Run("bad endpoint", func(t *testing.T) { | |
| 850 | - | _, err := EncodeClient(AddHost{Epoch: 7, Connection: connection, Operation: testOperationID(1), Endpoint: "not a host"}, testEnvelopeCeiling, testChunkCeiling) | |
| 948 | + | _, err := EncodeClient(AddHost{Epoch: 7, Connection: connection, Operation: testOperationID(1), Endpoint: "not a host", Policy: testPolicy()}, testEnvelopeCeiling, testChunkCeiling) | |
| 851 | 949 | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 852 | 950 | }) | |
| 853 | 951 | t.Run("taxonomy switches", func(t *testing.T) { | |
| @@ -974,8 +1072,12 @@ func TestBrokerWrongDirection(t *testing.T) { | |||
| 974 | 1072 | func TestBrokerCorruptInputSmoke(t *testing.T) { | |
| 975 | 1073 | seeds := [][]byte{ | |
| 976 | 1074 | mustEncodeClient(t, Register{}), | |
| 1075 | + | mustEncodeClient(t, AddHost{Epoch: 7, Connection: testConnectionID(2), Operation: testOperationID(0x21), Endpoint: "dev@host:22", Policy: testPolicy()}), | |
| 1076 | + | mustEncodeClient(t, RemoveHost{Epoch: 7, Connection: testConnectionID(2), Operation: testOperationID(0x22), Registration: testRegistration()}), | |
| 1077 | + | mustEncodeClient(t, UpdateHostPolicy{Epoch: 7, Connection: testConnectionID(2), Operation: testOperationID(0x23), Registration: testRegistration(), Policy: testPolicy()}), | |
| 977 | 1078 | mustEncodeClient(t, OpenStream{Epoch: 7, Connection: testConnectionID(2), Stream: 3, Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}), | |
| 978 | 1079 | mustEncodeServer(t, SnapshotPart{Epoch: 7, Connection: testConnectionID(2), Generation: 1, Revision: 1, Index: 0, Part: SnapshotEnd{}}), | |
| 1080 | + | mustEncodeServer(t, OperationResult{Epoch: 7, Connection: testConnectionID(2), Operation: testOperationID(0x24), Outcome: ports.BrokerOutcomeFailed, Error: testErrorDetail(), HasError: true}), | |
| 979 | 1081 | mustEncodeServer(t, Shutdown{Epoch: 7, Connection: testConnectionID(2), Reason: ShutdownIdleExit}), | |
| 980 | 1082 | } | |
| 981 | 1083 | for _, seed := range seeds { | |
| @@ -1003,6 +1105,9 @@ func fuzzClientSeeds(t testing.TB) [][]byte { | |||
| 1003 | 1105 | for _, message := range []ClientMessage{ | |
| 1004 | 1106 | Register{}, | |
| 1005 | 1107 | Subscribe{Epoch: 7, Connection: testConnectionID(0x11), Generation: 1}, | |
| 1108 | + | AddHost{Epoch: 7, Connection: testConnectionID(0x11), Operation: testOperationID(0x11), Endpoint: "dev@host:22", Policy: testPolicy()}, | |
| 1109 | + | RemoveHost{Epoch: 7, Connection: testConnectionID(0x11), Operation: testOperationID(0x12), Registration: testRegistration()}, | |
| 1110 | + | UpdateHostPolicy{Epoch: 7, Connection: testConnectionID(0x11), Operation: testOperationID(0x13), Registration: testRegistration(), Policy: testPolicy()}, | |
| 1006 | 1111 | OpenStream{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: testPolicy()}, | |
| 1007 | 1112 | ClientStreamData{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3, Data: []byte("frame")}, | |
| 1008 | 1113 | CloseStream{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3}, | |
| @@ -1021,6 +1126,7 @@ func fuzzServerSeeds(t testing.TB) [][]byte { | |||
| 1021 | 1126 | Registered{Epoch: 7, Connection: testConnectionID(0x11)}, | |
| 1022 | 1127 | SnapshotPart{Epoch: 7, Connection: testConnectionID(0x11), Generation: 2, Revision: 5, Index: 0, Part: SnapshotEnd{}}, | |
| 1023 | 1128 | SnapshotPart{Epoch: 7, Connection: testConnectionID(0x11), Generation: 2, Revision: 5, Index: 2, Part: SnapshotSessionPart{HostIndex: 0, SessionIndex: 0, Session: testCatalogSession()}}, | |
| 1129 | + | OperationResult{Epoch: 7, Connection: testConnectionID(0x11), Operation: testOperationID(0x14), Outcome: ports.BrokerOutcomeOK, Registration: testRegistration()}, | |
| 1024 | 1130 | BrokerErrorMessage{Epoch: 7, Connection: testConnectionID(0x11), Error: testErrorDetail()}, | |
| 1025 | 1131 | Shutdown{Epoch: 7, Connection: testConnectionID(0x11), Reason: ShutdownTerminating}, | |
| 1026 | 1132 | } { | |
M internal/adapters/brokerwire/doc.go +1 -1
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | // Package brokerwire adapts typed broker messages to Protobuf envelopes. | |
| 2 | 2 | // | |
| 3 | 3 | // The broker is a separate conversation from the session protocol: client | |
| 4 | - | // tags 101-110 and server tags 201-209 are disjoint from every session tag, | |
| 4 | + | // tags 101-111 and server tags 201-209 are disjoint from every session tag, | |
| 5 | 5 | // and each direction is its own closed oneof union. Every connection starts | |
| 6 | 6 | // with the bounded broker preamble (roles 3/4); magic, epoch, exact | |
| 7 | 7 | // protocol version, and negotiated limits are validated before application | |
A internal/adapters/brokerwire/membership_test.go +500 -0
| @@ -0,0 +1,500 @@ | |||
| 1 | + | package brokerwire | |
| 2 | + | ||
| 3 | + | // Membership wire contract (Phase C1). | |
| 4 | + | // | |
| 5 | + | // AddHost carries a required policy, RemoveHost carries exact registration | |
| 6 | + | // authority instead of an endpoint name, UpdateHostPolicy carries exact | |
| 7 | + | // authority plus the replacement policy, and every result carries the | |
| 8 | + | // presence-carrying outcome of the request it completes. These tests pin the | |
| 9 | + | // stateless codec's half of that contract: byte round trips, direction | |
| 10 | + | // closure, strict scan before unmarshal, exact generation preservation, the | |
| 11 | + | // request/result presence rules, and the typed error codes 11/12. | |
| 12 | + | ||
| 13 | + | import ( | |
| 14 | + | "math" | |
| 15 | + | "testing" | |
| 16 | + | ||
| 17 | + | "google.golang.org/protobuf/encoding/protowire" | |
| 18 | + | "google.golang.org/protobuf/proto" | |
| 19 | + | ||
| 20 | + | "github.com/bnema/vev/internal/domain" | |
| 21 | + | "github.com/bnema/vev/internal/ports" | |
| 22 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 23 | + | "github.com/stretchr/testify/require" | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | // membershipRequests are the three mutating client requests with their frozen | |
| 27 | + | // envelope tags. | |
| 28 | + | func membershipRequests(connection ports.BrokerConnectionID, operation ports.BrokerOperationID) []struct { | |
| 29 | + | name string | |
| 30 | + | message ClientMessage | |
| 31 | + | tag protowire.Number | |
| 32 | + | } { | |
| 33 | + | return []struct { | |
| 34 | + | name string | |
| 35 | + | message ClientMessage | |
| 36 | + | tag protowire.Number | |
| 37 | + | }{ | |
| 38 | + | {"add_host", AddHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22", Policy: testPolicy()}, 105}, | |
| 39 | + | {"remove_host", RemoveHost{Epoch: 7, Connection: connection, Operation: operation, Registration: testRegistration()}, 106}, | |
| 40 | + | {"update_host_policy", UpdateHostPolicy{Epoch: 7, Connection: connection, Operation: operation, Registration: testRegistration(), Policy: testPolicy()}, 111}, | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // TestMembershipRequestByteRoundTrip proves each mutating request travels on | |
| 45 | + | // its frozen tag and survives an encode/decode/re-encode cycle byte for byte, | |
| 46 | + | // including the generation that fences a removal and the full policy. | |
| 47 | + | func TestMembershipRequestByteRoundTrip(t *testing.T) { | |
| 48 | + | connection := testConnectionID(0x21) | |
| 49 | + | operation := testOperationID(0x31) | |
| 50 | + | for _, tc := range membershipRequests(connection, operation) { | |
| 51 | + | t.Run(tc.name, func(t *testing.T) { | |
| 52 | + | raw := mustEncodeClient(t, tc.message) | |
| 53 | + | number, wireType, consumed := protowire.ConsumeTag(raw) | |
| 54 | + | require.Greater(t, consumed, 0) | |
| 55 | + | require.Equal(t, tc.tag, number) | |
| 56 | + | require.Equal(t, protowire.BytesType, wireType) | |
| 57 | + | ||
| 58 | + | decoded, err := DecodeClient(raw, testEnvelopeCeiling, testChunkCeiling) | |
| 59 | + | require.NoError(t, err) | |
| 60 | + | require.Equal(t, tc.message, decoded) | |
| 61 | + | ||
| 62 | + | again, err := EncodeClient(decoded, testEnvelopeCeiling, testChunkCeiling) | |
| 63 | + | require.NoError(t, err) | |
| 64 | + | require.Equal(t, raw, again, "re-encode must be byte-for-byte identical") | |
| 65 | + | }) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // TestMembershipResultByteRoundTrip proves every legal result shape round-trips | |
| 70 | + | // byte for byte: an authoritative success carrying its registration, an | |
| 71 | + | // idempotent removal success carrying none, and a failed result carrying only | |
| 72 | + | // its typed error. | |
| 73 | + | func TestMembershipResultByteRoundTrip(t *testing.T) { | |
| 74 | + | connection := testConnectionID(0x22) | |
| 75 | + | operation := testOperationID(0x32) | |
| 76 | + | messages := []struct { | |
| 77 | + | name string | |
| 78 | + | message OperationResult | |
| 79 | + | }{ | |
| 80 | + | {"add success carries registration", OperationResult{ | |
| 81 | + | Epoch: 7, Connection: connection, Operation: operation, | |
| 82 | + | Outcome: ports.BrokerOutcomeOK, Registration: testRegistration(), | |
| 83 | + | }}, | |
| 84 | + | {"update success carries advanced registration", OperationResult{ | |
| 85 | + | Epoch: 7, Connection: connection, Operation: operation, | |
| 86 | + | Outcome: ports.BrokerOutcomeOK, Registration: domain.RemoteRegistration{ | |
| 87 | + | Endpoint: "dev@host:22", Incarnation: testRegistration().Incarnation, Generation: 10, | |
| 88 | + | }, | |
| 89 | + | }}, | |
| 90 | + | {"remove success reports removal only", OperationResult{ | |
| 91 | + | Epoch: 7, Connection: connection, Operation: operation, | |
| 92 | + | Outcome: ports.BrokerOutcomeOK, Removed: true, | |
| 93 | + | }}, | |
| 94 | + | {"idempotent remove reports not removed", OperationResult{ | |
| 95 | + | Epoch: 7, Connection: connection, Operation: operation, | |
| 96 | + | Outcome: ports.BrokerOutcomeOK, | |
| 97 | + | }}, | |
| 98 | + | {"failed carries only its error", OperationResult{ | |
| 99 | + | Epoch: 7, Connection: connection, Operation: operation, | |
| 100 | + | Outcome: ports.BrokerOutcomeFailed, Error: testErrorDetail(), HasError: true, | |
| 101 | + | }}, | |
| 102 | + | {"unknown carries only its error", OperationResult{ | |
| 103 | + | Epoch: 7, Connection: connection, Operation: operation, | |
| 104 | + | Outcome: ports.BrokerOutcomeUnknown, | |
| 105 | + | Error: ErrorDetail{Code: ports.BrokerErrorOutcomeUnknown}, HasError: true, | |
| 106 | + | }}, | |
| 107 | + | } | |
| 108 | + | for _, tc := range messages { | |
| 109 | + | t.Run(tc.name, func(t *testing.T) { | |
| 110 | + | raw := mustEncodeServer(t, tc.message) | |
| 111 | + | number, wireType, consumed := protowire.ConsumeTag(raw) | |
| 112 | + | require.Greater(t, consumed, 0) | |
| 113 | + | require.Equal(t, protowire.Number(203), number) | |
| 114 | + | require.Equal(t, protowire.BytesType, wireType) | |
| 115 | + | ||
| 116 | + | decoded, err := DecodeServer(raw, testEnvelopeCeiling, testChunkCeiling) | |
| 117 | + | require.NoError(t, err) | |
| 118 | + | require.Equal(t, tc.message, decoded) | |
| 119 | + | ||
| 120 | + | again, err := EncodeServer(decoded, testEnvelopeCeiling, testChunkCeiling) | |
| 121 | + | require.NoError(t, err) | |
| 122 | + | require.Equal(t, raw, again, "re-encode must be byte-for-byte identical") | |
| 123 | + | }) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // TestMembershipExactGenerationPreserved proves the generation that fences a | |
| 128 | + | // remove/re-add race survives the wire exactly, including its extremes: a | |
| 129 | + | // generation is never widened, truncated, or re-derived from time. | |
| 130 | + | func TestMembershipExactGenerationPreserved(t *testing.T) { | |
| 131 | + | connection := testConnectionID(0x23) | |
| 132 | + | operation := testOperationID(0x33) | |
| 133 | + | base := testRegistration() | |
| 134 | + | for name, generation := range map[string]domain.RemoteGeneration{ | |
| 135 | + | "one": 1, | |
| 136 | + | "large": 1 << 40, | |
| 137 | + | "max uint64": math.MaxUint64, | |
| 138 | + | } { | |
| 139 | + | t.Run(name, func(t *testing.T) { | |
| 140 | + | registration := base | |
| 141 | + | registration.Generation = generation | |
| 142 | + | ||
| 143 | + | request := RemoveHost{Epoch: 7, Connection: connection, Operation: operation, Registration: registration} | |
| 144 | + | decoded, err := DecodeClient(mustEncodeClient(t, request), testEnvelopeCeiling, testChunkCeiling) | |
| 145 | + | require.NoError(t, err) | |
| 146 | + | require.Equal(t, request, decoded) | |
| 147 | + | require.Equal(t, generation, decoded.(RemoveHost).Registration.Generation) | |
| 148 | + | ||
| 149 | + | update := UpdateHostPolicy{Epoch: 7, Connection: connection, Operation: operation, Registration: registration, Policy: testPolicy()} | |
| 150 | + | decodedUpdate, err := DecodeClient(mustEncodeClient(t, update), testEnvelopeCeiling, testChunkCeiling) | |
| 151 | + | require.NoError(t, err) | |
| 152 | + | require.Equal(t, update, decodedUpdate) | |
| 153 | + | require.Equal(t, generation, decodedUpdate.(UpdateHostPolicy).Registration.Generation) | |
| 154 | + | ||
| 155 | + | result := OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeOK, Registration: registration} | |
| 156 | + | decodedResult, err := DecodeServer(mustEncodeServer(t, result), testEnvelopeCeiling, testChunkCeiling) | |
| 157 | + | require.NoError(t, err) | |
| 158 | + | require.Equal(t, result, decodedResult) | |
| 159 | + | require.Equal(t, generation, decodedResult.(OperationResult).Registration.Generation) | |
| 160 | + | }) | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // TestMembershipWrongDirection proves direction stays structural: a mutating | |
| 165 | + | // request never decodes as server output and a result never decodes as a | |
| 166 | + | // request. Misuse is refused by the strict scanner (the tag is unknown to the | |
| 167 | + | // other envelope) before generated unmarshal; the typed direction refusal is | |
| 168 | + | // already covered by the closed directional interfaces and | |
| 169 | + | // TestBrokerWrongDirection. | |
| 170 | + | func TestMembershipWrongDirection(t *testing.T) { | |
| 171 | + | connection := testConnectionID(0x24) | |
| 172 | + | operation := testOperationID(0x34) | |
| 173 | + | for _, tc := range membershipRequests(connection, operation) { | |
| 174 | + | t.Run("request/"+tc.name, func(t *testing.T) { | |
| 175 | + | raw := mustEncodeClient(t, tc.message) | |
| 176 | + | decoded, err := DecodeServer(raw, testEnvelopeCeiling, testChunkCeiling) | |
| 177 | + | require.Error(t, err) | |
| 178 | + | require.Nil(t, decoded, "no server message may be decoded from a client frame") | |
| 179 | + | }) | |
| 180 | + | } | |
| 181 | + | result := OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeOK, Registration: testRegistration()} | |
| 182 | + | raw := mustEncodeServer(t, result) | |
| 183 | + | decoded, err := DecodeClient(raw, testEnvelopeCeiling, testChunkCeiling) | |
| 184 | + | require.Error(t, err) | |
| 185 | + | require.Nil(t, decoded, "no client message may be decoded from a server frame") | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // TestMembershipMissingAuthorityRefused proves an absent policy or an absent | |
| 189 | + | // registration is not authority: the decode path refuses both rather than | |
| 190 | + | // inventing a default, on encode and on receive. | |
| 191 | + | func TestMembershipMissingAuthorityRefused(t *testing.T) { | |
| 192 | + | connection := testConnectionID(0x25) | |
| 193 | + | operation := testOperationID(0x35) | |
| 194 | + | ||
| 195 | + | clearFields := func(t *testing.T, message ClientMessage, clear func(*wire.BrokerClientEnvelope)) []byte { | |
| 196 | + | t.Helper() | |
| 197 | + | raw := mustEncodeClient(t, message) | |
| 198 | + | envelope := &wire.BrokerClientEnvelope{} | |
| 199 | + | require.NoError(t, wire.ScanEnvelope(envelope, raw)) | |
| 200 | + | require.NoError(t, proto.Unmarshal(raw, envelope)) | |
| 201 | + | clear(envelope) | |
| 202 | + | mutated, err := proto.Marshal(envelope) | |
| 203 | + | require.NoError(t, err) | |
| 204 | + | return mutated | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | t.Run("add_host without policy", func(t *testing.T) { | |
| 208 | + | _, err := EncodeClient(AddHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22"}, testEnvelopeCeiling, testChunkCeiling) | |
| 209 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 210 | + | mutated := clearFields(t, AddHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22", Policy: testPolicy()}, func(envelope *wire.BrokerClientEnvelope) { | |
| 211 | + | envelope.GetAddHost().Policy = nil | |
| 212 | + | }) | |
| 213 | + | _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling) | |
| 214 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 215 | + | }) | |
| 216 | + | ||
| 217 | + | t.Run("add_host with invalid policy", func(t *testing.T) { | |
| 218 | + | _, err := EncodeClient(AddHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22", Policy: ports.BrokerPolicy{}}, testEnvelopeCeiling, testChunkCeiling) | |
| 219 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 220 | + | mutated := clearFields(t, AddHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22", Policy: testPolicy()}, func(envelope *wire.BrokerClientEnvelope) { | |
| 221 | + | envelope.GetAddHost().GetPolicy().EnvironmentPolicy = 99 | |
| 222 | + | }) | |
| 223 | + | _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling) | |
| 224 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 225 | + | }) | |
| 226 | + | ||
| 227 | + | t.Run("remove_host without registration", func(t *testing.T) { | |
| 228 | + | _, err := EncodeClient(RemoveHost{Epoch: 7, Connection: connection, Operation: operation}, testEnvelopeCeiling, testChunkCeiling) | |
| 229 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 230 | + | mutated := clearFields(t, RemoveHost{Epoch: 7, Connection: connection, Operation: operation, Registration: testRegistration()}, func(envelope *wire.BrokerClientEnvelope) { | |
| 231 | + | envelope.GetRemoveHost().Registration = nil | |
| 232 | + | }) | |
| 233 | + | _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling) | |
| 234 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 235 | + | }) | |
| 236 | + | ||
| 237 | + | t.Run("remove_host with partial registration", func(t *testing.T) { | |
| 238 | + | partial := testRegistration() | |
| 239 | + | partial.Incarnation = [16]byte{} | |
| 240 | + | _, err := EncodeClient(RemoveHost{Epoch: 7, Connection: connection, Operation: operation, Registration: partial}, testEnvelopeCeiling, testChunkCeiling) | |
| 241 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 242 | + | ||
| 243 | + | shortIncarnation := clearFields(t, RemoveHost{Epoch: 7, Connection: connection, Operation: operation, Registration: testRegistration()}, func(envelope *wire.BrokerClientEnvelope) { | |
| 244 | + | envelope.GetRemoveHost().GetRegistration().Incarnation = []byte{1, 2, 3} | |
| 245 | + | }) | |
| 246 | + | _, err = DecodeClient(shortIncarnation, testEnvelopeCeiling, testChunkCeiling) | |
| 247 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 248 | + | ||
| 249 | + | zeroGeneration := clearFields(t, RemoveHost{Epoch: 7, Connection: connection, Operation: operation, Registration: testRegistration()}, func(envelope *wire.BrokerClientEnvelope) { | |
| 250 | + | envelope.GetRemoveHost().GetRegistration().Generation = 0 | |
| 251 | + | }) | |
| 252 | + | _, err = DecodeClient(zeroGeneration, testEnvelopeCeiling, testChunkCeiling) | |
| 253 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 254 | + | ||
| 255 | + | badEndpoint := clearFields(t, RemoveHost{Epoch: 7, Connection: connection, Operation: operation, Registration: testRegistration()}, func(envelope *wire.BrokerClientEnvelope) { | |
| 256 | + | envelope.GetRemoveHost().GetRegistration().Endpoint = "two words" | |
| 257 | + | }) | |
| 258 | + | _, err = DecodeClient(badEndpoint, testEnvelopeCeiling, testChunkCeiling) | |
| 259 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 260 | + | }) | |
| 261 | + | ||
| 262 | + | t.Run("update_host_policy missing registration or policy", func(t *testing.T) { | |
| 263 | + | _, err := EncodeClient(UpdateHostPolicy{Epoch: 7, Connection: connection, Operation: operation, Policy: testPolicy()}, testEnvelopeCeiling, testChunkCeiling) | |
| 264 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 265 | + | _, err = EncodeClient(UpdateHostPolicy{Epoch: 7, Connection: connection, Operation: operation, Registration: testRegistration()}, testEnvelopeCeiling, testChunkCeiling) | |
| 266 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 267 | + | ||
| 268 | + | update := UpdateHostPolicy{Epoch: 7, Connection: connection, Operation: operation, Registration: testRegistration(), Policy: testPolicy()} | |
| 269 | + | missingPolicy := clearFields(t, update, func(envelope *wire.BrokerClientEnvelope) { | |
| 270 | + | envelope.GetUpdateHostPolicy().Policy = nil | |
| 271 | + | }) | |
| 272 | + | _, err = DecodeClient(missingPolicy, testEnvelopeCeiling, testChunkCeiling) | |
| 273 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 274 | + | missingRegistration := clearFields(t, update, func(envelope *wire.BrokerClientEnvelope) { | |
| 275 | + | envelope.GetUpdateHostPolicy().Registration = nil | |
| 276 | + | }) | |
| 277 | + | _, err = DecodeClient(missingRegistration, testEnvelopeCeiling, testChunkCeiling) | |
| 278 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 279 | + | }) | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | // TestMembershipResultPresenceMismatchRefused proves the result presence rules | |
| 283 | + | // fail closed in both directions of the codec: removal never travels with a | |
| 284 | + | // registration, a failed or outcome-unknown result carries neither authority | |
| 285 | + | // and must carry its error, and a success carries no error. | |
| 286 | + | func TestMembershipResultPresenceMismatchRefused(t *testing.T) { | |
| 287 | + | connection := testConnectionID(0x26) | |
| 288 | + | operation := testOperationID(0x36) | |
| 289 | + | clearRegistration := func(t *testing.T) []byte { | |
| 290 | + | t.Helper() | |
| 291 | + | raw := mustEncodeServer(t, OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeOK, Registration: testRegistration()}) | |
| 292 | + | envelope := &wire.BrokerServerEnvelope{} | |
| 293 | + | require.NoError(t, wire.ScanEnvelope(envelope, raw)) | |
| 294 | + | require.NoError(t, proto.Unmarshal(raw, envelope)) | |
| 295 | + | envelope.GetOperationResult().Registration.Generation = 0 | |
| 296 | + | mutated, err := proto.Marshal(envelope) | |
| 297 | + | require.NoError(t, err) | |
| 298 | + | return mutated | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | for name, message := range map[string]OperationResult{ | |
| 302 | + | "removed and registration together": { | |
| 303 | + | Epoch: 7, Connection: connection, Operation: operation, | |
| 304 | + | Outcome: ports.BrokerOutcomeOK, Removed: true, Registration: testRegistration(), | |
| 305 | + | }, | |
| 306 | + | "failed with registration": { | |
| 307 | + | Epoch: 7, Connection: connection, Operation: operation, | |
| 308 | + | Outcome: ports.BrokerOutcomeFailed, Registration: testRegistration(), Error: testErrorDetail(), HasError: true, | |
| 309 | + | }, | |
| 310 | + | "failed with removal": { | |
| 311 | + | Epoch: 7, Connection: connection, Operation: operation, | |
| 312 | + | Outcome: ports.BrokerOutcomeFailed, Removed: true, Error: testErrorDetail(), HasError: true, | |
| 313 | + | }, | |
| 314 | + | "failed without error": { | |
| 315 | + | Epoch: 7, Connection: connection, Operation: operation, | |
| 316 | + | Outcome: ports.BrokerOutcomeFailed, | |
| 317 | + | }, | |
| 318 | + | "unknown without error": { | |
| 319 | + | Epoch: 7, Connection: connection, Operation: operation, | |
| 320 | + | Outcome: ports.BrokerOutcomeUnknown, | |
| 321 | + | }, | |
| 322 | + | "success with error": { | |
| 323 | + | Epoch: 7, Connection: connection, Operation: operation, | |
| 324 | + | Outcome: ports.BrokerOutcomeOK, Registration: testRegistration(), Error: testErrorDetail(), HasError: true, | |
| 325 | + | }, | |
| 326 | + | "success with invalid registration": { | |
| 327 | + | Epoch: 7, Connection: connection, Operation: operation, | |
| 328 | + | Outcome: ports.BrokerOutcomeOK, Registration: domain.RemoteRegistration{Endpoint: "dev@host:22"}, | |
| 329 | + | }, | |
| 330 | + | } { | |
| 331 | + | t.Run("encode/"+name, func(t *testing.T) { | |
| 332 | + | _, err := EncodeServer(message, testEnvelopeCeiling, testChunkCeiling) | |
| 333 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 334 | + | }) | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | t.Run("decode refuses a registration that cannot fence", func(t *testing.T) { | |
| 338 | + | _, err := DecodeServer(clearRegistration(t), testEnvelopeCeiling, testChunkCeiling) | |
| 339 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 340 | + | }) | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | // TestMembershipResultAuthorityPerMutationKind proves the per-kind rule names | |
| 344 | + | // the right authority for each request, so a removal success can never be read | |
| 345 | + | // as an addition and an addition success can never be read as a removal. | |
| 346 | + | func TestMembershipResultAuthorityPerMutationKind(t *testing.T) { | |
| 347 | + | connection := testConnectionID(0x27) | |
| 348 | + | operation := testOperationID(0x37) | |
| 349 | + | add := OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeOK, Registration: testRegistration()} | |
| 350 | + | remove := OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeOK, Removed: true} | |
| 351 | + | removeNoop := OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeOK} | |
| 352 | + | failed := OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeFailed, Error: testErrorDetail(), HasError: true} | |
| 353 | + | ||
| 354 | + | require.NoError(t, add.ValidateForMutation(MutationKindAddHost)) | |
| 355 | + | require.NoError(t, add.ValidateForMutation(MutationKindUpdateHostPolicy)) | |
| 356 | + | require.ErrorIs(t, add.ValidateForMutation(MutationKindRemoveHost), ErrInvalidMessage) | |
| 357 | + | ||
| 358 | + | require.NoError(t, remove.ValidateForMutation(MutationKindRemoveHost)) | |
| 359 | + | require.NoError(t, removeNoop.ValidateForMutation(MutationKindRemoveHost), "an absent host is an idempotent no-op success") | |
| 360 | + | require.ErrorIs(t, remove.ValidateForMutation(MutationKindAddHost), ErrInvalidMessage) | |
| 361 | + | require.ErrorIs(t, removeNoop.ValidateForMutation(MutationKindAddHost), ErrInvalidMessage, "an addition success must carry its registration") | |
| 362 | + | require.ErrorIs(t, removeNoop.ValidateForMutation(MutationKindUpdateHostPolicy), ErrInvalidMessage) | |
| 363 | + | ||
| 364 | + | for _, kind := range []RegisterMutationKind{MutationKindAddHost, MutationKindRemoveHost, MutationKindUpdateHostPolicy} { | |
| 365 | + | require.NoError(t, failed.ValidateForMutation(kind)) | |
| 366 | + | } | |
| 367 | + | require.ErrorIs(t, add.ValidateForMutation(RegisterMutationKind(0)), errConvertRange) | |
| 368 | + | require.ErrorIs(t, add.ValidateForMutation(RegisterMutationKind(9)), errConvertRange) | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | // TestMembershipErrorCodes proves the two membership codes travel and return | |
| 372 | + | // as the exact closed enumeration: 11 is host conflict and 12 is immutable | |
| 373 | + | // membership, on both the encode and decode paths. | |
| 374 | + | func TestMembershipErrorCodes(t *testing.T) { | |
| 375 | + | connection := testConnectionID(0x28) | |
| 376 | + | operation := testOperationID(0x38) | |
| 377 | + | require.Equal(t, ports.BrokerErrorCode(11), ports.BrokerErrorHostConflict) | |
| 378 | + | require.Equal(t, ports.BrokerErrorCode(12), ports.BrokerErrorMembershipImmutable) | |
| 379 | + | ||
| 380 | + | for name, code := range map[string]ports.BrokerErrorCode{ | |
| 381 | + | "host_conflict": ports.BrokerErrorHostConflict, | |
| 382 | + | "membership_immutable": ports.BrokerErrorMembershipImmutable, | |
| 383 | + | } { | |
| 384 | + | t.Run(name, func(t *testing.T) { | |
| 385 | + | message := OperationResult{ | |
| 386 | + | Epoch: 7, Connection: connection, Operation: operation, | |
| 387 | + | Outcome: ports.BrokerOutcomeFailed, | |
| 388 | + | Error: ErrorDetail{Code: code, Text: "refused"}, HasError: true, | |
| 389 | + | } | |
| 390 | + | raw := mustEncodeServer(t, message) | |
| 391 | + | decoded, err := DecodeServer(raw, testEnvelopeCeiling, testChunkCeiling) | |
| 392 | + | require.NoError(t, err) | |
| 393 | + | require.Equal(t, message, decoded) | |
| 394 | + | require.Equal(t, code, decoded.(OperationResult).Error.Code) | |
| 395 | + | ||
| 396 | + | envelope := &wire.BrokerServerEnvelope{} | |
| 397 | + | require.NoError(t, proto.Unmarshal(raw, envelope)) | |
| 398 | + | require.Equal(t, uint32(code), envelope.GetOperationResult().GetError().GetCode()) | |
| 399 | + | }) | |
| 400 | + | } | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | // TestMembershipMalformedTruncatedTrailing proves the strict scanner runs | |
| 404 | + | // before generated unmarshal on the new variants too: unknown fields, | |
| 405 | + | // truncation, trailing garbage, and an oversize envelope are all refused. | |
| 406 | + | func TestMembershipMalformedTruncatedTrailing(t *testing.T) { | |
| 407 | + | connection := testConnectionID(0x29) | |
| 408 | + | operation := testOperationID(0x39) | |
| 409 | + | requests := membershipRequests(connection, operation) | |
| 410 | + | ||
| 411 | + | for _, tc := range requests { | |
| 412 | + | t.Run("truncated/"+tc.name, func(t *testing.T) { | |
| 413 | + | raw := mustEncodeClient(t, tc.message) | |
| 414 | + | for size := range len(raw) { | |
| 415 | + | _, err := DecodeClient(raw[:size], testEnvelopeCeiling, testChunkCeiling) | |
| 416 | + | require.Error(t, err, "prefix[:%d] accepted", size) | |
| 417 | + | } | |
| 418 | + | }) | |
| 419 | + | t.Run("trailing/"+tc.name, func(t *testing.T) { | |
| 420 | + | raw := mustEncodeClient(t, tc.message) | |
| 421 | + | _, err := DecodeClient(append(append([]byte(nil), raw...), 0xFF), testEnvelopeCeiling, testChunkCeiling) | |
| 422 | + | require.Error(t, err) | |
| 423 | + | concatenated := append(append([]byte(nil), raw...), raw...) | |
| 424 | + | _, err = DecodeClient(concatenated, testEnvelopeCeiling, testChunkCeiling) | |
| 425 | + | require.ErrorIs(t, err, wire.ErrScanDuplicate) | |
| 426 | + | }) | |
| 427 | + | t.Run("oversize/"+tc.name, func(t *testing.T) { | |
| 428 | + | raw := mustEncodeClient(t, tc.message) | |
| 429 | + | _, err := DecodeClient(raw, uint64(len(raw)-1), testChunkCeiling) | |
| 430 | + | require.ErrorIs(t, err, wire.ErrScanLength) | |
| 431 | + | }) | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | t.Run("unknown nested field", func(t *testing.T) { | |
| 435 | + | // UpdateHostPolicy → RemoteRegistration with an undeclared field. | |
| 436 | + | registration := protowire.AppendTag(nil, 1, protowire.BytesType) | |
| 437 | + | registration = protowire.AppendBytes(registration, []byte("dev@host:22")) | |
| 438 | + | registration = protowire.AppendTag(registration, 9, protowire.VarintType) | |
| 439 | + | registration = protowire.AppendVarint(registration, 1) | |
| 440 | + | update := protowire.AppendTag(nil, 3, protowire.BytesType) // UpdateHostPolicy.registration | |
| 441 | + | update = protowire.AppendBytes(update, registration) | |
| 442 | + | outer := protowire.AppendTag(nil, 111, protowire.BytesType) // BrokerClientEnvelope.update_host_policy | |
| 443 | + | outer = protowire.AppendBytes(outer, update) | |
| 444 | + | _, err := DecodeClient(outer, testEnvelopeCeiling, testChunkCeiling) | |
| 445 | + | require.ErrorIs(t, err, wire.ErrScanUnknown) | |
| 446 | + | }) | |
| 447 | + | ||
| 448 | + | t.Run("unknown request field", func(t *testing.T) { | |
| 449 | + | raw := mustEncodeClient(t, AddHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22", Policy: testPolicy()}) | |
| 450 | + | envelope := &wire.BrokerClientEnvelope{} | |
| 451 | + | require.NoError(t, wire.ScanEnvelope(envelope, raw)) | |
| 452 | + | require.NoError(t, proto.Unmarshal(raw, envelope)) | |
| 453 | + | envelope.GetAddHost().Policy = nil | |
| 454 | + | mutated, err := proto.Marshal(envelope) | |
| 455 | + | require.NoError(t, err) | |
| 456 | + | mutated = protowire.AppendTag(mutated, 9, protowire.VarintType) | |
| 457 | + | mutated = protowire.AppendVarint(mutated, 1) | |
| 458 | + | _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling) | |
| 459 | + | require.ErrorIs(t, err, wire.ErrScanUnknown) | |
| 460 | + | }) | |
| 461 | + | ||
| 462 | + | t.Run("wrong wire type on the new tag", func(t *testing.T) { | |
| 463 | + | wrongType := protowire.AppendTag(nil, 111, protowire.VarintType) | |
| 464 | + | wrongType = protowire.AppendVarint(wrongType, 1) | |
| 465 | + | _, err := DecodeClient(wrongType, testEnvelopeCeiling, testChunkCeiling) | |
| 466 | + | require.ErrorIs(t, err, wire.ErrScanWireType) | |
| 467 | + | }) | |
| 468 | + | ||
| 469 | + | t.Run("result truncated", func(t *testing.T) { | |
| 470 | + | raw := mustEncodeServer(t, OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeOK, Registration: testRegistration()}) | |
| 471 | + | for size := range len(raw) { | |
| 472 | + | _, err := DecodeServer(raw[:size], testEnvelopeCeiling, testChunkCeiling) | |
| 473 | + | require.Error(t, err, "server prefix[:%d] accepted", size) | |
| 474 | + | } | |
| 475 | + | }) | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | // TestMembershipUnknownTaxonomyRefused proves an out-of-range request tag or a | |
| 479 | + | // result outcome outside the closed taxonomy is refused rather than coerced. | |
| 480 | + | func TestMembershipUnknownTaxonomyRefused(t *testing.T) { | |
| 481 | + | connection := testConnectionID(0x2A) | |
| 482 | + | operation := testOperationID(0x3A) | |
| 483 | + | raw := mustEncodeServer(t, OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeFailed, Error: testErrorDetail(), HasError: true}) | |
| 484 | + | envelope := &wire.BrokerServerEnvelope{} | |
| 485 | + | require.NoError(t, wire.ScanEnvelope(envelope, raw)) | |
| 486 | + | require.NoError(t, proto.Unmarshal(raw, envelope)) | |
| 487 | + | envelope.GetOperationResult().Outcome = 9 | |
| 488 | + | mutated, err := proto.Marshal(envelope) | |
| 489 | + | require.NoError(t, err) | |
| 490 | + | _, err = DecodeServer(mutated, testEnvelopeCeiling, testChunkCeiling) | |
| 491 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 492 | + | ||
| 493 | + | // A malformed error code on a result is refused like one on an error frame. | |
| 494 | + | envelope.GetOperationResult().Outcome = 2 | |
| 495 | + | envelope.GetOperationResult().GetError().Code = 99 | |
| 496 | + | mutated, err = proto.Marshal(envelope) | |
| 497 | + | require.NoError(t, err) | |
| 498 | + | _, err = DecodeServer(mutated, testEnvelopeCeiling, testChunkCeiling) | |
| 499 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 500 | + | } | |
M internal/adapters/brokerwire/messages.go +182 -17
| @@ -67,27 +67,48 @@ type Unsubscribe struct { | |||
| 67 | 67 | ||
| 68 | 68 | func (Unsubscribe) brokerClientMessage() {} | |
| 69 | 69 | ||
| 70 | - | // AddHost adds one configured host registration. Operation is the | |
| 71 | - | // idempotency identity of this mutating operation. | |
| 70 | + | // AddHost adds one configured host registration under an exact policy. | |
| 71 | + | // Operation is the idempotency identity of this mutating operation. Policy | |
| 72 | + | // is required authority: an absent or invalid policy is refused rather than | |
| 73 | + | // inherited, so a membership addition can never be authorized by a zero | |
| 74 | + | // policy. | |
| 72 | 75 | type AddHost struct { | |
| 73 | 76 | Epoch ports.BrokerEpoch | |
| 74 | 77 | Connection ports.BrokerConnectionID | |
| 75 | 78 | Operation ports.BrokerOperationID | |
| 76 | 79 | Endpoint string | |
| 80 | + | Policy ports.BrokerPolicy | |
| 77 | 81 | } | |
| 78 | 82 | ||
| 79 | 83 | func (AddHost) brokerClientMessage() {} | |
| 80 | 84 | ||
| 81 | - | // RemoveHost removes one configured host registration. | |
| 85 | + | // RemoveHost removes exactly one configured host registration: the request | |
| 86 | + | // carries the full expected authority (endpoint, incarnation, and | |
| 87 | + | // generation), never a bare endpoint name, so a stale removal can never | |
| 88 | + | // retire a re-added host. | |
| 82 | 89 | type RemoveHost struct { | |
| 83 | - | Epoch ports.BrokerEpoch | |
| 84 | - | Connection ports.BrokerConnectionID | |
| 85 | - | Operation ports.BrokerOperationID | |
| 86 | - | Endpoint string | |
| 90 | + | Epoch ports.BrokerEpoch | |
| 91 | + | Connection ports.BrokerConnectionID | |
| 92 | + | Operation ports.BrokerOperationID | |
| 93 | + | Registration domain.RemoteRegistration | |
| 87 | 94 | } | |
| 88 | 95 | ||
| 89 | 96 | func (RemoveHost) brokerClientMessage() {} | |
| 90 | 97 | ||
| 98 | + | // UpdateHostPolicy replaces the policy of exactly one configured | |
| 99 | + | // registration. Registration is the exact expected authority and Policy is | |
| 100 | + | // the required replacement, so an update can neither target an endpoint | |
| 101 | + | // name nor be authorized by a zero policy. | |
| 102 | + | type UpdateHostPolicy struct { | |
| 103 | + | Epoch ports.BrokerEpoch | |
| 104 | + | Connection ports.BrokerConnectionID | |
| 105 | + | Operation ports.BrokerOperationID | |
| 106 | + | Registration domain.RemoteRegistration | |
| 107 | + | Policy ports.BrokerPolicy | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func (UpdateHostPolicy) brokerClientMessage() {} | |
| 111 | + | ||
| 91 | 112 | // Reconcile hands the broker one exact registration to probe. | |
| 92 | 113 | type Reconcile struct { | |
| 93 | 114 | Epoch ports.BrokerEpoch | |
| @@ -216,21 +237,165 @@ type SnapshotEnd struct{} | |||
| 216 | 237 | func (SnapshotEnd) snapshotPartPayload() {} | |
| 217 | 238 | ||
| 218 | 239 | // OperationResult is exactly one completion for one admitted mutating | |
| 219 | - | // operation. Removed reports the host is gone; Error carries the typed | |
| 220 | - | // failure when Outcome is failed or outcome-unknown. HasError=false means no | |
| 221 | - | // detail at all: encode refuses a nonzero Error rather than dropping it. | |
| 240 | + | // operation. Removed and Registration are presence-carrying authority: a | |
| 241 | + | // successful addition or policy update carries the resulting registration, | |
| 242 | + | // a successful removal reports Removed and carries no registration, and a | |
| 243 | + | // failed or outcome-unknown result carries neither authority, only its | |
| 244 | + | // typed error. Error carries the typed failure when Outcome is failed or | |
| 245 | + | // outcome-unknown; HasError=false means no detail at all, and both a | |
| 246 | + | // nonzero Error under HasError=false and an error on a successful outcome | |
| 247 | + | // are refused rather than dropped. | |
| 222 | 248 | type OperationResult struct { | |
| 223 | - | Epoch ports.BrokerEpoch | |
| 224 | - | Connection ports.BrokerConnectionID | |
| 225 | - | Operation ports.BrokerOperationID | |
| 226 | - | Outcome ports.BrokerMutationOutcome | |
| 227 | - | Removed bool | |
| 228 | - | Error ErrorDetail | |
| 229 | - | HasError bool | |
| 249 | + | Epoch ports.BrokerEpoch | |
| 250 | + | Connection ports.BrokerConnectionID | |
| 251 | + | Operation ports.BrokerOperationID | |
| 252 | + | Outcome ports.BrokerMutationOutcome | |
| 253 | + | Removed bool | |
| 254 | + | Registration domain.RemoteRegistration | |
| 255 | + | Error ErrorDetail | |
| 256 | + | HasError bool | |
| 230 | 257 | } | |
| 231 | 258 | ||
| 232 | 259 | func (OperationResult) brokerServerMessage() {} | |
| 233 | 260 | ||
| 261 | + | // RegisterMutationKind is the closed taxonomy of membership mutations. A | |
| 262 | + | // result's required authority depends on which request produced it, so the | |
| 263 | + | // taxonomy is explicit instead of being re-derived from a message type at | |
| 264 | + | // each result site. | |
| 265 | + | type RegisterMutationKind uint8 | |
| 266 | + | ||
| 267 | + | const ( | |
| 268 | + | // MutationKindAddHost is an AddHost request: its success carries the | |
| 269 | + | // authoritative registration. | |
| 270 | + | MutationKindAddHost RegisterMutationKind = iota + 1 | |
| 271 | + | // MutationKindRemoveHost is a RemoveHost request: its success reports | |
| 272 | + | // removal and carries no registration. | |
| 273 | + | MutationKindRemoveHost | |
| 274 | + | // MutationKindUpdateHostPolicy is an UpdateHostPolicy request: its | |
| 275 | + | // success carries the advanced registration. | |
| 276 | + | MutationKindUpdateHostPolicy | |
| 277 | + | ) | |
| 278 | + | ||
| 279 | + | func (k RegisterMutationKind) String() string { | |
| 280 | + | switch k { | |
| 281 | + | case MutationKindAddHost: | |
| 282 | + | return "add_host" | |
| 283 | + | case MutationKindRemoveHost: | |
| 284 | + | return "remove_host" | |
| 285 | + | case MutationKindUpdateHostPolicy: | |
| 286 | + | return "update_host_policy" | |
| 287 | + | default: | |
| 288 | + | return "unknown" | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | // Validate rejects a kind outside the closed membership-mutation taxonomy. | |
| 293 | + | func (k RegisterMutationKind) Validate() error { | |
| 294 | + | switch k { | |
| 295 | + | case MutationKindAddHost, MutationKindRemoveHost, MutationKindUpdateHostPolicy: | |
| 296 | + | return nil | |
| 297 | + | default: | |
| 298 | + | return errConvertRange | |
| 299 | + | } | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | // MutationKindOf classifies one client message that mutates host membership. | |
| 303 | + | // It is the request-side authority a per-kind result rule needs; pending | |
| 304 | + | // operation tracking is deliberately not wired here. | |
| 305 | + | func MutationKindOf(message ClientMessage) (RegisterMutationKind, bool) { | |
| 306 | + | switch m := message.(type) { | |
| 307 | + | case AddHost: | |
| 308 | + | return MutationKindAddHost, true | |
| 309 | + | case *AddHost: | |
| 310 | + | if m == nil { | |
| 311 | + | return 0, false | |
| 312 | + | } | |
| 313 | + | return MutationKindAddHost, true | |
| 314 | + | case RemoveHost: | |
| 315 | + | return MutationKindRemoveHost, true | |
| 316 | + | case *RemoveHost: | |
| 317 | + | if m == nil { | |
| 318 | + | return 0, false | |
| 319 | + | } | |
| 320 | + | return MutationKindRemoveHost, true | |
| 321 | + | case UpdateHostPolicy: | |
| 322 | + | return MutationKindUpdateHostPolicy, true | |
| 323 | + | case *UpdateHostPolicy: | |
| 324 | + | if m == nil { | |
| 325 | + | return 0, false | |
| 326 | + | } | |
| 327 | + | return MutationKindUpdateHostPolicy, true | |
| 328 | + | default: | |
| 329 | + | return 0, false | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | // validateResultAuthority enforces the presence rules of one mutating result | |
| 334 | + | // that hold without knowing which request produced it: removal and | |
| 335 | + | // registration never travel together, a successful result never carries an | |
| 336 | + | // error, and a failed or outcome-unknown result carries neither authority, | |
| 337 | + | // only its typed error. The per-kind rule (an AddHost or UpdateHostPolicy | |
| 338 | + | // success must carry a registration, a RemoveHost success must not) is | |
| 339 | + | // enforced by ValidateForMutation at the owning site, because the stateless | |
| 340 | + | // codec has no pending-operation state to consult. A successful removal with | |
| 341 | + | // no authority is legal: an absent host is an idempotent no-op, not an error. | |
| 342 | + | func validateResultAuthority(outcome ports.BrokerMutationOutcome, removed, hasRegistration, hasError bool) error { | |
| 343 | + | if err := outcome.Validate(); err != nil { | |
| 344 | + | return ErrInvalidMessage | |
| 345 | + | } | |
| 346 | + | if removed && hasRegistration { | |
| 347 | + | return ErrInvalidMessage | |
| 348 | + | } | |
| 349 | + | if outcome == ports.BrokerOutcomeOK { | |
| 350 | + | if hasError { | |
| 351 | + | return ErrInvalidMessage | |
| 352 | + | } | |
| 353 | + | return nil | |
| 354 | + | } | |
| 355 | + | if removed || hasRegistration || !hasError { | |
| 356 | + | return ErrInvalidMessage | |
| 357 | + | } | |
| 358 | + | return nil | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | // ValidateForMutation enforces the full authority rule of one mutating result | |
| 362 | + | // against the kind of request it completes. AddHost and UpdateHostPolicy | |
| 363 | + | // succeed with the authoritative registration, RemoveHost succeeds with | |
| 364 | + | // removal and no registration, and every failed or outcome-unknown result | |
| 365 | + | // carries neither authority, only its validated typed error. | |
| 366 | + | func (r OperationResult) ValidateForMutation(kind RegisterMutationKind) error { | |
| 367 | + | if err := kind.Validate(); err != nil { | |
| 368 | + | return err | |
| 369 | + | } | |
| 370 | + | hasRegistration := r.Registration != (domain.RemoteRegistration{}) | |
| 371 | + | if err := validateResultAuthority(r.Outcome, r.Removed, hasRegistration, r.HasError); err != nil { | |
| 372 | + | return err | |
| 373 | + | } | |
| 374 | + | if r.HasError { | |
| 375 | + | if err := r.Error.validate(); err != nil { | |
| 376 | + | return err | |
| 377 | + | } | |
| 378 | + | } | |
| 379 | + | switch r.Outcome { | |
| 380 | + | case ports.BrokerOutcomeOK: | |
| 381 | + | switch kind { | |
| 382 | + | case MutationKindRemoveHost: | |
| 383 | + | if hasRegistration { | |
| 384 | + | return ErrInvalidMessage | |
| 385 | + | } | |
| 386 | + | default: | |
| 387 | + | if !hasRegistration { | |
| 388 | + | return ErrInvalidMessage | |
| 389 | + | } | |
| 390 | + | } | |
| 391 | + | default: | |
| 392 | + | if r.Removed || hasRegistration || !r.HasError { | |
| 393 | + | return ErrInvalidMessage | |
| 394 | + | } | |
| 395 | + | } | |
| 396 | + | return nil | |
| 397 | + | } | |
| 398 | + | ||
| 234 | 399 | // StreamOpened confirms one logical stream is established. | |
| 235 | 400 | type StreamOpened struct { | |
| 236 | 401 | Epoch ports.BrokerEpoch | |
M internal/ports/broker.go +14 -3
| @@ -563,6 +563,8 @@ const ( | |||
| 563 | 563 | BrokerErrorConflictingPolicy | |
| 564 | 564 | BrokerErrorExplicitExit | |
| 565 | 565 | BrokerErrorFatalTerminal | |
| 566 | + | BrokerErrorHostConflict | |
| 567 | + | BrokerErrorMembershipImmutable | |
| 566 | 568 | ) | |
| 567 | 569 | ||
| 568 | 570 | func (c BrokerErrorCode) String() string { | |
| @@ -587,6 +589,10 @@ func (c BrokerErrorCode) String() string { | |||
| 587 | 589 | return "explicit_exit" | |
| 588 | 590 | case BrokerErrorFatalTerminal: | |
| 589 | 591 | return "fatal_terminal" | |
| 592 | + | case BrokerErrorHostConflict: | |
| 593 | + | return "host_conflict" | |
| 594 | + | case BrokerErrorMembershipImmutable: | |
| 595 | + | return "membership_immutable" | |
| 590 | 596 | default: | |
| 591 | 597 | return "unknown" | |
| 592 | 598 | } | |
| @@ -597,7 +603,8 @@ func (c BrokerErrorCode) Validate() error { | |||
| 597 | 603 | case BrokerErrorUnavailable, BrokerErrorIncompatible, BrokerErrorTimeout, | |
| 598 | 604 | BrokerErrorCancelled, BrokerErrorOutcomeUnknown, BrokerErrorAttachmentLost, | |
| 599 | 605 | BrokerErrorStaleEpoch, BrokerErrorConflictingPolicy, | |
| 600 | - | BrokerErrorExplicitExit, BrokerErrorFatalTerminal: | |
| 606 | + | BrokerErrorExplicitExit, BrokerErrorFatalTerminal, | |
| 607 | + | BrokerErrorHostConflict, BrokerErrorMembershipImmutable: | |
| 601 | 608 | return nil | |
| 602 | 609 | default: | |
| 603 | 610 | return errors.New("ports: invalid broker error code") | |
| @@ -698,8 +705,12 @@ type BrokerService interface { | |||
| 698 | 705 | Subscribe() (BrokerSubscription, error) | |
| 699 | 706 | OpenStream(ctx context.Context, request BrokerOpenStreamRequest) (BrokerLogicalConnection, error) | |
| 700 | 707 | CloseStream(connection BrokerConnectionID, stream BrokerStreamID) error | |
| 701 | - | AddHost(ctx context.Context, target string) error | |
| 702 | - | RemoveHost(ctx context.Context, target string) (bool, error) | |
| 708 | + | // AddHost durably adds or pins endpoint under policy and returns its authority. | |
| 709 | + | AddHost(ctx context.Context, endpoint string, policy BrokerPolicy) (domain.RemoteRegistration, error) | |
| 710 | + | // RemoveHost removes only the exact expected registration. | |
| 711 | + | RemoveHost(ctx context.Context, expected domain.RemoteRegistration) (bool, error) | |
| 712 | + | // UpdateHostPolicy changes policy only for exact expected authority and returns the advanced registration. | |
| 713 | + | UpdateHostPolicy(ctx context.Context, expected domain.RemoteRegistration, policy BrokerPolicy) (domain.RemoteRegistration, error) | |
| 703 | 714 | RequestReconcile(endpoint string) | |
| 704 | 715 | Close() error | |
| 705 | 716 | } | |
M internal/ports/broker_test.go +2 -0
| @@ -510,6 +510,8 @@ func TestBrokerErrorTerminalOutcomes(t *testing.T) { | |||
| 510 | 510 | {code: BrokerErrorConflictingPolicy, wantTerminal: false}, | |
| 511 | 511 | {code: BrokerErrorExplicitExit, wantTerminal: true}, | |
| 512 | 512 | {code: BrokerErrorFatalTerminal, wantTerminal: true}, | |
| 513 | + | {code: BrokerErrorHostConflict, wantTerminal: false}, | |
| 514 | + | {code: BrokerErrorMembershipImmutable, wantTerminal: false}, | |
| 513 | 515 | } | |
| 514 | 516 | for _, tt := range tests { | |
| 515 | 517 | t.Run(tt.code.String(), func(t *testing.T) { | |
M internal/ports/mocks/broker_mock.go +116 -29
| @@ -146,20 +146,29 @@ func (_m *MockBrokerService) EXPECT() *MockBrokerService_Expecter { | |||
| 146 | 146 | } | |
| 147 | 147 | ||
| 148 | 148 | // AddHost provides a mock function for the type MockBrokerService | |
| 149 | - | func (_mock *MockBrokerService) AddHost(ctx context.Context, target string) error { | |
| 150 | - | ret := _mock.Called(ctx, target) | |
| 149 | + | func (_mock *MockBrokerService) AddHost(ctx context.Context, endpoint string, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) { | |
| 150 | + | ret := _mock.Called(ctx, endpoint, policy) | |
| 151 | 151 | ||
| 152 | 152 | if len(ret) == 0 { | |
| 153 | 153 | panic("no return value specified for AddHost") | |
| 154 | 154 | } | |
| 155 | 155 | ||
| 156 | - | var r0 error | |
| 157 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { | |
| 158 | - | r0 = returnFunc(ctx, target) | |
| 156 | + | var r0 domain.RemoteRegistration | |
| 157 | + | var r1 error | |
| 158 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, ports.BrokerPolicy) (domain.RemoteRegistration, error)); ok { | |
| 159 | + | return returnFunc(ctx, endpoint, policy) | |
| 160 | + | } | |
| 161 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, ports.BrokerPolicy) domain.RemoteRegistration); ok { | |
| 162 | + | r0 = returnFunc(ctx, endpoint, policy) | |
| 159 | 163 | } else { | |
| 160 | - | r0 = ret.Error(0) | |
| 164 | + | r0 = ret.Get(0).(domain.RemoteRegistration) | |
| 161 | 165 | } | |
| 162 | - | return r0 | |
| 166 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, ports.BrokerPolicy) error); ok { | |
| 167 | + | r1 = returnFunc(ctx, endpoint, policy) | |
| 168 | + | } else { | |
| 169 | + | r1 = ret.Error(1) | |
| 170 | + | } | |
| 171 | + | return r0, r1 | |
| 163 | 172 | } | |
| 164 | 173 | ||
| 165 | 174 | // MockBrokerService_AddHost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddHost' | |
| @@ -169,12 +178,13 @@ type MockBrokerService_AddHost_Call struct { | |||
| 169 | 178 | ||
| 170 | 179 | // AddHost is a helper method to define mock.On call | |
| 171 | 180 | // - ctx context.Context | |
| 172 | - | // - target string | |
| 173 | - | func (_e *MockBrokerService_Expecter) AddHost(ctx any, target any) *MockBrokerService_AddHost_Call { | |
| 174 | - | return &MockBrokerService_AddHost_Call{Call: _e.mock.On("AddHost", ctx, target)} | |
| 181 | + | // - endpoint string | |
| 182 | + | // - policy ports.BrokerPolicy | |
| 183 | + | func (_e *MockBrokerService_Expecter) AddHost(ctx any, endpoint any, policy any) *MockBrokerService_AddHost_Call { | |
| 184 | + | return &MockBrokerService_AddHost_Call{Call: _e.mock.On("AddHost", ctx, endpoint, policy)} | |
| 175 | 185 | } | |
| 176 | 186 | ||
| 177 | - | func (_c *MockBrokerService_AddHost_Call) Run(run func(ctx context.Context, target string)) *MockBrokerService_AddHost_Call { | |
| 187 | + | func (_c *MockBrokerService_AddHost_Call) Run(run func(ctx context.Context, endpoint string, policy ports.BrokerPolicy)) *MockBrokerService_AddHost_Call { | |
| 178 | 188 | _c.Call.Run(func(args mock.Arguments) { | |
| 179 | 189 | var arg0 context.Context | |
| 180 | 190 | if args[0] != nil { | |
| @@ -184,20 +194,25 @@ func (_c *MockBrokerService_AddHost_Call) Run(run func(ctx context.Context, targ | |||
| 184 | 194 | if args[1] != nil { | |
| 185 | 195 | arg1 = args[1].(string) | |
| 186 | 196 | } | |
| 197 | + | var arg2 ports.BrokerPolicy | |
| 198 | + | if args[2] != nil { | |
| 199 | + | arg2 = args[2].(ports.BrokerPolicy) | |
| 200 | + | } | |
| 187 | 201 | run( | |
| 188 | 202 | arg0, | |
| 189 | 203 | arg1, | |
| 204 | + | arg2, | |
| 190 | 205 | ) | |
| 191 | 206 | }) | |
| 192 | 207 | return _c | |
| 193 | 208 | } | |
| 194 | 209 | ||
| 195 | - | func (_c *MockBrokerService_AddHost_Call) Return(err error) *MockBrokerService_AddHost_Call { | |
| 196 | - | _c.Call.Return(err) | |
| 210 | + | func (_c *MockBrokerService_AddHost_Call) Return(remoteRegistration domain.RemoteRegistration, err error) *MockBrokerService_AddHost_Call { | |
| 211 | + | _c.Call.Return(remoteRegistration, err) | |
| 197 | 212 | return _c | |
| 198 | 213 | } | |
| 199 | 214 | ||
| 200 | - | func (_c *MockBrokerService_AddHost_Call) RunAndReturn(run func(ctx context.Context, target string) error) *MockBrokerService_AddHost_Call { | |
| 215 | + | func (_c *MockBrokerService_AddHost_Call) RunAndReturn(run func(ctx context.Context, endpoint string, policy ports.BrokerPolicy) (domain.RemoteRegistration, error)) *MockBrokerService_AddHost_Call { | |
| 201 | 216 | _c.Call.Return(run) | |
| 202 | 217 | return _c | |
| 203 | 218 | } | |
| @@ -508,8 +523,8 @@ func (_c *MockBrokerService_OpenStream_Call) RunAndReturn(run func(ctx context.C | |||
| 508 | 523 | } | |
| 509 | 524 | ||
| 510 | 525 | // RemoveHost provides a mock function for the type MockBrokerService | |
| 511 | - | func (_mock *MockBrokerService) RemoveHost(ctx context.Context, target string) (bool, error) { | |
| 512 | - | ret := _mock.Called(ctx, target) | |
| 526 | + | func (_mock *MockBrokerService) RemoveHost(ctx context.Context, expected domain.RemoteRegistration) (bool, error) { | |
| 527 | + | ret := _mock.Called(ctx, expected) | |
| 513 | 528 | ||
| 514 | 529 | if len(ret) == 0 { | |
| 515 | 530 | panic("no return value specified for RemoveHost") | |
| @@ -517,16 +532,16 @@ func (_mock *MockBrokerService) RemoveHost(ctx context.Context, target string) ( | |||
| 517 | 532 | ||
| 518 | 533 | var r0 bool | |
| 519 | 534 | var r1 error | |
| 520 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (bool, error)); ok { | |
| 521 | - | return returnFunc(ctx, target) | |
| 535 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RemoteRegistration) (bool, error)); ok { | |
| 536 | + | return returnFunc(ctx, expected) | |
| 522 | 537 | } | |
| 523 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) bool); ok { | |
| 524 | - | r0 = returnFunc(ctx, target) | |
| 538 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RemoteRegistration) bool); ok { | |
| 539 | + | r0 = returnFunc(ctx, expected) | |
| 525 | 540 | } else { | |
| 526 | 541 | r0 = ret.Get(0).(bool) | |
| 527 | 542 | } | |
| 528 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 529 | - | r1 = returnFunc(ctx, target) | |
| 543 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RemoteRegistration) error); ok { | |
| 544 | + | r1 = returnFunc(ctx, expected) | |
| 530 | 545 | } else { | |
| 531 | 546 | r1 = ret.Error(1) | |
| 532 | 547 | } | |
| @@ -540,20 +555,20 @@ type MockBrokerService_RemoveHost_Call struct { | |||
| 540 | 555 | ||
| 541 | 556 | // RemoveHost is a helper method to define mock.On call | |
| 542 | 557 | // - ctx context.Context | |
| 543 | - | // - target string | |
| 544 | - | func (_e *MockBrokerService_Expecter) RemoveHost(ctx any, target any) *MockBrokerService_RemoveHost_Call { | |
| 545 | - | return &MockBrokerService_RemoveHost_Call{Call: _e.mock.On("RemoveHost", ctx, target)} | |
| 558 | + | // - expected domain.RemoteRegistration | |
| 559 | + | func (_e *MockBrokerService_Expecter) RemoveHost(ctx any, expected any) *MockBrokerService_RemoveHost_Call { | |
| 560 | + | return &MockBrokerService_RemoveHost_Call{Call: _e.mock.On("RemoveHost", ctx, expected)} | |
| 546 | 561 | } | |
| 547 | 562 | ||
| 548 | - | func (_c *MockBrokerService_RemoveHost_Call) Run(run func(ctx context.Context, target string)) *MockBrokerService_RemoveHost_Call { | |
| 563 | + | func (_c *MockBrokerService_RemoveHost_Call) Run(run func(ctx context.Context, expected domain.RemoteRegistration)) *MockBrokerService_RemoveHost_Call { | |
| 549 | 564 | _c.Call.Run(func(args mock.Arguments) { | |
| 550 | 565 | var arg0 context.Context | |
| 551 | 566 | if args[0] != nil { | |
| 552 | 567 | arg0 = args[0].(context.Context) | |
| 553 | 568 | } | |
| 554 | - | var arg1 string | |
| 569 | + | var arg1 domain.RemoteRegistration | |
| 555 | 570 | if args[1] != nil { | |
| 556 | - | arg1 = args[1].(string) | |
| 571 | + | arg1 = args[1].(domain.RemoteRegistration) | |
| 557 | 572 | } | |
| 558 | 573 | run( | |
| 559 | 574 | arg0, | |
| @@ -568,7 +583,7 @@ func (_c *MockBrokerService_RemoveHost_Call) Return(b bool, err error) *MockBrok | |||
| 568 | 583 | return _c | |
| 569 | 584 | } | |
| 570 | 585 | ||
| 571 | - | func (_c *MockBrokerService_RemoveHost_Call) RunAndReturn(run func(ctx context.Context, target string) (bool, error)) *MockBrokerService_RemoveHost_Call { | |
| 586 | + | func (_c *MockBrokerService_RemoveHost_Call) RunAndReturn(run func(ctx context.Context, expected domain.RemoteRegistration) (bool, error)) *MockBrokerService_RemoveHost_Call { | |
| 572 | 587 | _c.Call.Return(run) | |
| 573 | 588 | return _c | |
| 574 | 589 | } | |
| @@ -712,6 +727,78 @@ func (_c *MockBrokerService_Subscribe_Call) RunAndReturn(run func() (ports.Broke | |||
| 712 | 727 | return _c | |
| 713 | 728 | } | |
| 714 | 729 | ||
| 730 | + | // UpdateHostPolicy provides a mock function for the type MockBrokerService | |
| 731 | + | func (_mock *MockBrokerService) UpdateHostPolicy(ctx context.Context, expected domain.RemoteRegistration, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) { | |
| 732 | + | ret := _mock.Called(ctx, expected, policy) | |
| 733 | + | ||
| 734 | + | if len(ret) == 0 { | |
| 735 | + | panic("no return value specified for UpdateHostPolicy") | |
| 736 | + | } | |
| 737 | + | ||
| 738 | + | var r0 domain.RemoteRegistration | |
| 739 | + | var r1 error | |
| 740 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RemoteRegistration, ports.BrokerPolicy) (domain.RemoteRegistration, error)); ok { | |
| 741 | + | return returnFunc(ctx, expected, policy) | |
| 742 | + | } | |
| 743 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RemoteRegistration, ports.BrokerPolicy) domain.RemoteRegistration); ok { | |
| 744 | + | r0 = returnFunc(ctx, expected, policy) | |
| 745 | + | } else { | |
| 746 | + | r0 = ret.Get(0).(domain.RemoteRegistration) | |
| 747 | + | } | |
| 748 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RemoteRegistration, ports.BrokerPolicy) error); ok { | |
| 749 | + | r1 = returnFunc(ctx, expected, policy) | |
| 750 | + | } else { | |
| 751 | + | r1 = ret.Error(1) | |
| 752 | + | } | |
| 753 | + | return r0, r1 | |
| 754 | + | } | |
| 755 | + | ||
| 756 | + | // MockBrokerService_UpdateHostPolicy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateHostPolicy' | |
| 757 | + | type MockBrokerService_UpdateHostPolicy_Call struct { | |
| 758 | + | *mock.Call | |
| 759 | + | } | |
| 760 | + | ||
| 761 | + | // UpdateHostPolicy is a helper method to define mock.On call | |
| 762 | + | // - ctx context.Context | |
| 763 | + | // - expected domain.RemoteRegistration | |
| 764 | + | // - policy ports.BrokerPolicy | |
| 765 | + | func (_e *MockBrokerService_Expecter) UpdateHostPolicy(ctx any, expected any, policy any) *MockBrokerService_UpdateHostPolicy_Call { | |
| 766 | + | return &MockBrokerService_UpdateHostPolicy_Call{Call: _e.mock.On("UpdateHostPolicy", ctx, expected, policy)} | |
| 767 | + | } | |
| 768 | + | ||
| 769 | + | func (_c *MockBrokerService_UpdateHostPolicy_Call) Run(run func(ctx context.Context, expected domain.RemoteRegistration, policy ports.BrokerPolicy)) *MockBrokerService_UpdateHostPolicy_Call { | |
| 770 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 771 | + | var arg0 context.Context | |
| 772 | + | if args[0] != nil { | |
| 773 | + | arg0 = args[0].(context.Context) | |
| 774 | + | } | |
| 775 | + | var arg1 domain.RemoteRegistration | |
| 776 | + | if args[1] != nil { | |
| 777 | + | arg1 = args[1].(domain.RemoteRegistration) | |
| 778 | + | } | |
| 779 | + | var arg2 ports.BrokerPolicy | |
| 780 | + | if args[2] != nil { | |
| 781 | + | arg2 = args[2].(ports.BrokerPolicy) | |
| 782 | + | } | |
| 783 | + | run( | |
| 784 | + | arg0, | |
| 785 | + | arg1, | |
| 786 | + | arg2, | |
| 787 | + | ) | |
| 788 | + | }) | |
| 789 | + | return _c | |
| 790 | + | } | |
| 791 | + | ||
| 792 | + | func (_c *MockBrokerService_UpdateHostPolicy_Call) Return(remoteRegistration domain.RemoteRegistration, err error) *MockBrokerService_UpdateHostPolicy_Call { | |
| 793 | + | _c.Call.Return(remoteRegistration, err) | |
| 794 | + | return _c | |
| 795 | + | } | |
| 796 | + | ||
| 797 | + | func (_c *MockBrokerService_UpdateHostPolicy_Call) RunAndReturn(run func(ctx context.Context, expected domain.RemoteRegistration, policy ports.BrokerPolicy) (domain.RemoteRegistration, error)) *MockBrokerService_UpdateHostPolicy_Call { | |
| 798 | + | _c.Call.Return(run) | |
| 799 | + | return _c | |
| 800 | + | } | |
| 801 | + | ||
| 715 | 802 | // NewMockBrokerSnapshotStore creates a new instance of MockBrokerSnapshotStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 716 | 803 | // The first argument is typically a *testing.T value. | |
| 717 | 804 | func NewMockBrokerSnapshotStore(t interface { | |
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 = 55 | |
| 19 | + | const Version uint16 = 56 | |
| 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(55), Version) | |
| 13 | + | require.Equal(t, uint16(56), Version) | |
| 14 | 14 | require.Equal(t, (16<<20)-407, MaxOutputDataLen) | |
| 15 | 15 | } | |
| 16 | 16 | ||
M internal/protocol/wire/broker.pb.go +289 -161
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | // Broker wire schema: the client/server broker conversations. | |
| 2 | 2 | // | |
| 3 | 3 | // The broker is an independently versioned conversation from the session | |
| 4 | - | // protocol: client tags 101-110 and server tags 201-209 are disjoint from | |
| 4 | + | // protocol: client tags 101-111 and server tags 201-209 are disjoint from | |
| 5 | 5 | // every session tag, and each direction is its own closed oneof union. | |
| 6 | 6 | // Scope (broker epoch plus client connection) is carried explicitly on | |
| 7 | 7 | // every payload so stream IDs and events never cross a scope. Semantic | |
| @@ -245,7 +245,7 @@ type BrokerErrorDetail struct { | |||
| 245 | 245 | // Closed taxonomy: 1 = unavailable, 2 = incompatible, 3 = timeout, | |
| 246 | 246 | // 4 = cancelled, 5 = outcome_unknown, 6 = attachment_lost, | |
| 247 | 247 | // 7 = stale_epoch, 8 = conflicting_policy, 9 = explicit_exit, | |
| 248 | - | // 10 = fatal_terminal. | |
| 248 | + | // 10 = fatal_terminal, 11 = host_conflict, 12 = membership_immutable. | |
| 249 | 249 | Code uint32 `protobuf:"varint,1,opt,name=code,proto3" json:"code,omitempty"` | |
| 250 | 250 | Text string `protobuf:"bytes,2,opt,name=text,proto3" json:"text,omitempty"` | |
| 251 | 251 | // Closed admission-refusal taxonomy; 0 = none. Codes are assigned by the | |
| @@ -387,6 +387,7 @@ type BrokerClientEnvelope struct { | |||
| 387 | 387 | // *BrokerClientEnvelope_OpenStream | |
| 388 | 388 | // *BrokerClientEnvelope_ClientStreamData | |
| 389 | 389 | // *BrokerClientEnvelope_CloseStream | |
| 390 | + | // *BrokerClientEnvelope_UpdateHostPolicy | |
| 390 | 391 | Payload isBrokerClientEnvelope_Payload `protobuf_oneof:"payload"` | |
| 391 | 392 | unknownFields protoimpl.UnknownFields | |
| 392 | 393 | sizeCache protoimpl.SizeCache | |
| @@ -519,6 +520,15 @@ func (x *BrokerClientEnvelope) GetCloseStream() *CloseStream { | |||
| 519 | 520 | return nil | |
| 520 | 521 | } | |
| 521 | 522 | ||
| 523 | + | func (x *BrokerClientEnvelope) GetUpdateHostPolicy() *UpdateHostPolicy { | |
| 524 | + | if x != nil { | |
| 525 | + | if x, ok := x.Payload.(*BrokerClientEnvelope_UpdateHostPolicy); ok { | |
| 526 | + | return x.UpdateHostPolicy | |
| 527 | + | } | |
| 528 | + | } | |
| 529 | + | return nil | |
| 530 | + | } | |
| 531 | + | ||
| 522 | 532 | type isBrokerClientEnvelope_Payload interface { | |
| 523 | 533 | isBrokerClientEnvelope_Payload() | |
| 524 | 534 | } | |
| @@ -563,6 +573,10 @@ type BrokerClientEnvelope_CloseStream struct { | |||
| 563 | 573 | CloseStream *CloseStream `protobuf:"bytes,110,opt,name=close_stream,json=closeStream,proto3,oneof"` | |
| 564 | 574 | } | |
| 565 | 575 | ||
| 576 | + | type BrokerClientEnvelope_UpdateHostPolicy struct { | |
| 577 | + | UpdateHostPolicy *UpdateHostPolicy `protobuf:"bytes,111,opt,name=update_host_policy,json=updateHostPolicy,proto3,oneof"` | |
| 578 | + | } | |
| 579 | + | ||
| 566 | 580 | func (*BrokerClientEnvelope_Register) isBrokerClientEnvelope_Payload() {} | |
| 567 | 581 | ||
| 568 | 582 | func (*BrokerClientEnvelope_Subscribe) isBrokerClientEnvelope_Payload() {} | |
| @@ -583,6 +597,8 @@ func (*BrokerClientEnvelope_ClientStreamData) isBrokerClientEnvelope_Payload() { | |||
| 583 | 597 | ||
| 584 | 598 | func (*BrokerClientEnvelope_CloseStream) isBrokerClientEnvelope_Payload() {} | |
| 585 | 599 | ||
| 600 | + | func (*BrokerClientEnvelope_UpdateHostPolicy) isBrokerClientEnvelope_Payload() {} | |
| 601 | + | ||
| 586 | 602 | // BrokerServerEnvelope is the closed server-to-client broker union. Exactly | |
| 587 | 603 | // one variant must be set per envelope. | |
| 588 | 604 | type BrokerServerEnvelope struct { | |
| @@ -977,12 +993,15 @@ func (x *Unsubscribe) GetGeneration() uint64 { | |||
| 977 | 993 | } | |
| 978 | 994 | ||
| 979 | 995 | // AddHost adds one configured host registration. operation_id is the | |
| 980 | - | // idempotency identity of this mutating operation. | |
| 996 | + | // idempotency identity of this mutating operation. policy is mandatory: | |
| 997 | + | // the request is refused unless it carries an exact valid policy, so a | |
| 998 | + | // membership addition can never be authorized by an absent policy. | |
| 981 | 999 | type AddHost struct { | |
| 982 | 1000 | state protoimpl.MessageState `protogen:"open.v1"` | |
| 983 | 1001 | Scope *BrokerScope `protobuf:"bytes,1,opt,name=scope,proto3" json:"scope,omitempty"` | |
| 984 | 1002 | OperationId []byte `protobuf:"bytes,2,opt,name=operation_id,json=operationId,proto3" json:"operation_id,omitempty"` // exactly 16 bytes | |
| 985 | 1003 | Endpoint string `protobuf:"bytes,3,opt,name=endpoint,proto3" json:"endpoint,omitempty"` | |
| 1004 | + | Policy *BrokerWirePolicy `protobuf:"bytes,4,opt,name=policy,proto3" json:"policy,omitempty"` | |
| 986 | 1005 | unknownFields protoimpl.UnknownFields | |
| 987 | 1006 | sizeCache protoimpl.SizeCache | |
| 988 | 1007 | } | |
| @@ -1038,12 +1057,22 @@ func (x *AddHost) GetEndpoint() string { | |||
| 1038 | 1057 | return "" | |
| 1039 | 1058 | } | |
| 1040 | 1059 | ||
| 1041 | - | // RemoveHost removes one configured host registration. | |
| 1060 | + | func (x *AddHost) GetPolicy() *BrokerWirePolicy { | |
| 1061 | + | if x != nil { | |
| 1062 | + | return x.Policy | |
| 1063 | + | } | |
| 1064 | + | return nil | |
| 1065 | + | } | |
| 1066 | + | ||
| 1067 | + | // RemoveHost removes exactly the registration carried by registration | |
| 1068 | + | // (endpoint, incarnation, and generation). A request whose authority does | |
| 1069 | + | // not exactly match the current registration is refused rather than | |
| 1070 | + | // applied to an endpoint name. | |
| 1042 | 1071 | type RemoveHost struct { | |
| 1043 | 1072 | state protoimpl.MessageState `protogen:"open.v1"` | |
| 1044 | 1073 | Scope *BrokerScope `protobuf:"bytes,1,opt,name=scope,proto3" json:"scope,omitempty"` | |
| 1045 | 1074 | OperationId []byte `protobuf:"bytes,2,opt,name=operation_id,json=operationId,proto3" json:"operation_id,omitempty"` // exactly 16 bytes | |
| 1046 | - | Endpoint string `protobuf:"bytes,3,opt,name=endpoint,proto3" json:"endpoint,omitempty"` | |
| 1075 | + | Registration *RemoteRegistration `protobuf:"bytes,3,opt,name=registration,proto3" json:"registration,omitempty"` | |
| 1047 | 1076 | unknownFields protoimpl.UnknownFields | |
| 1048 | 1077 | sizeCache protoimpl.SizeCache | |
| 1049 | 1078 | } | |
| @@ -1092,11 +1121,82 @@ func (x *RemoveHost) GetOperationId() []byte { | |||
| 1092 | 1121 | return nil | |
| 1093 | 1122 | } | |
| 1094 | 1123 | ||
| 1095 | - | func (x *RemoveHost) GetEndpoint() string { | |
| 1124 | + | func (x *RemoveHost) GetRegistration() *RemoteRegistration { | |
| 1096 | 1125 | if x != nil { | |
| 1097 | - | return x.Endpoint | |
| 1126 | + | return x.Registration | |
| 1098 | 1127 | } | |
| 1099 | - | return "" | |
| 1128 | + | return nil | |
| 1129 | + | } | |
| 1130 | + | ||
| 1131 | + | // UpdateHostPolicy replaces the policy of exactly the registration carried | |
| 1132 | + | // by registration and answers with the advanced registration authority. | |
| 1133 | + | // policy is mandatory: an absent policy is refused like an invalid one. | |
| 1134 | + | type UpdateHostPolicy struct { | |
| 1135 | + | state protoimpl.MessageState `protogen:"open.v1"` | |
| 1136 | + | Scope *BrokerScope `protobuf:"bytes,1,opt,name=scope,proto3" json:"scope,omitempty"` | |
| 1137 | + | OperationId []byte `protobuf:"bytes,2,opt,name=operation_id,json=operationId,proto3" json:"operation_id,omitempty"` // exactly 16 bytes | |
| 1138 | + | Registration *RemoteRegistration `protobuf:"bytes,3,opt,name=registration,proto3" json:"registration,omitempty"` | |
| 1139 | + | Policy *BrokerWirePolicy `protobuf:"bytes,4,opt,name=policy,proto3" json:"policy,omitempty"` | |
| 1140 | + | unknownFields protoimpl.UnknownFields | |
| 1141 | + | sizeCache protoimpl.SizeCache | |
| 1142 | + | } | |
| 1143 | + | ||
| 1144 | + | func (x *UpdateHostPolicy) Reset() { | |
| 1145 | + | *x = UpdateHostPolicy{} | |
| 1146 | + | mi := &file_broker_proto_msgTypes[13] | |
| 1147 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 1148 | + | ms.StoreMessageInfo(mi) | |
| 1149 | + | } | |
| 1150 | + | ||
| 1151 | + | func (x *UpdateHostPolicy) String() string { | |
| 1152 | + | return protoimpl.X.MessageStringOf(x) | |
| 1153 | + | } | |
| 1154 | + | ||
| 1155 | + | func (*UpdateHostPolicy) ProtoMessage() {} | |
| 1156 | + | ||
| 1157 | + | func (x *UpdateHostPolicy) ProtoReflect() protoreflect.Message { | |
| 1158 | + | mi := &file_broker_proto_msgTypes[13] | |
| 1159 | + | if x != nil { | |
| 1160 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 1161 | + | if ms.LoadMessageInfo() == nil { | |
| 1162 | + | ms.StoreMessageInfo(mi) | |
| 1163 | + | } | |
| 1164 | + | return ms | |
| 1165 | + | } | |
| 1166 | + | return mi.MessageOf(x) | |
| 1167 | + | } | |
| 1168 | + | ||
| 1169 | + | // Deprecated: Use UpdateHostPolicy.ProtoReflect.Descriptor instead. | |
| 1170 | + | func (*UpdateHostPolicy) Descriptor() ([]byte, []int) { | |
| 1171 | + | return file_broker_proto_rawDescGZIP(), []int{13} | |
| 1172 | + | } | |
| 1173 | + | ||
| 1174 | + | func (x *UpdateHostPolicy) GetScope() *BrokerScope { | |
| 1175 | + | if x != nil { | |
| 1176 | + | return x.Scope | |
| 1177 | + | } | |
| 1178 | + | return nil | |
| 1179 | + | } | |
| 1180 | + | ||
| 1181 | + | func (x *UpdateHostPolicy) GetOperationId() []byte { | |
| 1182 | + | if x != nil { | |
| 1183 | + | return x.OperationId | |
| 1184 | + | } | |
| 1185 | + | return nil | |
| 1186 | + | } | |
| 1187 | + | ||
| 1188 | + | func (x *UpdateHostPolicy) GetRegistration() *RemoteRegistration { | |
| 1189 | + | if x != nil { | |
| 1190 | + | return x.Registration | |
| 1191 | + | } | |
| 1192 | + | return nil | |
| 1193 | + | } | |
| 1194 | + | ||
| 1195 | + | func (x *UpdateHostPolicy) GetPolicy() *BrokerWirePolicy { | |
| 1196 | + | if x != nil { | |
| 1197 | + | return x.Policy | |
| 1198 | + | } | |
| 1199 | + | return nil | |
| 1100 | 1200 | } | |
| 1101 | 1201 | ||
| 1102 | 1202 | // Reconcile hands the broker one exact registration to probe. | |
| @@ -1110,7 +1210,7 @@ type Reconcile struct { | |||
| 1110 | 1210 | ||
| 1111 | 1211 | func (x *Reconcile) Reset() { | |
| 1112 | 1212 | *x = Reconcile{} | |
| 1113 | - | mi := &file_broker_proto_msgTypes[13] | |
| 1213 | + | mi := &file_broker_proto_msgTypes[14] | |
| 1114 | 1214 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 1115 | 1215 | ms.StoreMessageInfo(mi) | |
| 1116 | 1216 | } | |
| @@ -1122,7 +1222,7 @@ func (x *Reconcile) String() string { | |||
| 1122 | 1222 | func (*Reconcile) ProtoMessage() {} | |
| 1123 | 1223 | ||
| 1124 | 1224 | func (x *Reconcile) ProtoReflect() protoreflect.Message { | |
| 1125 | - | mi := &file_broker_proto_msgTypes[13] | |
| 1225 | + | mi := &file_broker_proto_msgTypes[14] | |
| 1126 | 1226 | if x != nil { | |
| 1127 | 1227 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 1128 | 1228 | if ms.LoadMessageInfo() == nil { | |
| @@ -1135,7 +1235,7 @@ func (x *Reconcile) ProtoReflect() protoreflect.Message { | |||
| 1135 | 1235 | ||
| 1136 | 1236 | // Deprecated: Use Reconcile.ProtoReflect.Descriptor instead. | |
| 1137 | 1237 | func (*Reconcile) Descriptor() ([]byte, []int) { | |
| 1138 | - | return file_broker_proto_rawDescGZIP(), []int{13} | |
| 1238 | + | return file_broker_proto_rawDescGZIP(), []int{14} | |
| 1139 | 1239 | } | |
| 1140 | 1240 | ||
| 1141 | 1241 | func (x *Reconcile) GetScope() *BrokerScope { | |
| @@ -1184,7 +1284,7 @@ type OpenStream struct { | |||
| 1184 | 1284 | ||
| 1185 | 1285 | func (x *OpenStream) Reset() { | |
| 1186 | 1286 | *x = OpenStream{} | |
| 1187 | - | mi := &file_broker_proto_msgTypes[14] | |
| 1287 | + | mi := &file_broker_proto_msgTypes[15] | |
| 1188 | 1288 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 1189 | 1289 | ms.StoreMessageInfo(mi) | |
| 1190 | 1290 | } | |
| @@ -1196,7 +1296,7 @@ func (x *OpenStream) String() string { | |||
| 1196 | 1296 | func (*OpenStream) ProtoMessage() {} | |
| 1197 | 1297 | ||
| 1198 | 1298 | func (x *OpenStream) ProtoReflect() protoreflect.Message { | |
| 1199 | - | mi := &file_broker_proto_msgTypes[14] | |
| 1299 | + | mi := &file_broker_proto_msgTypes[15] | |
| 1200 | 1300 | if x != nil { | |
| 1201 | 1301 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 1202 | 1302 | if ms.LoadMessageInfo() == nil { | |
| @@ -1209,7 +1309,7 @@ func (x *OpenStream) ProtoReflect() protoreflect.Message { | |||
| 1209 | 1309 | ||
| 1210 | 1310 | // Deprecated: Use OpenStream.ProtoReflect.Descriptor instead. | |
| 1211 | 1311 | func (*OpenStream) Descriptor() ([]byte, []int) { | |
| 1212 | - | return file_broker_proto_rawDescGZIP(), []int{14} | |
| 1312 | + | return file_broker_proto_rawDescGZIP(), []int{15} | |
| 1213 | 1313 | } | |
| 1214 | 1314 | ||
| 1215 | 1315 | func (x *OpenStream) GetRef() *BrokerStreamRef { | |
| @@ -1293,7 +1393,7 @@ type ClientStreamData struct { | |||
| 1293 | 1393 | ||
| 1294 | 1394 | func (x *ClientStreamData) Reset() { | |
| 1295 | 1395 | *x = ClientStreamData{} | |
| 1296 | - | mi := &file_broker_proto_msgTypes[15] | |
| 1396 | + | mi := &file_broker_proto_msgTypes[16] | |
| 1297 | 1397 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 1298 | 1398 | ms.StoreMessageInfo(mi) | |
| 1299 | 1399 | } | |
| @@ -1305,7 +1405,7 @@ func (x *ClientStreamData) String() string { | |||
| 1305 | 1405 | func (*ClientStreamData) ProtoMessage() {} | |
| 1306 | 1406 | ||
| 1307 | 1407 | func (x *ClientStreamData) ProtoReflect() protoreflect.Message { | |
| 1308 | - | mi := &file_broker_proto_msgTypes[15] | |
| 1408 | + | mi := &file_broker_proto_msgTypes[16] | |
| 1309 | 1409 | if x != nil { | |
| 1310 | 1410 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 1311 | 1411 | if ms.LoadMessageInfo() == nil { | |
| @@ -1318,7 +1418,7 @@ func (x *ClientStreamData) ProtoReflect() protoreflect.Message { | |||
| 1318 | 1418 | ||
| 1319 | 1419 | // Deprecated: Use ClientStreamData.ProtoReflect.Descriptor instead. | |
| 1320 | 1420 | func (*ClientStreamData) Descriptor() ([]byte, []int) { | |
| 1321 | - | return file_broker_proto_rawDescGZIP(), []int{15} | |
| 1421 | + | return file_broker_proto_rawDescGZIP(), []int{16} | |
| 1322 | 1422 | } | |
| 1323 | 1423 | ||
| 1324 | 1424 | func (x *ClientStreamData) GetRef() *BrokerStreamRef { | |
| @@ -1345,7 +1445,7 @@ type CloseStream struct { | |||
| 1345 | 1445 | ||
| 1346 | 1446 | func (x *CloseStream) Reset() { | |
| 1347 | 1447 | *x = CloseStream{} | |
| 1348 | - | mi := &file_broker_proto_msgTypes[16] | |
| 1448 | + | mi := &file_broker_proto_msgTypes[17] | |
| 1349 | 1449 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 1350 | 1450 | ms.StoreMessageInfo(mi) | |
| 1351 | 1451 | } | |
| @@ -1357,7 +1457,7 @@ func (x *CloseStream) String() string { | |||
| 1357 | 1457 | func (*CloseStream) ProtoMessage() {} | |
| 1358 | 1458 | ||
| 1359 | 1459 | func (x *CloseStream) ProtoReflect() protoreflect.Message { | |
| 1360 | - | mi := &file_broker_proto_msgTypes[16] | |
| 1460 | + | mi := &file_broker_proto_msgTypes[17] | |
| 1361 | 1461 | if x != nil { | |
| 1362 | 1462 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 1363 | 1463 | if ms.LoadMessageInfo() == nil { | |
| @@ -1370,7 +1470,7 @@ func (x *CloseStream) ProtoReflect() protoreflect.Message { | |||
| 1370 | 1470 | ||
| 1371 | 1471 | // Deprecated: Use CloseStream.ProtoReflect.Descriptor instead. | |
| 1372 | 1472 | func (*CloseStream) Descriptor() ([]byte, []int) { | |
| 1373 | - | return file_broker_proto_rawDescGZIP(), []int{16} | |
| 1473 | + | return file_broker_proto_rawDescGZIP(), []int{17} | |
| 1374 | 1474 | } | |
| 1375 | 1475 | ||
| 1376 | 1476 | func (x *CloseStream) GetRef() *BrokerStreamRef { | |
| @@ -1390,7 +1490,7 @@ type Registered struct { | |||
| 1390 | 1490 | ||
| 1391 | 1491 | func (x *Registered) Reset() { | |
| 1392 | 1492 | *x = Registered{} | |
| 1393 | - | mi := &file_broker_proto_msgTypes[17] | |
| 1493 | + | mi := &file_broker_proto_msgTypes[18] | |
| 1394 | 1494 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 1395 | 1495 | ms.StoreMessageInfo(mi) | |
| 1396 | 1496 | } | |
| @@ -1402,7 +1502,7 @@ func (x *Registered) String() string { | |||
| 1402 | 1502 | func (*Registered) ProtoMessage() {} | |
| 1403 | 1503 | ||
| 1404 | 1504 | func (x *Registered) ProtoReflect() protoreflect.Message { | |
| 1405 | - | mi := &file_broker_proto_msgTypes[17] | |
| 1505 | + | mi := &file_broker_proto_msgTypes[18] | |
| 1406 | 1506 | if x != nil { | |
| 1407 | 1507 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 1408 | 1508 | if ms.LoadMessageInfo() == nil { | |
| @@ -1415,7 +1515,7 @@ func (x *Registered) ProtoReflect() protoreflect.Message { | |||
| 1415 | 1515 | ||
| 1416 | 1516 | // Deprecated: Use Registered.ProtoReflect.Descriptor instead. | |
| 1417 | 1517 | func (*Registered) Descriptor() ([]byte, []int) { | |
| 1418 | - | return file_broker_proto_rawDescGZIP(), []int{17} | |
| 1518 | + | return file_broker_proto_rawDescGZIP(), []int{18} | |
| 1419 | 1519 | } | |
| 1420 | 1520 | ||
| 1421 | 1521 | func (x *Registered) GetScope() *BrokerScope { | |
| @@ -1448,7 +1548,7 @@ type SnapshotPart struct { | |||
| 1448 | 1548 | ||
| 1449 | 1549 | func (x *SnapshotPart) Reset() { | |
| 1450 | 1550 | *x = SnapshotPart{} | |
| 1451 | - | mi := &file_broker_proto_msgTypes[18] | |
| 1551 | + | mi := &file_broker_proto_msgTypes[19] | |
| 1452 | 1552 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 1453 | 1553 | ms.StoreMessageInfo(mi) | |
| 1454 | 1554 | } | |
| @@ -1460,7 +1560,7 @@ func (x *SnapshotPart) String() string { | |||
| 1460 | 1560 | func (*SnapshotPart) ProtoMessage() {} | |
| 1461 | 1561 | ||
| 1462 | 1562 | func (x *SnapshotPart) ProtoReflect() protoreflect.Message { | |
| 1463 | - | mi := &file_broker_proto_msgTypes[18] | |
| 1563 | + | mi := &file_broker_proto_msgTypes[19] | |
| 1464 | 1564 | if x != nil { | |
| 1465 | 1565 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 1466 | 1566 | if ms.LoadMessageInfo() == nil { | |
| @@ -1473,7 +1573,7 @@ func (x *SnapshotPart) ProtoReflect() protoreflect.Message { | |||
| 1473 | 1573 | ||
| 1474 | 1574 | // Deprecated: Use SnapshotPart.ProtoReflect.Descriptor instead. | |
| 1475 | 1575 | func (*SnapshotPart) Descriptor() ([]byte, []int) { | |
| 1476 | - | return file_broker_proto_rawDescGZIP(), []int{18} | |
| 1576 | + | return file_broker_proto_rawDescGZIP(), []int{19} | |
| 1477 | 1577 | } | |
| 1478 | 1578 | ||
| 1479 | 1579 | func (x *SnapshotPart) GetScope() *BrokerScope { | |
| @@ -1606,7 +1706,7 @@ type SnapshotBegin struct { | |||
| 1606 | 1706 | ||
| 1607 | 1707 | func (x *SnapshotBegin) Reset() { | |
| 1608 | 1708 | *x = SnapshotBegin{} | |
| 1609 | - | mi := &file_broker_proto_msgTypes[19] | |
| 1709 | + | mi := &file_broker_proto_msgTypes[20] | |
| 1610 | 1710 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 1611 | 1711 | ms.StoreMessageInfo(mi) | |
| 1612 | 1712 | } | |
| @@ -1618,7 +1718,7 @@ func (x *SnapshotBegin) String() string { | |||
| 1618 | 1718 | func (*SnapshotBegin) ProtoMessage() {} | |
| 1619 | 1719 | ||
| 1620 | 1720 | func (x *SnapshotBegin) ProtoReflect() protoreflect.Message { | |
| 1621 | - | mi := &file_broker_proto_msgTypes[19] | |
| 1721 | + | mi := &file_broker_proto_msgTypes[20] | |
| 1622 | 1722 | if x != nil { | |
| 1623 | 1723 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 1624 | 1724 | if ms.LoadMessageInfo() == nil { | |
| @@ -1631,7 +1731,7 @@ func (x *SnapshotBegin) ProtoReflect() protoreflect.Message { | |||
| 1631 | 1731 | ||
| 1632 | 1732 | // Deprecated: Use SnapshotBegin.ProtoReflect.Descriptor instead. | |
| 1633 | 1733 | func (*SnapshotBegin) Descriptor() ([]byte, []int) { | |
| 1634 | - | return file_broker_proto_rawDescGZIP(), []int{19} | |
| 1734 | + | return file_broker_proto_rawDescGZIP(), []int{20} | |
| 1635 | 1735 | } | |
| 1636 | 1736 | ||
| 1637 | 1737 | func (x *SnapshotBegin) GetHostCount() uint32 { | |
| @@ -1706,7 +1806,7 @@ type SnapshotDaemon struct { | |||
| 1706 | 1806 | ||
| 1707 | 1807 | func (x *SnapshotDaemon) Reset() { | |
| 1708 | 1808 | *x = SnapshotDaemon{} | |
| 1709 | - | mi := &file_broker_proto_msgTypes[20] | |
| 1809 | + | mi := &file_broker_proto_msgTypes[21] | |
| 1710 | 1810 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 1711 | 1811 | ms.StoreMessageInfo(mi) | |
| 1712 | 1812 | } | |
| @@ -1718,7 +1818,7 @@ func (x *SnapshotDaemon) String() string { | |||
| 1718 | 1818 | func (*SnapshotDaemon) ProtoMessage() {} | |
| 1719 | 1819 | ||
| 1720 | 1820 | func (x *SnapshotDaemon) ProtoReflect() protoreflect.Message { | |
| 1721 | - | mi := &file_broker_proto_msgTypes[20] | |
| 1821 | + | mi := &file_broker_proto_msgTypes[21] | |
| 1722 | 1822 | if x != nil { | |
| 1723 | 1823 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 1724 | 1824 | if ms.LoadMessageInfo() == nil { | |
| @@ -1731,7 +1831,7 @@ func (x *SnapshotDaemon) ProtoReflect() protoreflect.Message { | |||
| 1731 | 1831 | ||
| 1732 | 1832 | // Deprecated: Use SnapshotDaemon.ProtoReflect.Descriptor instead. | |
| 1733 | 1833 | func (*SnapshotDaemon) Descriptor() ([]byte, []int) { | |
| 1734 | - | return file_broker_proto_rawDescGZIP(), []int{20} | |
| 1834 | + | return file_broker_proto_rawDescGZIP(), []int{21} | |
| 1735 | 1835 | } | |
| 1736 | 1836 | ||
| 1737 | 1837 | func (x *SnapshotDaemon) GetHostIndex() uint32 { | |
| @@ -1896,7 +1996,7 @@ type SnapshotSession struct { | |||
| 1896 | 1996 | ||
| 1897 | 1997 | func (x *SnapshotSession) Reset() { | |
| 1898 | 1998 | *x = SnapshotSession{} | |
| 1899 | - | mi := &file_broker_proto_msgTypes[21] | |
| 1999 | + | mi := &file_broker_proto_msgTypes[22] | |
| 1900 | 2000 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 1901 | 2001 | ms.StoreMessageInfo(mi) | |
| 1902 | 2002 | } | |
| @@ -1908,7 +2008,7 @@ func (x *SnapshotSession) String() string { | |||
| 1908 | 2008 | func (*SnapshotSession) ProtoMessage() {} | |
| 1909 | 2009 | ||
| 1910 | 2010 | func (x *SnapshotSession) ProtoReflect() protoreflect.Message { | |
| 1911 | - | mi := &file_broker_proto_msgTypes[21] | |
| 2011 | + | mi := &file_broker_proto_msgTypes[22] | |
| 1912 | 2012 | if x != nil { | |
| 1913 | 2013 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 1914 | 2014 | if ms.LoadMessageInfo() == nil { | |
| @@ -1921,7 +2021,7 @@ func (x *SnapshotSession) ProtoReflect() protoreflect.Message { | |||
| 1921 | 2021 | ||
| 1922 | 2022 | // Deprecated: Use SnapshotSession.ProtoReflect.Descriptor instead. | |
| 1923 | 2023 | func (*SnapshotSession) Descriptor() ([]byte, []int) { | |
| 1924 | - | return file_broker_proto_rawDescGZIP(), []int{21} | |
| 2024 | + | return file_broker_proto_rawDescGZIP(), []int{22} | |
| 1925 | 2025 | } | |
| 1926 | 2026 | ||
| 1927 | 2027 | func (x *SnapshotSession) GetHostIndex() uint32 { | |
| @@ -1965,7 +2065,7 @@ type SnapshotTombstone struct { | |||
| 1965 | 2065 | ||
| 1966 | 2066 | func (x *SnapshotTombstone) Reset() { | |
| 1967 | 2067 | *x = SnapshotTombstone{} | |
| 1968 | - | mi := &file_broker_proto_msgTypes[22] | |
| 2068 | + | mi := &file_broker_proto_msgTypes[23] | |
| 1969 | 2069 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 1970 | 2070 | ms.StoreMessageInfo(mi) | |
| 1971 | 2071 | } | |
| @@ -1977,7 +2077,7 @@ func (x *SnapshotTombstone) String() string { | |||
| 1977 | 2077 | func (*SnapshotTombstone) ProtoMessage() {} | |
| 1978 | 2078 | ||
| 1979 | 2079 | func (x *SnapshotTombstone) ProtoReflect() protoreflect.Message { | |
| 1980 | - | mi := &file_broker_proto_msgTypes[22] | |
| 2080 | + | mi := &file_broker_proto_msgTypes[23] | |
| 1981 | 2081 | if x != nil { | |
| 1982 | 2082 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 1983 | 2083 | if ms.LoadMessageInfo() == nil { | |
| @@ -1990,7 +2090,7 @@ func (x *SnapshotTombstone) ProtoReflect() protoreflect.Message { | |||
| 1990 | 2090 | ||
| 1991 | 2091 | // Deprecated: Use SnapshotTombstone.ProtoReflect.Descriptor instead. | |
| 1992 | 2092 | func (*SnapshotTombstone) Descriptor() ([]byte, []int) { | |
| 1993 | - | return file_broker_proto_rawDescGZIP(), []int{22} | |
| 2093 | + | return file_broker_proto_rawDescGZIP(), []int{23} | |
| 1994 | 2094 | } | |
| 1995 | 2095 | ||
| 1996 | 2096 | func (x *SnapshotTombstone) GetTombstoneIndex() uint32 { | |
| @@ -2023,7 +2123,7 @@ type SnapshotEnd struct { | |||
| 2023 | 2123 | ||
| 2024 | 2124 | func (x *SnapshotEnd) Reset() { | |
| 2025 | 2125 | *x = SnapshotEnd{} | |
| 2026 | - | mi := &file_broker_proto_msgTypes[23] | |
| 2126 | + | mi := &file_broker_proto_msgTypes[24] | |
| 2027 | 2127 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 2028 | 2128 | ms.StoreMessageInfo(mi) | |
| 2029 | 2129 | } | |
| @@ -2035,7 +2135,7 @@ func (x *SnapshotEnd) String() string { | |||
| 2035 | 2135 | func (*SnapshotEnd) ProtoMessage() {} | |
| 2036 | 2136 | ||
| 2037 | 2137 | func (x *SnapshotEnd) ProtoReflect() protoreflect.Message { | |
| 2038 | - | mi := &file_broker_proto_msgTypes[23] | |
| 2138 | + | mi := &file_broker_proto_msgTypes[24] | |
| 2039 | 2139 | if x != nil { | |
| 2040 | 2140 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 2041 | 2141 | if ms.LoadMessageInfo() == nil { | |
| @@ -2048,26 +2148,30 @@ func (x *SnapshotEnd) ProtoReflect() protoreflect.Message { | |||
| 2048 | 2148 | ||
| 2049 | 2149 | // Deprecated: Use SnapshotEnd.ProtoReflect.Descriptor instead. | |
| 2050 | 2150 | func (*SnapshotEnd) Descriptor() ([]byte, []int) { | |
| 2051 | - | return file_broker_proto_rawDescGZIP(), []int{23} | |
| 2151 | + | return file_broker_proto_rawDescGZIP(), []int{24} | |
| 2052 | 2152 | } | |
| 2053 | 2153 | ||
| 2054 | 2154 | // OperationResult is exactly one completion for one admitted mutating | |
| 2055 | - | // operation. | |
| 2155 | + | // operation. removed and registration are presence-carrying authority: | |
| 2156 | + | // an authoritative success carries the resulting registration, a removal | |
| 2157 | + | // success carries none, and a failed or outcome-unknown result carries | |
| 2158 | + | // neither removal nor registration, only its typed error. | |
| 2056 | 2159 | type OperationResult struct { | |
| 2057 | 2160 | state protoimpl.MessageState `protogen:"open.v1"` | |
| 2058 | 2161 | Scope *BrokerScope `protobuf:"bytes,1,opt,name=scope,proto3" json:"scope,omitempty"` | |
| 2059 | 2162 | OperationId []byte `protobuf:"bytes,2,opt,name=operation_id,json=operationId,proto3" json:"operation_id,omitempty"` // exactly 16 bytes | |
| 2060 | 2163 | // Closed taxonomy: 1 = ok, 2 = failed, 3 = outcome_unknown. | |
| 2061 | - | Outcome uint32 `protobuf:"varint,3,opt,name=outcome,proto3" json:"outcome,omitempty"` | |
| 2062 | - | Removed bool `protobuf:"varint,4,opt,name=removed,proto3" json:"removed,omitempty"` | |
| 2063 | - | Error *BrokerErrorDetail `protobuf:"bytes,5,opt,name=error,proto3" json:"error,omitempty"` | |
| 2164 | + | Outcome uint32 `protobuf:"varint,3,opt,name=outcome,proto3" json:"outcome,omitempty"` | |
| 2165 | + | Removed bool `protobuf:"varint,4,opt,name=removed,proto3" json:"removed,omitempty"` | |
| 2166 | + | Error *BrokerErrorDetail `protobuf:"bytes,5,opt,name=error,proto3" json:"error,omitempty"` | |
| 2167 | + | Registration *RemoteRegistration `protobuf:"bytes,6,opt,name=registration,proto3" json:"registration,omitempty"` | |
| 2064 | 2168 | unknownFields protoimpl.UnknownFields | |
| 2065 | 2169 | sizeCache protoimpl.SizeCache | |
| 2066 | 2170 | } | |
| 2067 | 2171 | ||
| 2068 | 2172 | func (x *OperationResult) Reset() { | |
| 2069 | 2173 | *x = OperationResult{} | |
| 2070 | - | mi := &file_broker_proto_msgTypes[24] | |
| 2174 | + | mi := &file_broker_proto_msgTypes[25] | |
| 2071 | 2175 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 2072 | 2176 | ms.StoreMessageInfo(mi) | |
| 2073 | 2177 | } | |
| @@ -2079,7 +2183,7 @@ func (x *OperationResult) String() string { | |||
| 2079 | 2183 | func (*OperationResult) ProtoMessage() {} | |
| 2080 | 2184 | ||
| 2081 | 2185 | func (x *OperationResult) ProtoReflect() protoreflect.Message { | |
| 2082 | - | mi := &file_broker_proto_msgTypes[24] | |
| 2186 | + | mi := &file_broker_proto_msgTypes[25] | |
| 2083 | 2187 | if x != nil { | |
| 2084 | 2188 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 2085 | 2189 | if ms.LoadMessageInfo() == nil { | |
| @@ -2092,7 +2196,7 @@ func (x *OperationResult) ProtoReflect() protoreflect.Message { | |||
| 2092 | 2196 | ||
| 2093 | 2197 | // Deprecated: Use OperationResult.ProtoReflect.Descriptor instead. | |
| 2094 | 2198 | func (*OperationResult) Descriptor() ([]byte, []int) { | |
| 2095 | - | return file_broker_proto_rawDescGZIP(), []int{24} | |
| 2199 | + | return file_broker_proto_rawDescGZIP(), []int{25} | |
| 2096 | 2200 | } | |
| 2097 | 2201 | ||
| 2098 | 2202 | func (x *OperationResult) GetScope() *BrokerScope { | |
| @@ -2130,6 +2234,13 @@ func (x *OperationResult) GetError() *BrokerErrorDetail { | |||
| 2130 | 2234 | return nil | |
| 2131 | 2235 | } | |
| 2132 | 2236 | ||
| 2237 | + | func (x *OperationResult) GetRegistration() *RemoteRegistration { | |
| 2238 | + | if x != nil { | |
| 2239 | + | return x.Registration | |
| 2240 | + | } | |
| 2241 | + | return nil | |
| 2242 | + | } | |
| 2243 | + | ||
| 2133 | 2244 | // StreamOpened confirms one logical stream is established. | |
| 2134 | 2245 | type StreamOpened struct { | |
| 2135 | 2246 | state protoimpl.MessageState `protogen:"open.v1"` | |
| @@ -2140,7 +2251,7 @@ type StreamOpened struct { | |||
| 2140 | 2251 | ||
| 2141 | 2252 | func (x *StreamOpened) Reset() { | |
| 2142 | 2253 | *x = StreamOpened{} | |
| 2143 | - | mi := &file_broker_proto_msgTypes[25] | |
| 2254 | + | mi := &file_broker_proto_msgTypes[26] | |
| 2144 | 2255 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 2145 | 2256 | ms.StoreMessageInfo(mi) | |
| 2146 | 2257 | } | |
| @@ -2152,7 +2263,7 @@ func (x *StreamOpened) String() string { | |||
| 2152 | 2263 | func (*StreamOpened) ProtoMessage() {} | |
| 2153 | 2264 | ||
| 2154 | 2265 | func (x *StreamOpened) ProtoReflect() protoreflect.Message { | |
| 2155 | - | mi := &file_broker_proto_msgTypes[25] | |
| 2266 | + | mi := &file_broker_proto_msgTypes[26] | |
| 2156 | 2267 | if x != nil { | |
| 2157 | 2268 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 2158 | 2269 | if ms.LoadMessageInfo() == nil { | |
| @@ -2165,7 +2276,7 @@ func (x *StreamOpened) ProtoReflect() protoreflect.Message { | |||
| 2165 | 2276 | ||
| 2166 | 2277 | // Deprecated: Use StreamOpened.ProtoReflect.Descriptor instead. | |
| 2167 | 2278 | func (*StreamOpened) Descriptor() ([]byte, []int) { | |
| 2168 | - | return file_broker_proto_rawDescGZIP(), []int{25} | |
| 2279 | + | return file_broker_proto_rawDescGZIP(), []int{26} | |
| 2169 | 2280 | } | |
| 2170 | 2281 | ||
| 2171 | 2282 | func (x *StreamOpened) GetRef() *BrokerStreamRef { | |
| @@ -2186,7 +2297,7 @@ type ServerStreamData struct { | |||
| 2186 | 2297 | ||
| 2187 | 2298 | func (x *ServerStreamData) Reset() { | |
| 2188 | 2299 | *x = ServerStreamData{} | |
| 2189 | - | mi := &file_broker_proto_msgTypes[26] | |
| 2300 | + | mi := &file_broker_proto_msgTypes[27] | |
| 2190 | 2301 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 2191 | 2302 | ms.StoreMessageInfo(mi) | |
| 2192 | 2303 | } | |
| @@ -2198,7 +2309,7 @@ func (x *ServerStreamData) String() string { | |||
| 2198 | 2309 | func (*ServerStreamData) ProtoMessage() {} | |
| 2199 | 2310 | ||
| 2200 | 2311 | func (x *ServerStreamData) ProtoReflect() protoreflect.Message { | |
| 2201 | - | mi := &file_broker_proto_msgTypes[26] | |
| 2312 | + | mi := &file_broker_proto_msgTypes[27] | |
| 2202 | 2313 | if x != nil { | |
| 2203 | 2314 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 2204 | 2315 | if ms.LoadMessageInfo() == nil { | |
| @@ -2211,7 +2322,7 @@ func (x *ServerStreamData) ProtoReflect() protoreflect.Message { | |||
| 2211 | 2322 | ||
| 2212 | 2323 | // Deprecated: Use ServerStreamData.ProtoReflect.Descriptor instead. | |
| 2213 | 2324 | func (*ServerStreamData) Descriptor() ([]byte, []int) { | |
| 2214 | - | return file_broker_proto_rawDescGZIP(), []int{26} | |
| 2325 | + | return file_broker_proto_rawDescGZIP(), []int{27} | |
| 2215 | 2326 | } | |
| 2216 | 2327 | ||
| 2217 | 2328 | func (x *ServerStreamData) GetRef() *BrokerStreamRef { | |
| @@ -2240,7 +2351,7 @@ type StreamClosed struct { | |||
| 2240 | 2351 | ||
| 2241 | 2352 | func (x *StreamClosed) Reset() { | |
| 2242 | 2353 | *x = StreamClosed{} | |
| 2243 | - | mi := &file_broker_proto_msgTypes[27] | |
| 2354 | + | mi := &file_broker_proto_msgTypes[28] | |
| 2244 | 2355 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 2245 | 2356 | ms.StoreMessageInfo(mi) | |
| 2246 | 2357 | } | |
| @@ -2252,7 +2363,7 @@ func (x *StreamClosed) String() string { | |||
| 2252 | 2363 | func (*StreamClosed) ProtoMessage() {} | |
| 2253 | 2364 | ||
| 2254 | 2365 | func (x *StreamClosed) ProtoReflect() protoreflect.Message { | |
| 2255 | - | mi := &file_broker_proto_msgTypes[27] | |
| 2366 | + | mi := &file_broker_proto_msgTypes[28] | |
| 2256 | 2367 | if x != nil { | |
| 2257 | 2368 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 2258 | 2369 | if ms.LoadMessageInfo() == nil { | |
| @@ -2265,7 +2376,7 @@ func (x *StreamClosed) ProtoReflect() protoreflect.Message { | |||
| 2265 | 2376 | ||
| 2266 | 2377 | // Deprecated: Use StreamClosed.ProtoReflect.Descriptor instead. | |
| 2267 | 2378 | func (*StreamClosed) Descriptor() ([]byte, []int) { | |
| 2268 | - | return file_broker_proto_rawDescGZIP(), []int{27} | |
| 2379 | + | return file_broker_proto_rawDescGZIP(), []int{28} | |
| 2269 | 2380 | } | |
| 2270 | 2381 | ||
| 2271 | 2382 | func (x *StreamClosed) GetRef() *BrokerStreamRef { | |
| @@ -2294,7 +2405,7 @@ type Progress struct { | |||
| 2294 | 2405 | ||
| 2295 | 2406 | func (x *Progress) Reset() { | |
| 2296 | 2407 | *x = Progress{} | |
| 2297 | - | mi := &file_broker_proto_msgTypes[28] | |
| 2408 | + | mi := &file_broker_proto_msgTypes[29] | |
| 2298 | 2409 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 2299 | 2410 | ms.StoreMessageInfo(mi) | |
| 2300 | 2411 | } | |
| @@ -2306,7 +2417,7 @@ func (x *Progress) String() string { | |||
| 2306 | 2417 | func (*Progress) ProtoMessage() {} | |
| 2307 | 2418 | ||
| 2308 | 2419 | func (x *Progress) ProtoReflect() protoreflect.Message { | |
| 2309 | - | mi := &file_broker_proto_msgTypes[28] | |
| 2420 | + | mi := &file_broker_proto_msgTypes[29] | |
| 2310 | 2421 | if x != nil { | |
| 2311 | 2422 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 2312 | 2423 | if ms.LoadMessageInfo() == nil { | |
| @@ -2319,7 +2430,7 @@ func (x *Progress) ProtoReflect() protoreflect.Message { | |||
| 2319 | 2430 | ||
| 2320 | 2431 | // Deprecated: Use Progress.ProtoReflect.Descriptor instead. | |
| 2321 | 2432 | func (*Progress) Descriptor() ([]byte, []int) { | |
| 2322 | - | return file_broker_proto_rawDescGZIP(), []int{28} | |
| 2433 | + | return file_broker_proto_rawDescGZIP(), []int{29} | |
| 2323 | 2434 | } | |
| 2324 | 2435 | ||
| 2325 | 2436 | func (x *Progress) GetRef() *BrokerStreamRef { | |
| @@ -2354,7 +2465,7 @@ type BrokerErrorMessage struct { | |||
| 2354 | 2465 | ||
| 2355 | 2466 | func (x *BrokerErrorMessage) Reset() { | |
| 2356 | 2467 | *x = BrokerErrorMessage{} | |
| 2357 | - | mi := &file_broker_proto_msgTypes[29] | |
| 2468 | + | mi := &file_broker_proto_msgTypes[30] | |
| 2358 | 2469 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 2359 | 2470 | ms.StoreMessageInfo(mi) | |
| 2360 | 2471 | } | |
| @@ -2366,7 +2477,7 @@ func (x *BrokerErrorMessage) String() string { | |||
| 2366 | 2477 | func (*BrokerErrorMessage) ProtoMessage() {} | |
| 2367 | 2478 | ||
| 2368 | 2479 | func (x *BrokerErrorMessage) ProtoReflect() protoreflect.Message { | |
| 2369 | - | mi := &file_broker_proto_msgTypes[29] | |
| 2480 | + | mi := &file_broker_proto_msgTypes[30] | |
| 2370 | 2481 | if x != nil { | |
| 2371 | 2482 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 2372 | 2483 | if ms.LoadMessageInfo() == nil { | |
| @@ -2379,7 +2490,7 @@ func (x *BrokerErrorMessage) ProtoReflect() protoreflect.Message { | |||
| 2379 | 2490 | ||
| 2380 | 2491 | // Deprecated: Use BrokerErrorMessage.ProtoReflect.Descriptor instead. | |
| 2381 | 2492 | func (*BrokerErrorMessage) Descriptor() ([]byte, []int) { | |
| 2382 | - | return file_broker_proto_rawDescGZIP(), []int{29} | |
| 2493 | + | return file_broker_proto_rawDescGZIP(), []int{30} | |
| 2383 | 2494 | } | |
| 2384 | 2495 | ||
| 2385 | 2496 | func (x *BrokerErrorMessage) GetScope() *BrokerScope { | |
| @@ -2409,7 +2520,7 @@ type Shutdown struct { | |||
| 2409 | 2520 | ||
| 2410 | 2521 | func (x *Shutdown) Reset() { | |
| 2411 | 2522 | *x = Shutdown{} | |
| 2412 | - | mi := &file_broker_proto_msgTypes[30] | |
| 2523 | + | mi := &file_broker_proto_msgTypes[31] | |
| 2413 | 2524 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 2414 | 2525 | ms.StoreMessageInfo(mi) | |
| 2415 | 2526 | } | |
| @@ -2421,7 +2532,7 @@ func (x *Shutdown) String() string { | |||
| 2421 | 2532 | func (*Shutdown) ProtoMessage() {} | |
| 2422 | 2533 | ||
| 2423 | 2534 | func (x *Shutdown) ProtoReflect() protoreflect.Message { | |
| 2424 | - | mi := &file_broker_proto_msgTypes[30] | |
| 2535 | + | mi := &file_broker_proto_msgTypes[31] | |
| 2425 | 2536 | if x != nil { | |
| 2426 | 2537 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 2427 | 2538 | if ms.LoadMessageInfo() == nil { | |
| @@ -2434,7 +2545,7 @@ func (x *Shutdown) ProtoReflect() protoreflect.Message { | |||
| 2434 | 2545 | ||
| 2435 | 2546 | // Deprecated: Use Shutdown.ProtoReflect.Descriptor instead. | |
| 2436 | 2547 | func (*Shutdown) Descriptor() ([]byte, []int) { | |
| 2437 | - | return file_broker_proto_rawDescGZIP(), []int{30} | |
| 2548 | + | return file_broker_proto_rawDescGZIP(), []int{31} | |
| 2438 | 2549 | } | |
| 2439 | 2550 | ||
| 2440 | 2551 | func (x *Shutdown) GetScope() *BrokerScope { | |
| @@ -2480,7 +2591,7 @@ type BrokerCatalogSession struct { | |||
| 2480 | 2591 | ||
| 2481 | 2592 | func (x *BrokerCatalogSession) Reset() { | |
| 2482 | 2593 | *x = BrokerCatalogSession{} | |
| 2483 | - | mi := &file_broker_proto_msgTypes[31] | |
| 2594 | + | mi := &file_broker_proto_msgTypes[32] | |
| 2484 | 2595 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 2485 | 2596 | ms.StoreMessageInfo(mi) | |
| 2486 | 2597 | } | |
| @@ -2492,7 +2603,7 @@ func (x *BrokerCatalogSession) String() string { | |||
| 2492 | 2603 | func (*BrokerCatalogSession) ProtoMessage() {} | |
| 2493 | 2604 | ||
| 2494 | 2605 | func (x *BrokerCatalogSession) ProtoReflect() protoreflect.Message { | |
| 2495 | - | mi := &file_broker_proto_msgTypes[31] | |
| 2606 | + | mi := &file_broker_proto_msgTypes[32] | |
| 2496 | 2607 | if x != nil { | |
| 2497 | 2608 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 2498 | 2609 | if ms.LoadMessageInfo() == nil { | |
| @@ -2505,7 +2616,7 @@ func (x *BrokerCatalogSession) ProtoReflect() protoreflect.Message { | |||
| 2505 | 2616 | ||
| 2506 | 2617 | // Deprecated: Use BrokerCatalogSession.ProtoReflect.Descriptor instead. | |
| 2507 | 2618 | func (*BrokerCatalogSession) Descriptor() ([]byte, []int) { | |
| 2508 | - | return file_broker_proto_rawDescGZIP(), []int{31} | |
| 2619 | + | return file_broker_proto_rawDescGZIP(), []int{32} | |
| 2509 | 2620 | } | |
| 2510 | 2621 | ||
| 2511 | 2622 | func (x *BrokerCatalogSession) GetLifecycleId() []byte { | |
| @@ -2585,7 +2696,7 @@ type BrokerCatalogTab struct { | |||
| 2585 | 2696 | ||
| 2586 | 2697 | func (x *BrokerCatalogTab) Reset() { | |
| 2587 | 2698 | *x = BrokerCatalogTab{} | |
| 2588 | - | mi := &file_broker_proto_msgTypes[32] | |
| 2699 | + | mi := &file_broker_proto_msgTypes[33] | |
| 2589 | 2700 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 2590 | 2701 | ms.StoreMessageInfo(mi) | |
| 2591 | 2702 | } | |
| @@ -2597,7 +2708,7 @@ func (x *BrokerCatalogTab) String() string { | |||
| 2597 | 2708 | func (*BrokerCatalogTab) ProtoMessage() {} | |
| 2598 | 2709 | ||
| 2599 | 2710 | func (x *BrokerCatalogTab) ProtoReflect() protoreflect.Message { | |
| 2600 | - | mi := &file_broker_proto_msgTypes[32] | |
| 2711 | + | mi := &file_broker_proto_msgTypes[33] | |
| 2601 | 2712 | if x != nil { | |
| 2602 | 2713 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 2603 | 2714 | if ms.LoadMessageInfo() == nil { | |
| @@ -2610,7 +2721,7 @@ func (x *BrokerCatalogTab) ProtoReflect() protoreflect.Message { | |||
| 2610 | 2721 | ||
| 2611 | 2722 | // Deprecated: Use BrokerCatalogTab.ProtoReflect.Descriptor instead. | |
| 2612 | 2723 | func (*BrokerCatalogTab) Descriptor() ([]byte, []int) { | |
| 2613 | - | return file_broker_proto_rawDescGZIP(), []int{32} | |
| 2724 | + | return file_broker_proto_rawDescGZIP(), []int{33} | |
| 2614 | 2725 | } | |
| 2615 | 2726 | ||
| 2616 | 2727 | func (x *BrokerCatalogTab) GetId() string { | |
| @@ -2674,7 +2785,7 @@ const file_broker_proto_rawDesc = "" + | |||
| 2674 | 2785 | "\ffailure_kind\x18\x04 \x01(\rR\vfailureKind\"A\n" + | |
| 2675 | 2786 | "\x0fBrokerTimestamp\x12\x18\n" + | |
| 2676 | 2787 | "\aseconds\x18\x01 \x01(\x03R\aseconds\x12\x14\n" + | |
| 2677 | - | "\x05nanos\x18\x02 \x01(\x05R\x05nanos\"\xec\x04\n" + | |
| 2788 | + | "\x05nanos\x18\x02 \x01(\x05R\x05nanos\"\xbb\x05\n" + | |
| 2678 | 2789 | "\x14BrokerClientEnvelope\x123\n" + | |
| 2679 | 2790 | "\bregister\x18e \x01(\v2\x15.vev.wire.v1.RegisterH\x00R\bregister\x126\n" + | |
| 2680 | 2791 | "\tsubscribe\x18f \x01(\v2\x16.vev.wire.v1.SubscribeH\x00R\tsubscribe\x12-\n" + | |
| @@ -2687,7 +2798,8 @@ const file_broker_proto_rawDesc = "" + | |||
| 2687 | 2798 | "\vopen_stream\x18l \x01(\v2\x17.vev.wire.v1.OpenStreamH\x00R\n" + | |
| 2688 | 2799 | "openStream\x12M\n" + | |
| 2689 | 2800 | "\x12client_stream_data\x18m \x01(\v2\x1d.vev.wire.v1.ClientStreamDataH\x00R\x10clientStreamData\x12=\n" + | |
| 2690 | - | "\fclose_stream\x18n \x01(\v2\x18.vev.wire.v1.CloseStreamH\x00R\vcloseStreamB\t\n" + | |
| 2801 | + | "\fclose_stream\x18n \x01(\v2\x18.vev.wire.v1.CloseStreamH\x00R\vcloseStream\x12M\n" + | |
| 2802 | + | "\x12update_host_policy\x18o \x01(\v2\x1d.vev.wire.v1.UpdateHostPolicyH\x00R\x10updateHostPolicyB\t\n" + | |
| 2691 | 2803 | "\apayload\"\x84\x05\n" + | |
| 2692 | 2804 | "\x14BrokerServerEnvelope\x12:\n" + | |
| 2693 | 2805 | "\n" + | |
| @@ -2718,16 +2830,22 @@ const file_broker_proto_rawDesc = "" + | |||
| 2718 | 2830 | "\x05scope\x18\x01 \x01(\v2\x18.vev.wire.v1.BrokerScopeR\x05scope\x12\x1e\n" + | |
| 2719 | 2831 | "\n" + | |
| 2720 | 2832 | "generation\x18\x02 \x01(\x04R\n" + | |
| 2721 | - | "generation\"x\n" + | |
| 2833 | + | "generation\"\xaf\x01\n" + | |
| 2722 | 2834 | "\aAddHost\x12.\n" + | |
| 2723 | 2835 | "\x05scope\x18\x01 \x01(\v2\x18.vev.wire.v1.BrokerScopeR\x05scope\x12!\n" + | |
| 2724 | 2836 | "\foperation_id\x18\x02 \x01(\fR\voperationId\x12\x1a\n" + | |
| 2725 | - | "\bendpoint\x18\x03 \x01(\tR\bendpoint\"{\n" + | |
| 2837 | + | "\bendpoint\x18\x03 \x01(\tR\bendpoint\x125\n" + | |
| 2838 | + | "\x06policy\x18\x04 \x01(\v2\x1d.vev.wire.v1.BrokerWirePolicyR\x06policy\"\xa4\x01\n" + | |
| 2726 | 2839 | "\n" + | |
| 2727 | 2840 | "RemoveHost\x12.\n" + | |
| 2728 | 2841 | "\x05scope\x18\x01 \x01(\v2\x18.vev.wire.v1.BrokerScopeR\x05scope\x12!\n" + | |
| 2729 | - | "\foperation_id\x18\x02 \x01(\fR\voperationId\x12\x1a\n" + | |
| 2730 | - | "\bendpoint\x18\x03 \x01(\tR\bendpoint\"\x80\x01\n" + | |
| 2842 | + | "\foperation_id\x18\x02 \x01(\fR\voperationId\x12C\n" + | |
| 2843 | + | "\fregistration\x18\x03 \x01(\v2\x1f.vev.wire.v1.RemoteRegistrationR\fregistration\"\xe1\x01\n" + | |
| 2844 | + | "\x10UpdateHostPolicy\x12.\n" + | |
| 2845 | + | "\x05scope\x18\x01 \x01(\v2\x18.vev.wire.v1.BrokerScopeR\x05scope\x12!\n" + | |
| 2846 | + | "\foperation_id\x18\x02 \x01(\fR\voperationId\x12C\n" + | |
| 2847 | + | "\fregistration\x18\x03 \x01(\v2\x1f.vev.wire.v1.RemoteRegistrationR\fregistration\x125\n" + | |
| 2848 | + | "\x06policy\x18\x04 \x01(\v2\x1d.vev.wire.v1.BrokerWirePolicyR\x06policy\"\x80\x01\n" + | |
| 2731 | 2849 | "\tReconcile\x12.\n" + | |
| 2732 | 2850 | "\x05scope\x18\x01 \x01(\v2\x18.vev.wire.v1.BrokerScopeR\x05scope\x12C\n" + | |
| 2733 | 2851 | "\fregistration\x18\x02 \x01(\v2\x1f.vev.wire.v1.RemoteRegistrationR\fregistration\"\xfa\x02\n" + | |
| @@ -2806,13 +2924,14 @@ const file_broker_proto_rawDesc = "" + | |||
| 2806 | 2924 | "\x0ftombstone_index\x18\x01 \x01(\rR\x0etombstoneIndex\x12C\n" + | |
| 2807 | 2925 | "\fregistration\x18\x02 \x01(\v2\x1f.vev.wire.v1.RemoteRegistrationR\fregistration\x12)\n" + | |
| 2808 | 2926 | "\x10retired_revision\x18\x03 \x01(\x04R\x0fretiredRevision\"\r\n" + | |
| 2809 | - | "\vSnapshotEnd\"\xce\x01\n" + | |
| 2927 | + | "\vSnapshotEnd\"\x93\x02\n" + | |
| 2810 | 2928 | "\x0fOperationResult\x12.\n" + | |
| 2811 | 2929 | "\x05scope\x18\x01 \x01(\v2\x18.vev.wire.v1.BrokerScopeR\x05scope\x12!\n" + | |
| 2812 | 2930 | "\foperation_id\x18\x02 \x01(\fR\voperationId\x12\x18\n" + | |
| 2813 | 2931 | "\aoutcome\x18\x03 \x01(\rR\aoutcome\x12\x18\n" + | |
| 2814 | 2932 | "\aremoved\x18\x04 \x01(\bR\aremoved\x124\n" + | |
| 2815 | - | "\x05error\x18\x05 \x01(\v2\x1e.vev.wire.v1.BrokerErrorDetailR\x05error\">\n" + | |
| 2933 | + | "\x05error\x18\x05 \x01(\v2\x1e.vev.wire.v1.BrokerErrorDetailR\x05error\x12C\n" + | |
| 2934 | + | "\fregistration\x18\x06 \x01(\v2\x1f.vev.wire.v1.RemoteRegistrationR\fregistration\">\n" + | |
| 2816 | 2935 | "\fStreamOpened\x12.\n" + | |
| 2817 | 2936 | "\x03ref\x18\x01 \x01(\v2\x1c.vev.wire.v1.BrokerStreamRefR\x03ref\"V\n" + | |
| 2818 | 2937 | "\x10ServerStreamData\x12.\n" + | |
| @@ -2861,7 +2980,7 @@ func file_broker_proto_rawDescGZIP() []byte { | |||
| 2861 | 2980 | return file_broker_proto_rawDescData | |
| 2862 | 2981 | } | |
| 2863 | 2982 | ||
| 2864 | - | var file_broker_proto_msgTypes = make([]protoimpl.MessageInfo, 33) | |
| 2983 | + | var file_broker_proto_msgTypes = make([]protoimpl.MessageInfo, 34) | |
| 2865 | 2984 | var file_broker_proto_goTypes = []any{ | |
| 2866 | 2985 | (*BrokerScope)(nil), // 0: vev.wire.v1.BrokerScope | |
| 2867 | 2986 | (*BrokerStreamRef)(nil), // 1: vev.wire.v1.BrokerStreamRef | |
| @@ -2876,28 +2995,29 @@ var file_broker_proto_goTypes = []any{ | |||
| 2876 | 2995 | (*Unsubscribe)(nil), // 10: vev.wire.v1.Unsubscribe | |
| 2877 | 2996 | (*AddHost)(nil), // 11: vev.wire.v1.AddHost | |
| 2878 | 2997 | (*RemoveHost)(nil), // 12: vev.wire.v1.RemoveHost | |
| 2879 | - | (*Reconcile)(nil), // 13: vev.wire.v1.Reconcile | |
| 2880 | - | (*OpenStream)(nil), // 14: vev.wire.v1.OpenStream | |
| 2881 | - | (*ClientStreamData)(nil), // 15: vev.wire.v1.ClientStreamData | |
| 2882 | - | (*CloseStream)(nil), // 16: vev.wire.v1.CloseStream | |
| 2883 | - | (*Registered)(nil), // 17: vev.wire.v1.Registered | |
| 2884 | - | (*SnapshotPart)(nil), // 18: vev.wire.v1.SnapshotPart | |
| 2885 | - | (*SnapshotBegin)(nil), // 19: vev.wire.v1.SnapshotBegin | |
| 2886 | - | (*SnapshotDaemon)(nil), // 20: vev.wire.v1.SnapshotDaemon | |
| 2887 | - | (*SnapshotSession)(nil), // 21: vev.wire.v1.SnapshotSession | |
| 2888 | - | (*SnapshotTombstone)(nil), // 22: vev.wire.v1.SnapshotTombstone | |
| 2889 | - | (*SnapshotEnd)(nil), // 23: vev.wire.v1.SnapshotEnd | |
| 2890 | - | (*OperationResult)(nil), // 24: vev.wire.v1.OperationResult | |
| 2891 | - | (*StreamOpened)(nil), // 25: vev.wire.v1.StreamOpened | |
| 2892 | - | (*ServerStreamData)(nil), // 26: vev.wire.v1.ServerStreamData | |
| 2893 | - | (*StreamClosed)(nil), // 27: vev.wire.v1.StreamClosed | |
| 2894 | - | (*Progress)(nil), // 28: vev.wire.v1.Progress | |
| 2895 | - | (*BrokerErrorMessage)(nil), // 29: vev.wire.v1.BrokerErrorMessage | |
| 2896 | - | (*Shutdown)(nil), // 30: vev.wire.v1.Shutdown | |
| 2897 | - | (*BrokerCatalogSession)(nil), // 31: vev.wire.v1.BrokerCatalogSession | |
| 2898 | - | (*BrokerCatalogTab)(nil), // 32: vev.wire.v1.BrokerCatalogTab | |
| 2899 | - | (*RemoteRegistration)(nil), // 33: vev.wire.v1.RemoteRegistration | |
| 2900 | - | (*ExactTarget)(nil), // 34: vev.wire.v1.ExactTarget | |
| 2998 | + | (*UpdateHostPolicy)(nil), // 13: vev.wire.v1.UpdateHostPolicy | |
| 2999 | + | (*Reconcile)(nil), // 14: vev.wire.v1.Reconcile | |
| 3000 | + | (*OpenStream)(nil), // 15: vev.wire.v1.OpenStream | |
| 3001 | + | (*ClientStreamData)(nil), // 16: vev.wire.v1.ClientStreamData | |
| 3002 | + | (*CloseStream)(nil), // 17: vev.wire.v1.CloseStream | |
| 3003 | + | (*Registered)(nil), // 18: vev.wire.v1.Registered | |
| 3004 | + | (*SnapshotPart)(nil), // 19: vev.wire.v1.SnapshotPart | |
| 3005 | + | (*SnapshotBegin)(nil), // 20: vev.wire.v1.SnapshotBegin | |
| 3006 | + | (*SnapshotDaemon)(nil), // 21: vev.wire.v1.SnapshotDaemon | |
| 3007 | + | (*SnapshotSession)(nil), // 22: vev.wire.v1.SnapshotSession | |
| 3008 | + | (*SnapshotTombstone)(nil), // 23: vev.wire.v1.SnapshotTombstone | |
| 3009 | + | (*SnapshotEnd)(nil), // 24: vev.wire.v1.SnapshotEnd | |
| 3010 | + | (*OperationResult)(nil), // 25: vev.wire.v1.OperationResult | |
| 3011 | + | (*StreamOpened)(nil), // 26: vev.wire.v1.StreamOpened | |
| 3012 | + | (*ServerStreamData)(nil), // 27: vev.wire.v1.ServerStreamData | |
| 3013 | + | (*StreamClosed)(nil), // 28: vev.wire.v1.StreamClosed | |
| 3014 | + | (*Progress)(nil), // 29: vev.wire.v1.Progress | |
| 3015 | + | (*BrokerErrorMessage)(nil), // 30: vev.wire.v1.BrokerErrorMessage | |
| 3016 | + | (*Shutdown)(nil), // 31: vev.wire.v1.Shutdown | |
| 3017 | + | (*BrokerCatalogSession)(nil), // 32: vev.wire.v1.BrokerCatalogSession | |
| 3018 | + | (*BrokerCatalogTab)(nil), // 33: vev.wire.v1.BrokerCatalogTab | |
| 3019 | + | (*RemoteRegistration)(nil), // 34: vev.wire.v1.RemoteRegistration | |
| 3020 | + | (*ExactTarget)(nil), // 35: vev.wire.v1.ExactTarget | |
| 2901 | 3021 | } | |
| 2902 | 3022 | var file_broker_proto_depIdxs = []int32{ | |
| 2903 | 3023 | 0, // 0: vev.wire.v1.BrokerStreamRef.scope:type_name -> vev.wire.v1.BrokerScope | |
| @@ -2907,62 +3027,69 @@ var file_broker_proto_depIdxs = []int32{ | |||
| 2907 | 3027 | 10, // 4: vev.wire.v1.BrokerClientEnvelope.unsubscribe:type_name -> vev.wire.v1.Unsubscribe | |
| 2908 | 3028 | 11, // 5: vev.wire.v1.BrokerClientEnvelope.add_host:type_name -> vev.wire.v1.AddHost | |
| 2909 | 3029 | 12, // 6: vev.wire.v1.BrokerClientEnvelope.remove_host:type_name -> vev.wire.v1.RemoveHost | |
| 2910 | - | 13, // 7: vev.wire.v1.BrokerClientEnvelope.reconcile:type_name -> vev.wire.v1.Reconcile | |
| 2911 | - | 14, // 8: vev.wire.v1.BrokerClientEnvelope.open_stream:type_name -> vev.wire.v1.OpenStream | |
| 2912 | - | 15, // 9: vev.wire.v1.BrokerClientEnvelope.client_stream_data:type_name -> vev.wire.v1.ClientStreamData | |
| 2913 | - | 16, // 10: vev.wire.v1.BrokerClientEnvelope.close_stream:type_name -> vev.wire.v1.CloseStream | |
| 2914 | - | 17, // 11: vev.wire.v1.BrokerServerEnvelope.registered:type_name -> vev.wire.v1.Registered | |
| 2915 | - | 18, // 12: vev.wire.v1.BrokerServerEnvelope.snapshot_part:type_name -> vev.wire.v1.SnapshotPart | |
| 2916 | - | 24, // 13: vev.wire.v1.BrokerServerEnvelope.operation_result:type_name -> vev.wire.v1.OperationResult | |
| 2917 | - | 25, // 14: vev.wire.v1.BrokerServerEnvelope.stream_opened:type_name -> vev.wire.v1.StreamOpened | |
| 2918 | - | 26, // 15: vev.wire.v1.BrokerServerEnvelope.server_stream_data:type_name -> vev.wire.v1.ServerStreamData | |
| 2919 | - | 27, // 16: vev.wire.v1.BrokerServerEnvelope.stream_closed:type_name -> vev.wire.v1.StreamClosed | |
| 2920 | - | 28, // 17: vev.wire.v1.BrokerServerEnvelope.progress:type_name -> vev.wire.v1.Progress | |
| 2921 | - | 29, // 18: vev.wire.v1.BrokerServerEnvelope.broker_error_message:type_name -> vev.wire.v1.BrokerErrorMessage | |
| 2922 | - | 30, // 19: vev.wire.v1.BrokerServerEnvelope.shutdown:type_name -> vev.wire.v1.Shutdown | |
| 2923 | - | 0, // 20: vev.wire.v1.Subscribe.scope:type_name -> vev.wire.v1.BrokerScope | |
| 2924 | - | 0, // 21: vev.wire.v1.Resync.scope:type_name -> vev.wire.v1.BrokerScope | |
| 2925 | - | 0, // 22: vev.wire.v1.Unsubscribe.scope:type_name -> vev.wire.v1.BrokerScope | |
| 2926 | - | 0, // 23: vev.wire.v1.AddHost.scope:type_name -> vev.wire.v1.BrokerScope | |
| 2927 | - | 0, // 24: vev.wire.v1.RemoveHost.scope:type_name -> vev.wire.v1.BrokerScope | |
| 2928 | - | 0, // 25: vev.wire.v1.Reconcile.scope:type_name -> vev.wire.v1.BrokerScope | |
| 2929 | - | 33, // 26: vev.wire.v1.Reconcile.registration:type_name -> vev.wire.v1.RemoteRegistration | |
| 2930 | - | 1, // 27: vev.wire.v1.OpenStream.ref:type_name -> vev.wire.v1.BrokerStreamRef | |
| 2931 | - | 33, // 28: vev.wire.v1.OpenStream.registration:type_name -> vev.wire.v1.RemoteRegistration | |
| 2932 | - | 34, // 29: vev.wire.v1.OpenStream.target:type_name -> vev.wire.v1.ExactTarget | |
| 2933 | - | 2, // 30: vev.wire.v1.OpenStream.policy:type_name -> vev.wire.v1.BrokerWirePolicy | |
| 2934 | - | 1, // 31: vev.wire.v1.ClientStreamData.ref:type_name -> vev.wire.v1.BrokerStreamRef | |
| 2935 | - | 1, // 32: vev.wire.v1.CloseStream.ref:type_name -> vev.wire.v1.BrokerStreamRef | |
| 2936 | - | 0, // 33: vev.wire.v1.Registered.scope:type_name -> vev.wire.v1.BrokerScope | |
| 2937 | - | 0, // 34: vev.wire.v1.SnapshotPart.scope:type_name -> vev.wire.v1.BrokerScope | |
| 2938 | - | 19, // 35: vev.wire.v1.SnapshotPart.begin:type_name -> vev.wire.v1.SnapshotBegin | |
| 2939 | - | 20, // 36: vev.wire.v1.SnapshotPart.daemon:type_name -> vev.wire.v1.SnapshotDaemon | |
| 2940 | - | 21, // 37: vev.wire.v1.SnapshotPart.session:type_name -> vev.wire.v1.SnapshotSession | |
| 2941 | - | 22, // 38: vev.wire.v1.SnapshotPart.tombstone:type_name -> vev.wire.v1.SnapshotTombstone | |
| 2942 | - | 23, // 39: vev.wire.v1.SnapshotPart.end:type_name -> vev.wire.v1.SnapshotEnd | |
| 2943 | - | 33, // 40: vev.wire.v1.SnapshotDaemon.registration:type_name -> vev.wire.v1.RemoteRegistration | |
| 2944 | - | 2, // 41: vev.wire.v1.SnapshotDaemon.policy:type_name -> vev.wire.v1.BrokerWirePolicy | |
| 2945 | - | 4, // 42: vev.wire.v1.SnapshotDaemon.last_attempt:type_name -> vev.wire.v1.BrokerTimestamp | |
| 2946 | - | 4, // 43: vev.wire.v1.SnapshotDaemon.last_success:type_name -> vev.wire.v1.BrokerTimestamp | |
| 2947 | - | 4, // 44: vev.wire.v1.SnapshotDaemon.next_due:type_name -> vev.wire.v1.BrokerTimestamp | |
| 2948 | - | 31, // 45: vev.wire.v1.SnapshotSession.session:type_name -> vev.wire.v1.BrokerCatalogSession | |
| 2949 | - | 33, // 46: vev.wire.v1.SnapshotTombstone.registration:type_name -> vev.wire.v1.RemoteRegistration | |
| 2950 | - | 0, // 47: vev.wire.v1.OperationResult.scope:type_name -> vev.wire.v1.BrokerScope | |
| 2951 | - | 3, // 48: vev.wire.v1.OperationResult.error:type_name -> vev.wire.v1.BrokerErrorDetail | |
| 2952 | - | 1, // 49: vev.wire.v1.StreamOpened.ref:type_name -> vev.wire.v1.BrokerStreamRef | |
| 2953 | - | 1, // 50: vev.wire.v1.ServerStreamData.ref:type_name -> vev.wire.v1.BrokerStreamRef | |
| 2954 | - | 1, // 51: vev.wire.v1.StreamClosed.ref:type_name -> vev.wire.v1.BrokerStreamRef | |
| 2955 | - | 3, // 52: vev.wire.v1.StreamClosed.error:type_name -> vev.wire.v1.BrokerErrorDetail | |
| 2956 | - | 1, // 53: vev.wire.v1.Progress.ref:type_name -> vev.wire.v1.BrokerStreamRef | |
| 2957 | - | 0, // 54: vev.wire.v1.BrokerErrorMessage.scope:type_name -> vev.wire.v1.BrokerScope | |
| 2958 | - | 3, // 55: vev.wire.v1.BrokerErrorMessage.error:type_name -> vev.wire.v1.BrokerErrorDetail | |
| 2959 | - | 0, // 56: vev.wire.v1.Shutdown.scope:type_name -> vev.wire.v1.BrokerScope | |
| 2960 | - | 32, // 57: vev.wire.v1.BrokerCatalogSession.tabs:type_name -> vev.wire.v1.BrokerCatalogTab | |
| 2961 | - | 58, // [58:58] is the sub-list for method output_type | |
| 2962 | - | 58, // [58:58] is the sub-list for method input_type | |
| 2963 | - | 58, // [58:58] is the sub-list for extension type_name | |
| 2964 | - | 58, // [58:58] is the sub-list for extension extendee | |
| 2965 | - | 0, // [0:58] is the sub-list for field type_name | |
| 3030 | + | 14, // 7: vev.wire.v1.BrokerClientEnvelope.reconcile:type_name -> vev.wire.v1.Reconcile | |
| 3031 | + | 15, // 8: vev.wire.v1.BrokerClientEnvelope.open_stream:type_name -> vev.wire.v1.OpenStream | |
| 3032 | + | 16, // 9: vev.wire.v1.BrokerClientEnvelope.client_stream_data:type_name -> vev.wire.v1.ClientStreamData | |
| 3033 | + | 17, // 10: vev.wire.v1.BrokerClientEnvelope.close_stream:type_name -> vev.wire.v1.CloseStream | |
| 3034 | + | 13, // 11: vev.wire.v1.BrokerClientEnvelope.update_host_policy:type_name -> vev.wire.v1.UpdateHostPolicy | |
| 3035 | + | 18, // 12: vev.wire.v1.BrokerServerEnvelope.registered:type_name -> vev.wire.v1.Registered | |
| 3036 | + | 19, // 13: vev.wire.v1.BrokerServerEnvelope.snapshot_part:type_name -> vev.wire.v1.SnapshotPart | |
| 3037 | + | 25, // 14: vev.wire.v1.BrokerServerEnvelope.operation_result:type_name -> vev.wire.v1.OperationResult | |
| 3038 | + | 26, // 15: vev.wire.v1.BrokerServerEnvelope.stream_opened:type_name -> vev.wire.v1.StreamOpened | |
| 3039 | + | 27, // 16: vev.wire.v1.BrokerServerEnvelope.server_stream_data:type_name -> vev.wire.v1.ServerStreamData | |
| 3040 | + | 28, // 17: vev.wire.v1.BrokerServerEnvelope.stream_closed:type_name -> vev.wire.v1.StreamClosed | |
| 3041 | + | 29, // 18: vev.wire.v1.BrokerServerEnvelope.progress:type_name -> vev.wire.v1.Progress | |
| 3042 | + | 30, // 19: vev.wire.v1.BrokerServerEnvelope.broker_error_message:type_name -> vev.wire.v1.BrokerErrorMessage | |
| 3043 | + | 31, // 20: vev.wire.v1.BrokerServerEnvelope.shutdown:type_name -> vev.wire.v1.Shutdown | |
| 3044 | + | 0, // 21: vev.wire.v1.Subscribe.scope:type_name -> vev.wire.v1.BrokerScope | |
| 3045 | + | 0, // 22: vev.wire.v1.Resync.scope:type_name -> vev.wire.v1.BrokerScope | |
| 3046 | + | 0, // 23: vev.wire.v1.Unsubscribe.scope:type_name -> vev.wire.v1.BrokerScope | |
| 3047 | + | 0, // 24: vev.wire.v1.AddHost.scope:type_name -> vev.wire.v1.BrokerScope | |
| 3048 | + | 2, // 25: vev.wire.v1.AddHost.policy:type_name -> vev.wire.v1.BrokerWirePolicy | |
| 3049 | + | 0, // 26: vev.wire.v1.RemoveHost.scope:type_name -> vev.wire.v1.BrokerScope | |
| 3050 | + | 34, // 27: vev.wire.v1.RemoveHost.registration:type_name -> vev.wire.v1.RemoteRegistration | |
| 3051 | + | 0, // 28: vev.wire.v1.UpdateHostPolicy.scope:type_name -> vev.wire.v1.BrokerScope | |
| 3052 | + | 34, // 29: vev.wire.v1.UpdateHostPolicy.registration:type_name -> vev.wire.v1.RemoteRegistration | |
| 3053 | + | 2, // 30: vev.wire.v1.UpdateHostPolicy.policy:type_name -> vev.wire.v1.BrokerWirePolicy | |
| 3054 | + | 0, // 31: vev.wire.v1.Reconcile.scope:type_name -> vev.wire.v1.BrokerScope | |
| 3055 | + | 34, // 32: vev.wire.v1.Reconcile.registration:type_name -> vev.wire.v1.RemoteRegistration | |
| 3056 | + | 1, // 33: vev.wire.v1.OpenStream.ref:type_name -> vev.wire.v1.BrokerStreamRef | |
| 3057 | + | 34, // 34: vev.wire.v1.OpenStream.registration:type_name -> vev.wire.v1.RemoteRegistration | |
| 3058 | + | 35, // 35: vev.wire.v1.OpenStream.target:type_name -> vev.wire.v1.ExactTarget | |
| 3059 | + | 2, // 36: vev.wire.v1.OpenStream.policy:type_name -> vev.wire.v1.BrokerWirePolicy | |
| 3060 | + | 1, // 37: vev.wire.v1.ClientStreamData.ref:type_name -> vev.wire.v1.BrokerStreamRef | |
| 3061 | + | 1, // 38: vev.wire.v1.CloseStream.ref:type_name -> vev.wire.v1.BrokerStreamRef | |
| 3062 | + | 0, // 39: vev.wire.v1.Registered.scope:type_name -> vev.wire.v1.BrokerScope | |
| 3063 | + | 0, // 40: vev.wire.v1.SnapshotPart.scope:type_name -> vev.wire.v1.BrokerScope | |
| 3064 | + | 20, // 41: vev.wire.v1.SnapshotPart.begin:type_name -> vev.wire.v1.SnapshotBegin | |
| 3065 | + | 21, // 42: vev.wire.v1.SnapshotPart.daemon:type_name -> vev.wire.v1.SnapshotDaemon | |
| 3066 | + | 22, // 43: vev.wire.v1.SnapshotPart.session:type_name -> vev.wire.v1.SnapshotSession | |
| 3067 | + | 23, // 44: vev.wire.v1.SnapshotPart.tombstone:type_name -> vev.wire.v1.SnapshotTombstone | |
| 3068 | + | 24, // 45: vev.wire.v1.SnapshotPart.end:type_name -> vev.wire.v1.SnapshotEnd | |
| 3069 | + | 34, // 46: vev.wire.v1.SnapshotDaemon.registration:type_name -> vev.wire.v1.RemoteRegistration | |
| 3070 | + | 2, // 47: vev.wire.v1.SnapshotDaemon.policy:type_name -> vev.wire.v1.BrokerWirePolicy | |
| 3071 | + | 4, // 48: vev.wire.v1.SnapshotDaemon.last_attempt:type_name -> vev.wire.v1.BrokerTimestamp | |
| 3072 | + | 4, // 49: vev.wire.v1.SnapshotDaemon.last_success:type_name -> vev.wire.v1.BrokerTimestamp | |
| 3073 | + | 4, // 50: vev.wire.v1.SnapshotDaemon.next_due:type_name -> vev.wire.v1.BrokerTimestamp | |
| 3074 | + | 32, // 51: vev.wire.v1.SnapshotSession.session:type_name -> vev.wire.v1.BrokerCatalogSession | |
| 3075 | + | 34, // 52: vev.wire.v1.SnapshotTombstone.registration:type_name -> vev.wire.v1.RemoteRegistration | |
| 3076 | + | 0, // 53: vev.wire.v1.OperationResult.scope:type_name -> vev.wire.v1.BrokerScope | |
| 3077 | + | 3, // 54: vev.wire.v1.OperationResult.error:type_name -> vev.wire.v1.BrokerErrorDetail | |
| 3078 | + | 34, // 55: vev.wire.v1.OperationResult.registration:type_name -> vev.wire.v1.RemoteRegistration | |
| 3079 | + | 1, // 56: vev.wire.v1.StreamOpened.ref:type_name -> vev.wire.v1.BrokerStreamRef | |
| 3080 | + | 1, // 57: vev.wire.v1.ServerStreamData.ref:type_name -> vev.wire.v1.BrokerStreamRef | |
| 3081 | + | 1, // 58: vev.wire.v1.StreamClosed.ref:type_name -> vev.wire.v1.BrokerStreamRef | |
| 3082 | + | 3, // 59: vev.wire.v1.StreamClosed.error:type_name -> vev.wire.v1.BrokerErrorDetail | |
| 3083 | + | 1, // 60: vev.wire.v1.Progress.ref:type_name -> vev.wire.v1.BrokerStreamRef | |
| 3084 | + | 0, // 61: vev.wire.v1.BrokerErrorMessage.scope:type_name -> vev.wire.v1.BrokerScope | |
| 3085 | + | 3, // 62: vev.wire.v1.BrokerErrorMessage.error:type_name -> vev.wire.v1.BrokerErrorDetail | |
| 3086 | + | 0, // 63: vev.wire.v1.Shutdown.scope:type_name -> vev.wire.v1.BrokerScope | |
| 3087 | + | 33, // 64: vev.wire.v1.BrokerCatalogSession.tabs:type_name -> vev.wire.v1.BrokerCatalogTab | |
| 3088 | + | 65, // [65:65] is the sub-list for method output_type | |
| 3089 | + | 65, // [65:65] is the sub-list for method input_type | |
| 3090 | + | 65, // [65:65] is the sub-list for extension type_name | |
| 3091 | + | 65, // [65:65] is the sub-list for extension extendee | |
| 3092 | + | 0, // [0:65] is the sub-list for field type_name | |
| 2966 | 3093 | } | |
| 2967 | 3094 | ||
| 2968 | 3095 | func init() { file_broker_proto_init() } | |
| @@ -2982,6 +3109,7 @@ func file_broker_proto_init() { | |||
| 2982 | 3109 | (*BrokerClientEnvelope_OpenStream)(nil), | |
| 2983 | 3110 | (*BrokerClientEnvelope_ClientStreamData)(nil), | |
| 2984 | 3111 | (*BrokerClientEnvelope_CloseStream)(nil), | |
| 3112 | + | (*BrokerClientEnvelope_UpdateHostPolicy)(nil), | |
| 2985 | 3113 | } | |
| 2986 | 3114 | file_broker_proto_msgTypes[6].OneofWrappers = []any{ | |
| 2987 | 3115 | (*BrokerServerEnvelope_Registered)(nil), | |
| @@ -2994,7 +3122,7 @@ func file_broker_proto_init() { | |||
| 2994 | 3122 | (*BrokerServerEnvelope_BrokerErrorMessage)(nil), | |
| 2995 | 3123 | (*BrokerServerEnvelope_Shutdown)(nil), | |
| 2996 | 3124 | } | |
| 2997 | - | file_broker_proto_msgTypes[18].OneofWrappers = []any{ | |
| 3125 | + | file_broker_proto_msgTypes[19].OneofWrappers = []any{ | |
| 2998 | 3126 | (*SnapshotPart_Begin)(nil), | |
| 2999 | 3127 | (*SnapshotPart_Daemon)(nil), | |
| 3000 | 3128 | (*SnapshotPart_Session)(nil), | |
| @@ -3007,7 +3135,7 @@ func file_broker_proto_init() { | |||
| 3007 | 3135 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), | |
| 3008 | 3136 | RawDescriptor: unsafe.Slice(unsafe.StringData(file_broker_proto_rawDesc), len(file_broker_proto_rawDesc)), | |
| 3009 | 3137 | NumEnums: 0, | |
| 3010 | - | NumMessages: 33, | |
| 3138 | + | NumMessages: 34, | |
| 3011 | 3139 | NumExtensions: 0, | |
| 3012 | 3140 | NumServices: 0, | |
| 3013 | 3141 | }, | |
M internal/protocol/wire/broker_test.go +75 -5
| @@ -19,7 +19,7 @@ import ( | |||
| 19 | 19 | var brokerClientTags = map[string]protoreflect.FieldNumber{ | |
| 20 | 20 | "register": 101, "subscribe": 102, "resync": 103, "unsubscribe": 104, | |
| 21 | 21 | "add_host": 105, "remove_host": 106, "reconcile": 107, "open_stream": 108, | |
| 22 | - | "client_stream_data": 109, "close_stream": 110, | |
| 22 | + | "client_stream_data": 109, "close_stream": 110, "update_host_policy": 111, | |
| 23 | 23 | } | |
| 24 | 24 | ||
| 25 | 25 | // brokerServerTags is the frozen server tag inventory. | |
| @@ -151,9 +151,14 @@ func brokerClientSamples() map[string]*BrokerClientEnvelope { | |||
| 151 | 151 | "unsubscribe": {Payload: &BrokerClientEnvelope_Unsubscribe{Unsubscribe: &Unsubscribe{Scope: brokerScope(), Generation: 1 << 42}}}, | |
| 152 | 152 | "add_host": {Payload: &BrokerClientEnvelope_AddHost{AddHost: &AddHost{ | |
| 153 | 153 | Scope: brokerScope(), OperationId: bytes.Repeat([]byte{0x44}, 16), Endpoint: "dev@host:22", | |
| 154 | + | Policy: brokerPolicy(), | |
| 154 | 155 | }}}, | |
| 155 | 156 | "remove_host": {Payload: &BrokerClientEnvelope_RemoveHost{RemoveHost: &RemoveHost{ | |
| 156 | - | Scope: brokerScope(), OperationId: bytes.Repeat([]byte{0x45}, 16), Endpoint: "dev@host:23", | |
| 157 | + | Scope: brokerScope(), OperationId: bytes.Repeat([]byte{0x45}, 16), Registration: brokerRegistration(), | |
| 158 | + | }}}, | |
| 159 | + | "update_host_policy": {Payload: &BrokerClientEnvelope_UpdateHostPolicy{UpdateHostPolicy: &UpdateHostPolicy{ | |
| 160 | + | Scope: brokerScope(), OperationId: bytes.Repeat([]byte{0x46}, 16), | |
| 161 | + | Registration: brokerRegistration(), Policy: brokerPolicy(), | |
| 157 | 162 | }}}, | |
| 158 | 163 | "reconcile": {Payload: &BrokerClientEnvelope_Reconcile{Reconcile: &Reconcile{ | |
| 159 | 164 | Scope: brokerScope(), Registration: brokerRegistration(), | |
| @@ -180,9 +185,9 @@ func brokerServerSamples() map[string]*BrokerServerEnvelope { | |||
| 180 | 185 | Scope: brokerScope(), Generation: 2, Revision: 5, Index: 9, | |
| 181 | 186 | Part: &SnapshotPart_Daemon{Daemon: &SnapshotDaemon{ | |
| 182 | 187 | HostIndex: 1, Endpoint: "dev@host:22", DisplayOrigin: "dev@host", | |
| 183 | - | Rank: 2, Registration: brokerRegistration(), Policy: &BrokerWirePolicy{ProtocolVersion: 55}, | |
| 188 | + | Rank: 2, Registration: brokerRegistration(), Policy: &BrokerWirePolicy{ProtocolVersion: 56}, | |
| 184 | 189 | DaemonIdentity: "authed-daemon", DaemonIncarnation: bytes.Repeat([]byte{0x9}, 16), | |
| 185 | - | ProtocolVersion: 55, Capabilities: 1, | |
| 190 | + | ProtocolVersion: 56, Capabilities: 1, | |
| 186 | 191 | Availability: 1, | |
| 187 | 192 | Checking: true, | |
| 188 | 193 | LastAttempt: &BrokerTimestamp{Seconds: 1700000000, Nanos: 1}, | |
| @@ -193,7 +198,7 @@ func brokerServerSamples() map[string]*BrokerServerEnvelope { | |||
| 193 | 198 | }}, | |
| 194 | 199 | }}}, | |
| 195 | 200 | "operation_result": {Payload: &BrokerServerEnvelope_OperationResult{OperationResult: &OperationResult{ | |
| 196 | - | Scope: brokerScope(), OperationId: bytes.Repeat([]byte{0x77}, 16), Outcome: 2, Removed: true, | |
| 201 | + | Scope: brokerScope(), OperationId: bytes.Repeat([]byte{0x77}, 16), Outcome: 2, | |
| 197 | 202 | Error: &BrokerErrorDetail{Code: 2, Text: "incompatible", AdmissionCode: 1, FailureKind: 3}, | |
| 198 | 203 | }}}, | |
| 199 | 204 | "stream_opened": {Payload: &BrokerServerEnvelope_StreamOpened{StreamOpened: &StreamOpened{Ref: brokerRef()}}}, | |
| @@ -440,3 +445,68 @@ func TestBrokerEnvelopeTrailingGarbage(t *testing.T) { | |||
| 440 | 445 | } | |
| 441 | 446 | } | |
| 442 | 447 | } | |
| 448 | + | ||
| 449 | + | // TestBrokerMembershipSchemaShape pins the Phase C1 membership layout: the new | |
| 450 | + | // client variant, the required policy on AddHost/UpdateHostPolicy, the exact | |
| 451 | + | // registration on RemoveHost/UpdateHostPolicy, and the registration member of | |
| 452 | + | // OperationResult. A field that moves would silently change the meaning of a | |
| 453 | + | // mutating request without changing its tag. | |
| 454 | + | func TestBrokerMembershipSchemaShape(t *testing.T) { | |
| 455 | + | fields := func(message proto.Message) map[string]protoreflect.FieldNumber { | |
| 456 | + | t.Helper() | |
| 457 | + | descriptor := message.ProtoReflect().Descriptor() | |
| 458 | + | out := make(map[string]protoreflect.FieldNumber, descriptor.Fields().Len()) | |
| 459 | + | for i := range descriptor.Fields().Len() { | |
| 460 | + | field := descriptor.Fields().Get(i) | |
| 461 | + | out[string(field.Name())] = field.Number() | |
| 462 | + | } | |
| 463 | + | return out | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | if got, want := oneofTags(t, &BrokerClientEnvelope{})["update_host_policy"], protoreflect.FieldNumber(111); got != want { | |
| 467 | + | t.Fatalf("BrokerClientEnvelope.update_host_policy = %d, want %d", got, want) | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | addHost := fields(&AddHost{}) | |
| 471 | + | if addHost["policy"] != 4 { | |
| 472 | + | t.Fatalf("AddHost.policy = %d, want 4 (required authority)", addHost["policy"]) | |
| 473 | + | } | |
| 474 | + | removeHost := fields(&RemoveHost{}) | |
| 475 | + | if removeHost["registration"] != 3 { | |
| 476 | + | t.Fatalf("RemoveHost.registration = %d, want 3", removeHost["registration"]) | |
| 477 | + | } | |
| 478 | + | if _, present := removeHost["endpoint"]; present { | |
| 479 | + | t.Fatal("RemoveHost still carries an endpoint field; removal is exact-registration authority") | |
| 480 | + | } | |
| 481 | + | update := fields(&UpdateHostPolicy{}) | |
| 482 | + | if update["registration"] != 3 || update["policy"] != 4 { | |
| 483 | + | t.Fatalf("UpdateHostPolicy fields = %v, want registration 3 and policy 4", update) | |
| 484 | + | } | |
| 485 | + | result := fields(&OperationResult{}) | |
| 486 | + | if result["registration"] != 6 { | |
| 487 | + | t.Fatalf("OperationResult.registration = %d, want 6", result["registration"]) | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | // Every membership sample survives strict scan, unmarshal, and equality, | |
| 491 | + | // and every strict prefix of it is refused. | |
| 492 | + | for name, sample := range brokerClientSamples() { | |
| 493 | + | switch name { | |
| 494 | + | case "add_host", "remove_host", "update_host_policy": | |
| 495 | + | default: | |
| 496 | + | continue | |
| 497 | + | } | |
| 498 | + | raw := mustMarshalBroker(t, sample) | |
| 499 | + | decoded := &BrokerClientEnvelope{} | |
| 500 | + | if err := validatedUnmarshal(decoded, raw); err != nil { | |
| 501 | + | t.Fatalf("%s: validatedUnmarshal() = %v", name, err) | |
| 502 | + | } | |
| 503 | + | if !proto.Equal(sample, decoded) { | |
| 504 | + | t.Fatalf("%s: roundtrip mismatch:\n got %v\nwant %v", name, decoded, sample) | |
| 505 | + | } | |
| 506 | + | for size := range len(raw) { | |
| 507 | + | if err := ScanEnvelope(&BrokerClientEnvelope{}, raw[:size]); err == nil { | |
| 508 | + | t.Fatalf("%s prefix[:%d] accepted", name, size) | |
| 509 | + | } | |
| 510 | + | } | |
| 511 | + | } | |
| 512 | + | } | |
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 != 55 { | |
| 19 | - | t.Fatalf("protocol.Version = %d, want 55", protocol.Version) | |
| 18 | + | if protocol.Version != 56 { | |
| 19 | + | t.Fatalf("protocol.Version = %d, want 56", 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 +1 -1 generated file: collapsed
M internal/protocol/wire/schema/broker.proto +27 -6
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | // Broker wire schema: the client/server broker conversations. | |
| 2 | 2 | // | |
| 3 | 3 | // The broker is an independently versioned conversation from the session | |
| 4 | - | // protocol: client tags 101-110 and server tags 201-209 are disjoint from | |
| 4 | + | // protocol: client tags 101-111 and server tags 201-209 are disjoint from | |
| 5 | 5 | // every session tag, and each direction is its own closed oneof union. | |
| 6 | 6 | // Scope (broker epoch plus client connection) is carried explicitly on | |
| 7 | 7 | // every payload so stream IDs and events never cross a scope. Semantic | |
| @@ -52,7 +52,7 @@ message BrokerErrorDetail { | |||
| 52 | 52 | // Closed taxonomy: 1 = unavailable, 2 = incompatible, 3 = timeout, | |
| 53 | 53 | // 4 = cancelled, 5 = outcome_unknown, 6 = attachment_lost, | |
| 54 | 54 | // 7 = stale_epoch, 8 = conflicting_policy, 9 = explicit_exit, | |
| 55 | - | // 10 = fatal_terminal. | |
| 55 | + | // 10 = fatal_terminal, 11 = host_conflict, 12 = membership_immutable. | |
| 56 | 56 | uint32 code = 1; | |
| 57 | 57 | string text = 2; | |
| 58 | 58 | // Closed admission-refusal taxonomy; 0 = none. Codes are assigned by the | |
| @@ -85,6 +85,7 @@ message BrokerClientEnvelope { | |||
| 85 | 85 | OpenStream open_stream = 108; | |
| 86 | 86 | ClientStreamData client_stream_data = 109; | |
| 87 | 87 | CloseStream close_stream = 110; | |
| 88 | + | UpdateHostPolicy update_host_policy = 111; | |
| 88 | 89 | } | |
| 89 | 90 | } | |
| 90 | 91 | ||
| @@ -127,18 +128,34 @@ message Unsubscribe { | |||
| 127 | 128 | } | |
| 128 | 129 | ||
| 129 | 130 | // AddHost adds one configured host registration. operation_id is the | |
| 130 | - | // idempotency identity of this mutating operation. | |
| 131 | + | // idempotency identity of this mutating operation. policy is mandatory: | |
| 132 | + | // the request is refused unless it carries an exact valid policy, so a | |
| 133 | + | // membership addition can never be authorized by an absent policy. | |
| 131 | 134 | message AddHost { | |
| 132 | 135 | BrokerScope scope = 1; | |
| 133 | 136 | bytes operation_id = 2; // exactly 16 bytes | |
| 134 | 137 | string endpoint = 3; | |
| 138 | + | BrokerWirePolicy policy = 4; | |
| 135 | 139 | } | |
| 136 | 140 | ||
| 137 | - | // RemoveHost removes one configured host registration. | |
| 141 | + | // RemoveHost removes exactly the registration carried by registration | |
| 142 | + | // (endpoint, incarnation, and generation). A request whose authority does | |
| 143 | + | // not exactly match the current registration is refused rather than | |
| 144 | + | // applied to an endpoint name. | |
| 138 | 145 | message RemoveHost { | |
| 139 | 146 | BrokerScope scope = 1; | |
| 140 | 147 | bytes operation_id = 2; // exactly 16 bytes | |
| 141 | - | string endpoint = 3; | |
| 148 | + | RemoteRegistration registration = 3; | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | // UpdateHostPolicy replaces the policy of exactly the registration carried | |
| 152 | + | // by registration and answers with the advanced registration authority. | |
| 153 | + | // policy is mandatory: an absent policy is refused like an invalid one. | |
| 154 | + | message UpdateHostPolicy { | |
| 155 | + | BrokerScope scope = 1; | |
| 156 | + | bytes operation_id = 2; // exactly 16 bytes | |
| 157 | + | RemoteRegistration registration = 3; | |
| 158 | + | BrokerWirePolicy policy = 4; | |
| 142 | 159 | } | |
| 143 | 160 | ||
| 144 | 161 | // Reconcile hands the broker one exact registration to probe. | |
| @@ -279,7 +296,10 @@ message SnapshotTombstone { | |||
| 279 | 296 | message SnapshotEnd {} | |
| 280 | 297 | ||
| 281 | 298 | // OperationResult is exactly one completion for one admitted mutating | |
| 282 | - | // operation. | |
| 299 | + | // operation. removed and registration are presence-carrying authority: | |
| 300 | + | // an authoritative success carries the resulting registration, a removal | |
| 301 | + | // success carries none, and a failed or outcome-unknown result carries | |
| 302 | + | // neither removal nor registration, only its typed error. | |
| 283 | 303 | message OperationResult { | |
| 284 | 304 | BrokerScope scope = 1; | |
| 285 | 305 | bytes operation_id = 2; // exactly 16 bytes | |
| @@ -287,6 +307,7 @@ message OperationResult { | |||
| 287 | 307 | uint32 outcome = 3; | |
| 288 | 308 | bool removed = 4; | |
| 289 | 309 | BrokerErrorDetail error = 5; | |
| 310 | + | RemoteRegistration registration = 6; | |
| 290 | 311 | } | |
| 291 | 312 | ||
| 292 | 313 | // StreamOpened confirms one logical stream is established. | |
M internal/protocol/wire/schema/multiplex.proto +1 -1
| @@ -3,7 +3,7 @@ | |||
| 3 | 3 | // physical connection. | |
| 4 | 4 | // | |
| 5 | 5 | // daemonmux is a third conversation, separate from the session protocol | |
| 6 | - | // (tags 1-32) and the broker protocol (client 101-110, server 201-209): | |
| 6 | + | // (tags 1-32) and the broker protocol (client 101-111, server 201-209): | |
| 7 | 7 | // client tags 301-304 and server tags 401-405 are disjoint from every other | |
| 8 | 8 | // tag, and each direction is its own closed oneof union. The physical | |
| 9 | 9 | // preamble is a separate adapter message pair, not an envelope variant: | |
M internal/usecase/broker/registry_local.go +0 -11
| @@ -75,17 +75,6 @@ func (l *LocalObservation) validate() error { | |||
| 75 | 75 | return nil | |
| 76 | 76 | } | |
| 77 | 77 | ||
| 78 | - | // observesLocalOnly reports whether this registry observes only the broker's | |
| 79 | - | // own daemon: observation is enabled, a local binding is configured, and no | |
| 80 | - | // remote membership is projected. It is the registry-side answer an authority | |
| 81 | - | // needs, because an authority whose connections expose immutable membership may | |
| 82 | - | // serve an observer only when there is no remote membership to manage. | |
| 83 | - | func (r *Registry) observesLocalOnly() bool { | |
| 84 | - | r.mu.Lock() | |
| 85 | - | defer r.mu.Unlock() | |
| 86 | - | return !r.observationDisabled && r.local != nil && len(r.hosts) == 0 | |
| 87 | - | } | |
| 88 | - | ||
| 89 | 78 | // dispatchLocalLocked admits at most one local probe attempt when its schedule | |
| 90 | 79 | // says it is due. Callers must hold r.mu. It reports whether an attempt | |
| 91 | 80 | // started, so dispatch publishes the transient Checking state exactly once. | |
M internal/usecase/broker/service.go +76 -39
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "sync" | |
| 7 | 7 | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | 9 | "github.com/bnema/vev/internal/ports" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| @@ -21,24 +22,9 @@ import ( | |||
| 21 | 22 | // admission only and is never retained; the admitted service owns a | |
| 22 | 23 | // connection-lived context derived from the supervisor root. | |
| 23 | 24 | // | |
| 24 | - | // This composition is deliberately offline and immutable: its registry runs | |
| 25 | - | // observation-disabled, so the returned service publishes and subscribes to | |
| 26 | - | // durable snapshots but never probes or reconciles, and host membership is | |
| 27 | - | // refused rather than mutated. Live membership mutation is composed in a later | |
| 28 | - | // slice. | |
| 29 | - | ||
| 30 | - | // ErrOfflineMembership is the typed refusal returned by AddHost and RemoveHost | |
| 31 | - | // on an offline broker connection: membership is immutable for the | |
| 32 | - | // connection's lifetime and the refusal never touches durable authority. | |
| 33 | - | // Callers classify it with errors.Is instead of matching message text. | |
| 34 | - | var ErrOfflineMembership = errors.New("broker: offline membership is immutable") | |
| 35 | - | ||
| 36 | - | // offlineMembershipRefusal is the typed, presentation-safe refusal. It is a | |
| 37 | - | // ports.BrokerError so the wire layer classifies it without message matching, | |
| 38 | - | // and it carries ErrOfflineMembership for errors.Is. | |
| 39 | - | func offlineMembershipRefusal() error { | |
| 40 | - | return ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: ErrOfflineMembership.Error(), Cause: ErrOfflineMembership} | |
| 41 | - | } | |
| 25 | + | // Registry construction determines observation and membership policy. The | |
| 26 | + | // authority preserves those guards and exposes admitted mutations through the | |
| 27 | + | // connection-scoped service. | |
| 42 | 28 | ||
| 43 | 29 | // Authority admits one accepted client connection to the broker core. It owns | |
| 44 | 30 | // no lifetime of its own: the supervisor owns the broker lifetime, the registry | |
| @@ -60,13 +46,6 @@ var _ ports.BrokerAuthority = (*Authority)(nil) | |||
| 60 | 46 | // NewAuthority composes one connection-scoped broker authority over an existing | |
| 61 | 47 | // registry, pool, and supervisor. All three must be live and share epoch. | |
| 62 | 48 | // | |
| 63 | - | // The authority's connections expose immutable membership: they neither add nor | |
| 64 | - | // remove hosts. It therefore accepts an observation-disabled registry, or an | |
| 65 | - | // observer whose observation is limited to the broker's own daemon, and refuses | |
| 66 | - | // a registry that observes configured hosts the service would refuse to manage. | |
| 67 | - | // A local-only observer has no remote membership to manage: its local entry is | |
| 68 | - | // configured authority supplied by the composition, not membership. | |
| 69 | - | // | |
| 70 | 49 | // The caller owns the dependencies' lifecycles. In particular it must start the | |
| 71 | 50 | // registry's single Registry.Run to own and drain the durable writer, and must | |
| 72 | 51 | // cancel that run context and join Run before the store is closed. NewAuthority | |
| @@ -80,9 +59,6 @@ func NewAuthority(epoch ports.BrokerEpoch, registry *Registry, pool *Pool, super | |||
| 80 | 59 | if registry.epoch != epoch || pool.epoch != epoch { | |
| 81 | 60 | return nil, errors.New("broker: authority epoch mismatch") | |
| 82 | 61 | } | |
| 83 | - | if !registry.observationDisabled && !registry.observesLocalOnly() { | |
| 84 | - | return nil, errors.New("broker: authority requires an observation-disabled registry or a local-only observer") | |
| 85 | - | } | |
| 86 | 62 | return &Authority{epoch: epoch, registry: registry, pool: pool, supervisor: supervisor}, nil | |
| 87 | 63 | } | |
| 88 | 64 | ||
| @@ -132,8 +108,8 @@ type Service struct { | |||
| 132 | 108 | mu sync.Mutex | |
| 133 | 109 | closed bool | |
| 134 | 110 | subs map[*serviceSubscription]struct{} | |
| 135 | - | // wg counts in-flight OpenStream calls so Close drains them, and their | |
| 136 | - | // operation leases, before it releases the connection resources. | |
| 111 | + | // wg counts in-flight stream opens and membership mutations so Close drains | |
| 112 | + | // them, and their operation leases, before releasing connection resources. | |
| 137 | 113 | wg sync.WaitGroup | |
| 138 | 114 | ||
| 139 | 115 | closeOnce sync.Once | |
| @@ -265,18 +241,79 @@ func (s *Service) CloseStream(connection ports.BrokerConnectionID, stream ports. | |||
| 265 | 241 | return s.pool.CloseStream(s.id, stream) | |
| 266 | 242 | } | |
| 267 | 243 | ||
| 268 | - | // AddHost refuses host admission: this connection has immutable offline | |
| 269 | - | // membership, so no mutation is attempted. | |
| 270 | - | func (s *Service) AddHost(context.Context, string) error { return offlineMembershipRefusal() } | |
| 244 | + | // AddHost delegates one admitted membership mutation to the registry. | |
| 245 | + | func (s *Service) AddHost(ctx context.Context, endpoint string, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) { | |
| 246 | + | return serviceMutation(s, ctx, func(ctx context.Context) (domain.RemoteRegistration, error) { | |
| 247 | + | return s.registry.AddHost(ctx, endpoint, policy) | |
| 248 | + | }) | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | // RemoveHost delegates one admitted exact-registration removal to the registry. | |
| 252 | + | func (s *Service) RemoveHost(ctx context.Context, expected domain.RemoteRegistration) (bool, error) { | |
| 253 | + | return serviceMutation(s, ctx, func(ctx context.Context) (bool, error) { | |
| 254 | + | return s.registry.RemoveHost(ctx, expected) | |
| 255 | + | }) | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | // UpdateHostPolicy delegates one admitted exact-registration policy update. | |
| 259 | + | func (s *Service) UpdateHostPolicy(ctx context.Context, expected domain.RemoteRegistration, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) { | |
| 260 | + | return serviceMutation(s, ctx, func(ctx context.Context) (domain.RemoteRegistration, error) { | |
| 261 | + | return s.registry.UpdateHostPolicy(ctx, expected, policy) | |
| 262 | + | }) | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | func serviceMutation[T any](s *Service, ctx context.Context, mutate func(context.Context) (T, error)) (T, error) { | |
| 266 | + | var zero T | |
| 267 | + | s.mu.Lock() | |
| 268 | + | if s.closed { | |
| 269 | + | s.mu.Unlock() | |
| 270 | + | return zero, ports.BrokerAdmissionClosed | |
| 271 | + | } | |
| 272 | + | s.wg.Add(1) | |
| 273 | + | s.mu.Unlock() | |
| 274 | + | defer s.wg.Done() | |
| 275 | + | ||
| 276 | + | operation, err := s.supervisor.AdmitOperation() | |
| 277 | + | if err != nil { | |
| 278 | + | return zero, err | |
| 279 | + | } | |
| 280 | + | defer operation.Release() | |
| 281 | + | ||
| 282 | + | operationCtx, release := s.openContext(ctx) | |
| 283 | + | defer release() | |
| 284 | + | value, err := mutate(operationCtx) | |
| 285 | + | return value, membershipError(err) | |
| 286 | + | } | |
| 271 | 287 | ||
| 272 | - | // RemoveHost refuses host removal without mutating authority. The result is the | |
| 273 | - | // explicit false/refusal pair. | |
| 274 | - | func (s *Service) RemoveHost(context.Context, string) (bool, error) { | |
| 275 | - | return false, offlineMembershipRefusal() | |
| 288 | + | func membershipError(err error) error { | |
| 289 | + | if err == nil { | |
| 290 | + | return nil | |
| 291 | + | } | |
| 292 | + | code := ports.BrokerErrorCode(0) | |
| 293 | + | text := "" | |
| 294 | + | var brokerError ports.BrokerError | |
| 295 | + | switch { | |
| 296 | + | case errors.Is(err, ports.ErrBrokerHostConflict): | |
| 297 | + | code, text = ports.BrokerErrorHostConflict, "host registration conflict" | |
| 298 | + | case errors.Is(err, ports.ErrBrokerMembershipImmutable): | |
| 299 | + | code, text = ports.BrokerErrorMembershipImmutable, "broker membership is immutable" | |
| 300 | + | case errors.As(err, &brokerError) && brokerError.Code == ports.BrokerErrorConflictingPolicy: | |
| 301 | + | code, text = ports.BrokerErrorConflictingPolicy, "host policy conflicts with existing policy" | |
| 302 | + | default: | |
| 303 | + | var unknown ports.BrokerStoreOutcomeUnknownError | |
| 304 | + | var unknownPointer *ports.BrokerStoreOutcomeUnknownError | |
| 305 | + | if errors.As(err, &unknown) || errors.As(err, &unknownPointer) { | |
| 306 | + | code, text = ports.BrokerErrorOutcomeUnknown, "mutation outcome is unknown" | |
| 307 | + | } | |
| 308 | + | } | |
| 309 | + | if code == 0 { | |
| 310 | + | return err | |
| 311 | + | } | |
| 312 | + | return ports.BrokerError{Code: code, Text: text, Cause: err} | |
| 276 | 313 | } | |
| 277 | 314 | ||
| 278 | - | // RequestReconcile is deliberately a no-op: an offline connection has no | |
| 279 | - | // observation to reconcile, so the hint starts no probe, timer, or reconcile. | |
| 315 | + | // RequestReconcile remains a no-op until the observation service exposes a | |
| 316 | + | // connection-safe reconcile seam. | |
| 280 | 317 | func (s *Service) RequestReconcile(string) {} | |
| 281 | 318 | ||
| 282 | 319 | // Close cancels and drains every owned resource exactly once: in-flight setup | |
M internal/usecase/broker/service_test.go +357 -31
| @@ -2,10 +2,13 @@ package broker | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "fmt" | |
| 7 | + | "strings" | |
| 6 | 8 | "sync" | |
| 7 | 9 | "testing" | |
| 8 | 10 | "time" | |
| 11 | + | "unicode/utf8" | |
| 9 | 12 | ||
| 10 | 13 | "github.com/bnema/vev/internal/domain" | |
| 11 | 14 | "github.com/bnema/vev/internal/ports" | |
| @@ -13,23 +16,32 @@ import ( | |||
| 13 | 16 | ) | |
| 14 | 17 | ||
| 15 | 18 | // newTestAuthority composes one offline authority over an observation-disabled | |
| 16 | - | // registry, a bounded pool, and a supervisor on a long grace. Every dependency | |
| 17 | - | // is drained at test end so no run goroutine leaks between tests: the registry | |
| 18 | - | // run is canceled and joined (which flushes and stops its durable writer), the | |
| 19 | - | // pool is closed, and the supervisor is closed. | |
| 19 | + | // registry, a bounded pool, and a supervisor on a long grace. | |
| 20 | 20 | func newTestAuthority(t testing.TB, epoch ports.BrokerEpoch, probe ports.BrokerHostProbe, connector poolConnector) (*Authority, *Registry, *Pool, *Supervisor, *manualClock, *testStore) { | |
| 21 | 21 | t.Helper() | |
| 22 | 22 | clock := newManualClock(time.Unix(0, 0)) | |
| 23 | 23 | store := newTestStore() | |
| 24 | 24 | registry, err := NewRegistryWithConfig(epoch, store, probe, clock, nil, RegistryConfig{ObservationDisabled: true}) | |
| 25 | 25 | require.NoError(t, err) | |
| 26 | + | authority, pool, supervisor, _ := composeTestAuthority(t, epoch, registry, connector, clock) | |
| 27 | + | return authority, registry, pool, supervisor, clock, store | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // composeTestAuthority wires one existing registry into a bounded pool and a | |
| 31 | + | // supervisor on a long grace, and owns the single Registry.Run. Every dependency | |
| 32 | + | // is drained at test end so no run goroutine leaks between tests: the registry | |
| 33 | + | // run is canceled and joined (which flushes and stops its durable writer), the | |
| 34 | + | // pool is closed, and the supervisor is closed. The supervisor's own manual | |
| 35 | + | // clock is returned so a test can drive the idle lifecycle. | |
| 36 | + | func composeTestAuthority(t testing.TB, epoch ports.BrokerEpoch, registry *Registry, connector poolConnector, clock *manualClock) (*Authority, *Pool, *Supervisor, *manualClock) { | |
| 37 | + | t.Helper() | |
| 26 | 38 | resolver := poolResolver(func(_ context.Context, r ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) { | |
| 27 | 39 | return ports.BrokerResolvedEndpoint{Identity: "canonical", Policy: r.Policy, Address: "fake"}, nil | |
| 28 | 40 | }) | |
| 29 | 41 | pool, err := NewPool(epoch, resolver, connector, clock, PoolLimits{Physical: 4, Clients: 8, Streams: 16, StreamsPerClient: 16, Idle: time.Minute}) | |
| 30 | 42 | require.NoError(t, err) | |
| 31 | 43 | t.Cleanup(func() { require.NoError(t, pool.Close()) }) | |
| 32 | - | supervisor, _ := newTestSupervisor(t, time.Hour) | |
| 44 | + | supervisor, idleClock := newTestSupervisor(t, time.Hour) | |
| 33 | 45 | authority, err := NewAuthority(epoch, registry, pool, supervisor) | |
| 34 | 46 | require.NoError(t, err) | |
| 35 | 47 | // The caller owns Registry.Run (see NewAuthority): start the single run and | |
| @@ -39,7 +51,70 @@ func newTestAuthority(t testing.TB, epoch ports.BrokerEpoch, probe ports.BrokerH | |||
| 39 | 51 | registryDone := make(chan struct{}) | |
| 40 | 52 | go func() { defer close(registryDone); registry.Run(registryCtx) }() | |
| 41 | 53 | t.Cleanup(func() { stopRegistry(); <-registryDone }) | |
| 42 | - | return authority, registry, pool, supervisor, clock, store | |
| 54 | + | return authority, pool, supervisor, idleClock | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // newServiceMembershipFixture composes one admitted authority over an | |
| 58 | + | // observation-disabled registry with the requested membership mode. The store is | |
| 59 | + | // the caller's, so a test can observe the exact CAS count and inject failures. | |
| 60 | + | func newServiceMembershipFixture(t testing.TB, store ports.BrokerHostStore, mode MembershipMode) (*Authority, *Registry, *Supervisor, *manualClock) { | |
| 61 | + | t.Helper() | |
| 62 | + | clock := newManualClock(time.Unix(0, 0)) | |
| 63 | + | registry, err := NewRegistryWithConfig(1, store, nil, clock, nil, RegistryConfig{ | |
| 64 | + | MembershipMode: mode, | |
| 65 | + | ObservationDisabled: true, | |
| 66 | + | IncarnationGenerator: (&incarnationSequencer{}).generate, | |
| 67 | + | }) | |
| 68 | + | require.NoError(t, err) | |
| 69 | + | registry.jitter = identityJitter | |
| 70 | + | authority, _, supervisor, idleClock := composeTestAuthority(t, 1, registry, immediateConnector, clock) | |
| 71 | + | return authority, registry, supervisor, idleClock | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // requireImmutableRefusal asserts one service-level membership mutation was | |
| 75 | + | // refused with the typed immutable code 12 and the ports sentinel. | |
| 76 | + | func requireImmutableRefusal(t *testing.T, err error) { | |
| 77 | + | t.Helper() | |
| 78 | + | require.Error(t, err) | |
| 79 | + | var refusal ports.BrokerError | |
| 80 | + | require.ErrorAs(t, err, &refusal) | |
| 81 | + | require.Equal(t, ports.BrokerErrorMembershipImmutable, refusal.Code) | |
| 82 | + | require.Equal(t, ports.BrokerErrorCode(12), refusal.Code, "immutable membership is the exact code 12") | |
| 83 | + | require.ErrorIs(t, err, ports.ErrBrokerMembershipImmutable) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // casThenGateStore commits its membership CAS and then parks the mutation until | |
| 87 | + | // release, so a test can make a caller cancellation arrive strictly after the | |
| 88 | + | // durable commit point but before the mutation returns. | |
| 89 | + | type casThenGateStore struct { | |
| 90 | + | *testStore | |
| 91 | + | committed chan struct{} | |
| 92 | + | release chan struct{} | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func newCASThenGateStore() *casThenGateStore { | |
| 96 | + | return &casThenGateStore{testStore: newTestStore(), committed: make(chan struct{}, 1), release: make(chan struct{})} | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func (s *casThenGateStore) ReplaceHosts(expected uint64, hosts []ports.BrokerHostRecord) error { | |
| 100 | + | if err := s.testStore.ReplaceHosts(expected, hosts); err != nil { | |
| 101 | + | return err | |
| 102 | + | } | |
| 103 | + | select { | |
| 104 | + | case s.committed <- struct{}{}: | |
| 105 | + | default: | |
| 106 | + | } | |
| 107 | + | <-s.release | |
| 108 | + | return nil | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // releaseCAS lets a parked post-commit mutation return. | |
| 112 | + | func (s *casThenGateStore) releaseCAS() { | |
| 113 | + | select { | |
| 114 | + | case <-s.release: | |
| 115 | + | default: | |
| 116 | + | close(s.release) | |
| 117 | + | } | |
| 43 | 118 | } | |
| 44 | 119 | ||
| 45 | 120 | // immediateConnector returns one healthy physical connection for any endpoint. | |
| @@ -121,34 +196,38 @@ func TestAuthorityAdmissionContextNotRetained(t *testing.T) { | |||
| 121 | 196 | func TestAuthorityValidation(t *testing.T) { | |
| 122 | 197 | _, _, pool, supervisor, clock, _ := newTestAuthority(t, 1, nil, immediateConnector) | |
| 123 | 198 | ||
| 124 | - | observing, err := NewRegistry(1, newTestStore(), newTestProbe(1), clock, nil) | |
| 199 | + | // Live membership mutation is composed in this slice, so the authority no | |
| 200 | + | // longer refuses a registry that owns remote membership its connections can | |
| 201 | + | // now manage: a mutable remote registry is served. | |
| 202 | + | mutableRemote, err := NewRegistryWithConfig(1, newTestStore(), newTestProbe(1), clock, nil, RegistryConfig{MembershipMode: MembershipMutable}) | |
| 125 | 203 | require.NoError(t, err) | |
| 126 | - | _, err = NewAuthority(1, observing, pool, supervisor) | |
| 127 | - | require.Error(t, err, "an observing registry must be refused by an offline authority") | |
| 128 | - | ||
| 129 | - | // An observer that is limited to the broker's own daemon is served: it has | |
| 130 | - | // no remote membership for the authority's immutable-membership connections | |
| 131 | - | // to manage, and its local entry is configured authority rather than | |
| 132 | - | // membership. | |
| 204 | + | t.Cleanup(mutableRemote.settle) | |
| 205 | + | require.NoError(t, mutableRemote.setHosts(hostRecords(registration(t, "user@host:22", 1)))) | |
| 206 | + | _, err = NewAuthority(1, mutableRemote, pool, supervisor) | |
| 207 | + | require.NoError(t, err, "a mutable remote registry must be served") | |
| 208 | + | ||
| 209 | + | // A local-only observer is served: its entry is configured authority rather | |
| 210 | + | // than membership, so it has nothing for the authority's connections to | |
| 211 | + | // manage either. | |
| 133 | 212 | localOnly, err := NewRegistryWithConfig(1, newTestStore(), nil, clock, nil, RegistryConfig{Local: &LocalObservation{ | |
| 134 | 213 | DisplayOrigin: "local", | |
| 135 | 214 | Policy: poolPolicy(), | |
| 136 | 215 | Probe: newScriptedLocalProbe(1), | |
| 137 | 216 | }}) | |
| 138 | 217 | require.NoError(t, err) | |
| 218 | + | t.Cleanup(localOnly.settle) | |
| 139 | 219 | _, err = NewAuthority(1, localOnly, pool, supervisor) | |
| 140 | 220 | require.NoError(t, err, "a local-only observer must be served") | |
| 141 | 221 | ||
| 142 | - | // The same observer stops being served as soon as it owns remote membership | |
| 143 | - | // the authority's connections could never manage. | |
| 144 | - | observerWithMembership, err := NewRegistry(1, newTestStore(), newTestProbe(1), clock, nil) | |
| 145 | - | require.NoError(t, err) | |
| 146 | - | require.NoError(t, observerWithMembership.setHosts(hostRecords(registration(t, "user@host:22", 1)))) | |
| 147 | - | _, err = NewAuthority(1, observerWithMembership, pool, supervisor) | |
| 148 | - | require.Error(t, err, "an observer that owns remote membership must be refused") | |
| 222 | + | // Construction still rejects remote observation without a probe: a registry | |
| 223 | + | // that would observe remotes but owns no probe cannot be constructed, so no | |
| 224 | + | // authority ever receives one. | |
| 225 | + | _, err = NewRegistry(1, newTestStore(), nil, clock, nil) | |
| 226 | + | require.ErrorContains(t, err, "invalid registry dependencies", "remote observation without a probe must be refused") | |
| 149 | 227 | ||
| 150 | 228 | disabled, err := NewRegistryWithConfig(1, newTestStore(), nil, clock, nil, RegistryConfig{ObservationDisabled: true}) | |
| 151 | 229 | require.NoError(t, err) | |
| 230 | + | t.Cleanup(disabled.settle) | |
| 152 | 231 | _, err = NewAuthority(2, disabled, pool, supervisor) | |
| 153 | 232 | require.Error(t, err, "an epoch mismatch must be refused") | |
| 154 | 233 | ||
| @@ -449,27 +528,274 @@ func TestServiceShutdownTerminatesOwnedWork(t *testing.T) { | |||
| 449 | 528 | require.Zero(t, clients) | |
| 450 | 529 | } | |
| 451 | 530 | ||
| 452 | - | func TestServiceOfflineMembershipRefusal(t *testing.T) { | |
| 453 | - | authority, registry, _, _, _, _ := newTestAuthority(t, 1, nil, immediateConnector) | |
| 531 | + | // TestServiceImmutableMembershipRefusesEveryMutation proves the immutable | |
| 532 | + | // connection refuses all three membership mutations with the typed immutable | |
| 533 | + | // code 12 and the ports sentinel before any durable authority moves: no CAS is | |
| 534 | + | // attempted, no publication changes, and reconcile stays a no-op. | |
| 535 | + | func TestServiceImmutableMembershipRefusesEveryMutation(t *testing.T) { | |
| 536 | + | store := newMembershipStore() | |
| 537 | + | authority, registry, _, _ := newServiceMembershipFixture(t, store, MembershipImmutable) | |
| 454 | 538 | service, err := authority.AdmitClient(context.Background()) | |
| 455 | 539 | require.NoError(t, err) | |
| 456 | 540 | t.Cleanup(func() { require.NoError(t, service.Close()) }) | |
| 457 | 541 | ||
| 458 | - | err = service.AddHost(context.Background(), "new@host:22") | |
| 459 | - | require.ErrorIs(t, err, ErrOfflineMembership) | |
| 460 | - | var typed ports.BrokerError | |
| 461 | - | require.ErrorAs(t, err, &typed) | |
| 542 | + | before := registry.Snapshot() | |
| 543 | + | _, err = service.AddHost(context.Background(), "new@host:22", poolPolicy()) | |
| 544 | + | requireImmutableRefusal(t, err) | |
| 462 | 545 | ||
| 463 | - | removed, err := service.RemoveHost(context.Background(), "new@host:22") | |
| 546 | + | removed, err := service.RemoveHost(context.Background(), registration(t, "new@host:22", 1)) | |
| 464 | 547 | require.False(t, removed) | |
| 465 | - | require.ErrorIs(t, err, ErrOfflineMembership) | |
| 548 | + | requireImmutableRefusal(t, err) | |
| 549 | + | ||
| 550 | + | updated, err := service.UpdateHostPolicy(context.Background(), registration(t, "new@host:22", 1), policyWithTrust("changed-trust")) | |
| 551 | + | require.Equal(t, domain.RemoteRegistration{}, updated) | |
| 552 | + | requireImmutableRefusal(t, err) | |
| 553 | + | ||
| 554 | + | require.Equal(t, 0, store.casCalls(), "an immutable refusal never reaches the store CAS") | |
| 555 | + | require.Equal(t, before, registry.Snapshot(), "a refused mutation must not touch authority") | |
| 466 | 556 | ||
| 467 | - | require.Empty(t, registry.Snapshot().Daemons, "a refused mutation must not touch authority") | |
| 468 | - | before := registry.Snapshot() | |
| 469 | 557 | service.RequestReconcile("new@host:22") | |
| 470 | 558 | require.Equal(t, before, registry.Snapshot(), "reconcile must be a no-op") | |
| 471 | 559 | } | |
| 472 | 560 | ||
| 561 | + | // TestServiceMutableMembershipDelegatesOnce proves an admitted mutable | |
| 562 | + | // connection delegates each mutation exactly once to durable authority: each | |
| 563 | + | // call crosses the store CAS exactly once and returns the registry's own | |
| 564 | + | // result. | |
| 565 | + | func TestServiceMutableMembershipDelegatesOnce(t *testing.T) { | |
| 566 | + | store := newMembershipStore() | |
| 567 | + | authority, registry, _, _ := newServiceMembershipFixture(t, store, MembershipMutable) | |
| 568 | + | service, err := authority.AdmitClient(context.Background()) | |
| 569 | + | require.NoError(t, err) | |
| 570 | + | t.Cleanup(func() { require.NoError(t, service.Close()) }) | |
| 571 | + | ||
| 572 | + | added, err := service.AddHost(context.Background(), "user@host:22", poolPolicy()) | |
| 573 | + | require.NoError(t, err) | |
| 574 | + | require.False(t, added.IsZero()) | |
| 575 | + | require.Equal(t, 1, store.casCalls(), "one addition crosses the store CAS exactly once") | |
| 576 | + | ||
| 577 | + | updated, err := service.UpdateHostPolicy(context.Background(), added, policyWithTrust("changed-trust")) | |
| 578 | + | require.NoError(t, err) | |
| 579 | + | require.Equal(t, added.Generation+1, updated.Generation) | |
| 580 | + | require.Equal(t, added.Incarnation, updated.Incarnation) | |
| 581 | + | require.Equal(t, 2, store.casCalls(), "one policy update crosses the store CAS exactly once") | |
| 582 | + | ||
| 583 | + | removed, err := service.RemoveHost(context.Background(), updated) | |
| 584 | + | require.NoError(t, err) | |
| 585 | + | require.True(t, removed) | |
| 586 | + | require.Equal(t, 3, store.casCalls(), "one removal crosses the store CAS exactly once") | |
| 587 | + | require.Empty(t, registry.Snapshot().Daemons) | |
| 588 | + | } | |
| 589 | + | ||
| 590 | + | // TestServiceMutationLeasePinsIdleLifecycle proves an in-flight membership | |
| 591 | + | // mutation holds one operation lease: the idle timer stays disarmed while the | |
| 592 | + | // mutation is parked past the grace, and the broker expires only after it | |
| 593 | + | // returns and the lease is released. | |
| 594 | + | func TestServiceMutationLeasePinsIdleLifecycle(t *testing.T) { | |
| 595 | + | store := newCASThenGateStore() | |
| 596 | + | authority, _, supervisor, idleClock := newServiceMembershipFixture(t, store, MembershipMutable) | |
| 597 | + | service, err := authority.AdmitClient(context.Background()) | |
| 598 | + | require.NoError(t, err) | |
| 599 | + | t.Cleanup(func() { require.NoError(t, service.Close()) }) | |
| 600 | + | ||
| 601 | + | type outcome struct { | |
| 602 | + | registration domain.RemoteRegistration | |
| 603 | + | err error | |
| 604 | + | } | |
| 605 | + | result := make(chan outcome, 1) | |
| 606 | + | go func() { | |
| 607 | + | registration, err := service.AddHost(context.Background(), "user@host:22", poolPolicy()) | |
| 608 | + | result <- outcome{registration: registration, err: err} | |
| 609 | + | }() | |
| 610 | + | ||
| 611 | + | // The CAS has committed and the mutation is parked, so its operation lease is | |
| 612 | + | // held: the idle timer is disarmed even well past the grace. | |
| 613 | + | select { | |
| 614 | + | case <-store.committed: | |
| 615 | + | case <-time.After(5 * time.Second): | |
| 616 | + | t.Fatal("the mutation never reached the store CAS") | |
| 617 | + | } | |
| 618 | + | _, operations, armed, _ := supervisorState(supervisor) | |
| 619 | + | require.Equal(t, 1, operations, "the in-flight mutation holds one operation lease") | |
| 620 | + | require.False(t, armed, "a held operation lease disarms the idle timer") | |
| 621 | + | idleClock.Advance(time.Hour * 24) | |
| 622 | + | requireSupervisorOpen(t, supervisor) | |
| 623 | + | ||
| 624 | + | store.releaseCAS() | |
| 625 | + | got := <-result | |
| 626 | + | require.NoError(t, got.err) | |
| 627 | + | require.False(t, got.registration.IsZero()) | |
| 628 | + | ||
| 629 | + | // The lease is released: the idle timer re-arms and the broker expires a | |
| 630 | + | // plain grace later. The connection still holds its client lease, so drop it | |
| 631 | + | // first. | |
| 632 | + | require.NoError(t, service.Close()) | |
| 633 | + | _, operations, armed, _ = supervisorState(supervisor) | |
| 634 | + | require.Zero(t, operations) | |
| 635 | + | require.True(t, armed) | |
| 636 | + | idleClock.Advance(time.Hour) | |
| 637 | + | await(t, supervisor.Done()) | |
| 638 | + | } | |
| 639 | + | ||
| 640 | + | // TestServiceCloseCancelsInFlightRegistryMutation proves Close cancels and | |
| 641 | + | // drains an in-flight registry mutation: the connection context reaches the | |
| 642 | + | // registry's mutation context, the caller observes the typed cancellation, and | |
| 643 | + | // Close returns only after the mutation has joined. | |
| 644 | + | func TestServiceCloseCancelsInFlightRegistryMutation(t *testing.T) { | |
| 645 | + | store := newMembershipStore() | |
| 646 | + | authority, registry, supervisor, _ := newServiceMembershipFixture(t, store, MembershipMutable) | |
| 647 | + | service, err := authority.AdmitClient(context.Background()) | |
| 648 | + | require.NoError(t, err) | |
| 649 | + | ||
| 650 | + | // Hold the registry lock so the mutation parks before it consults its | |
| 651 | + | // context, and Close can cancel the connection context while it waits. | |
| 652 | + | registry.mu.Lock() | |
| 653 | + | released := false | |
| 654 | + | t.Cleanup(func() { | |
| 655 | + | if !released { | |
| 656 | + | registry.mu.Unlock() | |
| 657 | + | } | |
| 658 | + | }) | |
| 659 | + | ||
| 660 | + | type outcome struct { | |
| 661 | + | removed bool | |
| 662 | + | err error | |
| 663 | + | } | |
| 664 | + | result := make(chan outcome, 1) | |
| 665 | + | go func() { | |
| 666 | + | removed, err := service.RemoveHost(context.Background(), registration(t, "user@host:22", 1)) | |
| 667 | + | result <- outcome{removed: removed, err: err} | |
| 668 | + | }() | |
| 669 | + | ||
| 670 | + | // Wait until the mutation holds its operation lease, which is strictly after | |
| 671 | + | // it registered with the connection's drain group. | |
| 672 | + | require.Eventually(t, func() bool { | |
| 673 | + | _, operations, _, _ := supervisorState(supervisor) | |
| 674 | + | return operations == 1 | |
| 675 | + | }, 5*time.Second, time.Millisecond, "the mutation never reached its operation lease") | |
| 676 | + | ||
| 677 | + | // Close cancels the connection context and waits for the in-flight mutation | |
| 678 | + | // to drain. It cannot return while the mutation is parked. | |
| 679 | + | closed := make(chan error, 1) | |
| 680 | + | go func() { closed <- service.Close() }() | |
| 681 | + | select { | |
| 682 | + | case <-closed: | |
| 683 | + | t.Fatal("Close returned before the in-flight mutation drained") | |
| 684 | + | case <-time.After(50 * time.Millisecond): | |
| 685 | + | } | |
| 686 | + | ||
| 687 | + | // Release the registry lock: the mutation now observes the cancelled | |
| 688 | + | // connection context instead of committing. | |
| 689 | + | registry.mu.Unlock() | |
| 690 | + | released = true | |
| 691 | + | ||
| 692 | + | got := <-result | |
| 693 | + | require.ErrorIs(t, got.err, context.Canceled, "the in-flight mutation observes the connection cancellation") | |
| 694 | + | require.False(t, got.removed) | |
| 695 | + | select { | |
| 696 | + | case err := <-closed: | |
| 697 | + | require.NoError(t, err) | |
| 698 | + | case <-time.After(5 * time.Second): | |
| 699 | + | t.Fatal("Close never returned after the mutation drained") | |
| 700 | + | } | |
| 701 | + | require.Zero(t, store.casCalls(), "a cancelled mutation never reaches the store CAS") | |
| 702 | + | require.Empty(t, registry.Snapshot().Daemons, "a cancelled mutation never mutates authority") | |
| 703 | + | } | |
| 704 | + | ||
| 705 | + | // TestServiceMutationCancellationAfterCommitStillReportsSuccess proves a caller | |
| 706 | + | // cancellation that arrives strictly after the durable CAS still reports the | |
| 707 | + | // committed success: the registry's mutation context is not consulted after | |
| 708 | + | // commit, so the value crosses the CAS and returns rather than surfacing a | |
| 709 | + | // cancellation for a mutation that did commit. | |
| 710 | + | func TestServiceMutationCancellationAfterCommitStillReportsSuccess(t *testing.T) { | |
| 711 | + | store := newCASThenGateStore() | |
| 712 | + | authority, _, _, _ := newServiceMembershipFixture(t, store, MembershipMutable) | |
| 713 | + | service, err := authority.AdmitClient(context.Background()) | |
| 714 | + | require.NoError(t, err) | |
| 715 | + | t.Cleanup(func() { require.NoError(t, service.Close()) }) | |
| 716 | + | ||
| 717 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 718 | + | type outcome struct { | |
| 719 | + | registration domain.RemoteRegistration | |
| 720 | + | err error | |
| 721 | + | } | |
| 722 | + | result := make(chan outcome, 1) | |
| 723 | + | go func() { | |
| 724 | + | registration, err := service.AddHost(ctx, "user@host:22", poolPolicy()) | |
| 725 | + | result <- outcome{registration: registration, err: err} | |
| 726 | + | }() | |
| 727 | + | ||
| 728 | + | select { | |
| 729 | + | case <-store.committed: | |
| 730 | + | case <-time.After(5 * time.Second): | |
| 731 | + | t.Fatal("the mutation never reached the store CAS") | |
| 732 | + | } | |
| 733 | + | // The commit already happened; a cancellation now cannot un-commit it. | |
| 734 | + | cancel() | |
| 735 | + | store.releaseCAS() | |
| 736 | + | ||
| 737 | + | got := <-result | |
| 738 | + | require.NoError(t, got.err, "a cancellation after the CAS must not turn a committed mutation into a failure") | |
| 739 | + | require.False(t, got.registration.IsZero()) | |
| 740 | + | } | |
| 741 | + | ||
| 742 | + | // TestServiceMembershipErrorMapping proves the service classifies a durable | |
| 743 | + | // store outcome-unknown as the typed unknown code with its cause preserved, and | |
| 744 | + | // surfaces the exact conflict code for a conflicting authority change. | |
| 745 | + | func TestServiceMembershipErrorTextIsWireSafe(t *testing.T) { | |
| 746 | + | secretPath := "/home/alice/.config/vev/private-hosts.json" | |
| 747 | + | cause := errors.New(secretPath + "\x00\n" + strings.Repeat("é", ports.BrokerMaxErrorBytes)) | |
| 748 | + | err := membershipError(ports.BrokerStoreOutcomeUnknownError{Err: cause}) | |
| 749 | + | var failure ports.BrokerError | |
| 750 | + | require.ErrorAs(t, err, &failure) | |
| 751 | + | require.NoError(t, failure.Validate()) | |
| 752 | + | require.LessOrEqual(t, len(failure.Text), ports.BrokerMaxErrorBytes) | |
| 753 | + | require.NotContains(t, failure.Text, "\x00") | |
| 754 | + | require.NotContains(t, failure.Text, "\n") | |
| 755 | + | require.NotContains(t, failure.Text, secretPath) | |
| 756 | + | require.Equal(t, "mutation outcome is unknown", failure.Text) | |
| 757 | + | require.ErrorIs(t, failure, cause) | |
| 758 | + | require.True(t, utf8.ValidString(failure.Text)) | |
| 759 | + | } | |
| 760 | + | ||
| 761 | + | func TestServiceMembershipErrorMapping(t *testing.T) { | |
| 762 | + | t.Run("outcome unknown maps to outcome-unknown with its cause", func(t *testing.T) { | |
| 763 | + | cause := errors.New("power loss") | |
| 764 | + | store := newMembershipStore() | |
| 765 | + | store.failCAS(ports.BrokerStoreOutcomeUnknownError{Err: cause}) | |
| 766 | + | authority, _, _, _ := newServiceMembershipFixture(t, store, MembershipMutable) | |
| 767 | + | service, err := authority.AdmitClient(context.Background()) | |
| 768 | + | require.NoError(t, err) | |
| 769 | + | t.Cleanup(func() { require.NoError(t, service.Close()) }) | |
| 770 | + | ||
| 771 | + | _, err = service.AddHost(context.Background(), "user@host:22", poolPolicy()) | |
| 772 | + | require.Error(t, err) | |
| 773 | + | var failure ports.BrokerError | |
| 774 | + | require.ErrorAs(t, err, &failure) | |
| 775 | + | require.Equal(t, ports.BrokerErrorOutcomeUnknown, failure.Code) | |
| 776 | + | require.ErrorIs(t, err, cause, "the underlying store cause stays inspectable") | |
| 777 | + | var unknown ports.BrokerStoreOutcomeUnknownError | |
| 778 | + | require.ErrorAs(t, err, &unknown) | |
| 779 | + | }) | |
| 780 | + | ||
| 781 | + | t.Run("an exact conflict maps to code 11", func(t *testing.T) { | |
| 782 | + | store := newMembershipStore() | |
| 783 | + | store.failCAS(ports.ErrBrokerHostConflict) | |
| 784 | + | authority, _, _, _ := newServiceMembershipFixture(t, store, MembershipMutable) | |
| 785 | + | service, err := authority.AdmitClient(context.Background()) | |
| 786 | + | require.NoError(t, err) | |
| 787 | + | t.Cleanup(func() { require.NoError(t, service.Close()) }) | |
| 788 | + | ||
| 789 | + | _, err = service.UpdateHostPolicy(context.Background(), registration(t, "user@host:22", 1), poolPolicy()) | |
| 790 | + | require.Error(t, err) | |
| 791 | + | var failure ports.BrokerError | |
| 792 | + | require.ErrorAs(t, err, &failure) | |
| 793 | + | require.Equal(t, ports.BrokerErrorHostConflict, failure.Code) | |
| 794 | + | require.Equal(t, ports.BrokerErrorCode(11), failure.Code, "host conflict is the exact code 11") | |
| 795 | + | require.ErrorIs(t, err, ports.ErrBrokerHostConflict) | |
| 796 | + | }) | |
| 797 | + | } | |
| 798 | + | ||
| 473 | 799 | func TestRegistryObservationDisabledRestoresAndPublishes(t *testing.T) { | |
| 474 | 800 | clock := newManualClock(time.Unix(0, 0)) | |
| 475 | 801 | store := newTestStore() | |
M internal/usecase/client/supervisor_integration_test.go +11 -3
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "github.com/stretchr/testify/require" | |
| 14 | 14 | ||
| 15 | 15 | "github.com/bnema/vev/internal/adapters/brokeripc" | |
| 16 | + | "github.com/bnema/vev/internal/domain" | |
| 16 | 17 | "github.com/bnema/vev/internal/ports" | |
| 17 | 18 | ) | |
| 18 | 19 | ||
| @@ -55,9 +56,16 @@ func (c *integrationCore) OpenStream(context.Context, ports.BrokerOpenStreamRequ | |||
| 55 | 56 | func (c *integrationCore) CloseStream(ports.BrokerConnectionID, ports.BrokerStreamID) error { | |
| 56 | 57 | return nil | |
| 57 | 58 | } | |
| 58 | - | func (c *integrationCore) AddHost(context.Context, string) error { return nil } | |
| 59 | - | func (c *integrationCore) RemoveHost(context.Context, string) (bool, error) { return false, nil } | |
| 60 | - | func (c *integrationCore) RequestReconcile(string) {} | |
| 59 | + | func (c *integrationCore) AddHost(context.Context, string, ports.BrokerPolicy) (domain.RemoteRegistration, error) { | |
| 60 | + | return domain.RemoteRegistration{}, nil | |
| 61 | + | } | |
| 62 | + | func (c *integrationCore) RemoveHost(context.Context, domain.RemoteRegistration) (bool, error) { | |
| 63 | + | return false, nil | |
| 64 | + | } | |
| 65 | + | func (c *integrationCore) UpdateHostPolicy(context.Context, domain.RemoteRegistration, ports.BrokerPolicy) (domain.RemoteRegistration, error) { | |
| 66 | + | return domain.RemoteRegistration{}, nil | |
| 67 | + | } | |
| 68 | + | func (c *integrationCore) RequestReconcile(string) {} | |
| 61 | 69 | ||
| 62 | 70 | func (c *integrationCore) Close() error { | |
| 63 | 71 | c.once.Do(func() { | |
M internal/usecase/client/supervisor_test.go +8 -2
| @@ -390,12 +390,18 @@ func (s *supervisorTestService) CloseStream(ports.BrokerConnectionID, ports.Brok | |||
| 390 | 390 | return nil | |
| 391 | 391 | } | |
| 392 | 392 | ||
| 393 | - | func (s *supervisorTestService) AddHost(context.Context, string) error { return nil } | |
| 393 | + | func (s *supervisorTestService) AddHost(context.Context, string, ports.BrokerPolicy) (domain.RemoteRegistration, error) { | |
| 394 | + | return domain.RemoteRegistration{}, nil | |
| 395 | + | } | |
| 394 | 396 | ||
| 395 | - | func (s *supervisorTestService) RemoveHost(context.Context, string) (bool, error) { | |
| 397 | + | func (s *supervisorTestService) RemoveHost(context.Context, domain.RemoteRegistration) (bool, error) { | |
| 396 | 398 | return false, nil | |
| 397 | 399 | } | |
| 398 | 400 | ||
| 401 | + | func (s *supervisorTestService) UpdateHostPolicy(context.Context, domain.RemoteRegistration, ports.BrokerPolicy) (domain.RemoteRegistration, error) { | |
| 402 | + | return domain.RemoteRegistration{}, nil | |
| 403 | + | } | |
| 404 | + | ||
| 399 | 405 | func (s *supervisorTestService) RequestReconcile(string) {} | |
| 400 | 406 | ||
| 401 | 407 | func (s *supervisorTestService) Close() error { | |