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 | decode-failure classification. | 23 | decode-failure classification. | ||
| 24 | - `internal/adapters/brokerwire`: translates between typed broker messages | 24 | - `internal/adapters/brokerwire`: translates between typed broker messages | ||
| 25 | and the separate broker Protobuf conversation (`schema/broker.proto`; | 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 | tag, each direction its own closed `oneof`). It owns the stateless | 27 | tag, each direction its own closed `oneof`). It owns the stateless | ||
| 28 | broker codec over `wire.ScanEnvelope`, the broker preamble (roles 3/4, | 28 | broker codec over `wire.ScanEnvelope`, the broker preamble (roles 3/4, | ||
| 29 | exact `protocol.Version`, 1 MiB..16 MiB / 1..64 KiB ceilings, 4 KiB | 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 | `go tool buf generate`, and handle the new variant in `sessionwire`. | 197 | `go tool buf generate`, and handle the new variant in `sessionwire`. | ||
| 198 | Never edit generated `*.pb.go`; never add manual IDs or dispatch tables. | 198 | Never edit generated `*.pb.go`; never add manual IDs or dispatch tables. | ||
| 199 | - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`. | 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 | ## Broker wire core (Plan 001 P3.1, not activated) | 202 | ## Broker wire core (Plan 001 P3.1, not activated) | ||
| 203 | 203 | ||||
| 204 | `internal/adapters/brokerwire` owns the broker's separate Protobuf | 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 | disjoint from every session tag, and each direction is its own closed | 206 | disjoint from every session tag, and each direction is its own closed | ||
| 207 | directional `oneof` union. A broker connection opens with exactly one | 207 | directional `oneof` union. A broker connection opens with exactly one | ||
| 208 | broker preamble (roles 3/4) that carries the same magic, epoch, exact | 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 | ## Broker negotiation | 97 | ## Broker negotiation | ||
| 98 | 98 | ||||
| 99 | Broker connections are a separate conversation from the session protocol. | 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 | 201-209 are disjoint from every session tag, and each direction is its own | 101 | 201-209 are disjoint from every session tag, and each direction is its own | ||
| 102 | closed directional `oneof` union. A broker connection opens with exactly one | 102 | closed directional `oneof` union. A broker connection opens with exactly one | ||
| 103 | broker preamble (`PreambleRequest` client → server, `PreambleResponse` server | 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 | time.Sleep(400 * time.Millisecond) | 176 | time.Sleep(400 * time.Millisecond) | ||
| 177 | require.ErrorIs(t, setupCtx.Err(), context.DeadlineExceeded, "the setup context must have expired") | 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) | 179 | + | requireConnectionDelegates(t, client) |
| 180 | - | defer cancel() | |||
| 181 | - | require.NoError(t, client.AddHost(ctx, "after@setup:22"), "a registered connection must outlive its setup context") | |||
| 182 | } | 180 | } | ||
| 183 | 181 | ||||
| 184 | // TestServerRegistrationBudgetSettlesSilentPeer proves a same-user peer cannot | 182 | // TestServerRegistrationBudgetSettlesSilentPeer proves a same-user peer cannot | ||
| @@ -279,9 +277,7 @@ func TestServerRegistrationBudgetLeavesRegisteredSessionHealthy(t *testing.T) { | |||||
| 279 | 277 | ||||
| 280 | // Outlive the accept-time registration budget. | 278 | // Outlive the accept-time registration budget. | ||
| 281 | time.Sleep(400 * time.Millisecond) | 279 | time.Sleep(400 * time.Millisecond) | ||
| 282 | - | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | 280 | + | requireConnectionDelegates(t, client) |
| 283 | - | defer cancel() | |||
| 284 | - | require.NoError(t, client.AddHost(ctx, "after@budget:22")) | |||
| 285 | select { | 281 | select { | ||
| 286 | case <-core.done: | 282 | case <-core.done: | ||
| 287 | t.Fatal("a session that registered in time must not be settled by the registration budget") | 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 | "github.com/bnema/vev/internal/adapters/brokerwire" | 9 | "github.com/bnema/vev/internal/adapters/brokerwire" | ||
| 10 | "github.com/bnema/vev/internal/adapters/ipc" | 10 | "github.com/bnema/vev/internal/adapters/ipc" | ||
| 11 | "github.com/bnema/vev/internal/adapters/streamframe" | 11 | "github.com/bnema/vev/internal/adapters/streamframe" | ||
| 12 | + | "github.com/bnema/vev/internal/domain" | |||
| 12 | "github.com/bnema/vev/internal/ports" | 13 | "github.com/bnema/vev/internal/ports" | ||
| 13 | "github.com/bnema/vev/internal/protocol/wire" | 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 | ctx: ctx, | 122 | ctx: ctx, | ||
| 122 | cancel: cancelSession, | 123 | cancel: cancelSession, | ||
| 123 | pending: make(map[ports.BrokerOperationID]chan operationResult), | 124 | pending: make(map[ports.BrokerOperationID]chan operationResult), | ||
| 125 | + | kinds: make(map[ports.BrokerOperationID]brokerwire.RegisterMutationKind), | |||
| 124 | streams: make(map[ports.BrokerStreamID]*clientStream), | 126 | streams: make(map[ports.BrokerStreamID]*clientStream), | ||
| 125 | done: make(chan struct{}), | 127 | done: make(chan struct{}), | ||
| 126 | } | 128 | } | ||
| @@ -192,9 +194,10 @@ func exchangeRegister(transport wire.BoundedTransport, ceilings brokerwire.Ceili | |||||
| 192 | 194 | ||||
| 193 | // operationResult is one reply to a mutating operation. | 195 | // operationResult is one reply to a mutating operation. | ||
| 194 | type operationResult struct { | 196 | type operationResult struct { | ||
| 195 | - | outcome ports.BrokerMutationOutcome | 197 | + | outcome ports.BrokerMutationOutcome |
| 196 | - | removed bool | 198 | + | removed bool |
| 197 | - | detail brokerwire.ErrorDetail | 199 | + | registration domain.RemoteRegistration |
| 200 | + | detail brokerwire.ErrorDetail | |||
| 198 | } | 201 | } | ||
| 199 | 202 | ||||
| 200 | // client implements ports.BrokerService over one broker IPC connection. | 203 | // client implements ports.BrokerService over one broker IPC connection. | ||
| @@ -217,6 +220,7 @@ type client struct { | |||||
| 217 | nextStream ports.BrokerStreamID | 220 | nextStream ports.BrokerStreamID | ||
| 218 | snapshot ports.BrokerSnapshot | 221 | snapshot ports.BrokerSnapshot | ||
| 219 | pending map[ports.BrokerOperationID]chan operationResult | 222 | pending map[ports.BrokerOperationID]chan operationResult | ||
| 223 | + | kinds map[ports.BrokerOperationID]brokerwire.RegisterMutationKind | |||
| 220 | streams map[ports.BrokerStreamID]*clientStream | 224 | streams map[ports.BrokerStreamID]*clientStream | ||
| 221 | closeOnce sync.Once | 225 | closeOnce sync.Once | ||
| 222 | done chan struct{} | 226 | done chan struct{} | ||
| @@ -267,10 +271,21 @@ func (c *client) dispatch(message brokerwire.ServerMessage) error { | |||||
| 267 | if !c.scopeMatches(m.Epoch, m.Connection) { | 271 | if !c.scopeMatches(m.Epoch, m.Connection) { | ||
| 268 | return errors.Join(ErrScopeMismatch, ErrProtocol) | 272 | return errors.Join(ErrScopeMismatch, ErrProtocol) | ||
| 269 | } | 273 | } | ||
| 270 | - | // The operation's waiter records the tracker completion exactly once; | 274 | + | c.mu.Lock() |
| 271 | - | // a result with no waiter (an abandoned or replayed operation) is | 275 | + | _, ok := c.pending[m.Operation] |
| 272 | - | // dropped here. | 276 | + | kind := c.kinds[m.Operation] |
| 273 | - | c.completeOperation(m.Operation, operationResult{outcome: m.Outcome, removed: m.Removed, detail: m.Error}) | 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 | return nil | 289 | return nil | ||
| 275 | case brokerwire.StreamOpened: | 290 | case brokerwire.StreamOpened: | ||
| 276 | if !c.scopeMatches(m.Epoch, m.Connection) { | 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 | // completeOperation hands one result to the operation's waiter, if any. | 387 | // completeOperation hands one result to the operation's waiter, if any. | ||
| 365 | func (c *client) completeOperation(operation ports.BrokerOperationID, result operationResult) { | 388 | func (c *client) completeOperation(operation ports.BrokerOperationID, result operationResult) { | ||
| 366 | c.mu.Lock() | 389 | c.mu.Lock() | ||
| 367 | - | ch := c.pending[operation] | 390 | + | reply, ok := c.pending[operation] |
| 368 | delete(c.pending, operation) | 391 | delete(c.pending, operation) | ||
| 392 | + | delete(c.kinds, operation) | |||
| 369 | c.mu.Unlock() | 393 | c.mu.Unlock() | ||
| 370 | - | if ch == nil { | 394 | + | if !ok { |
| 371 | return | 395 | return | ||
| 372 | } | 396 | } | ||
| 373 | select { | 397 | select { | ||
| 374 | - | case ch <- result: | 398 | + | case reply <- result: |
| 375 | default: | 399 | default: | ||
| 376 | } | 400 | } | ||
| 377 | } | 401 | } | ||
| @@ -533,30 +557,37 @@ func (c *client) CloseStream(connection ports.BrokerConnectionID, stream ports.B | |||||
| 533 | return nil | 557 | return nil | ||
| 534 | } | 558 | } | ||
| 535 | 559 | ||||
| 536 | - | // AddHost requests one host registration. A lost reply is reported as | 560 | + | // AddHost sends one exact endpoint and policy mutation. |
| 537 | - | // outcome-unknown, never as an invitation to replay blindly. | 561 | + | func (c *client) AddHost(ctx context.Context, endpoint string, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) { |
| 538 | - | func (c *client) AddHost(ctx context.Context, target string) error { | 562 | + | result, err := c.mutate(ctx, brokerwire.MutationKindAddHost, func(operation ports.BrokerOperationID) brokerwire.ClientMessage { |
| 539 | - | result, err := c.mutate(ctx, func(operation ports.BrokerOperationID) brokerwire.ClientMessage { | 563 | + | return brokerwire.AddHost{Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation, Endpoint: endpoint, Policy: policy} |
| 540 | - | return brokerwire.AddHost{Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation, Endpoint: target} | |||
| 541 | }) | 564 | }) | ||
| 542 | if err != nil { | 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. | 571 | + | // RemoveHost sends one exact-registration mutation. |
| 549 | - | func (c *client) RemoveHost(ctx context.Context, target string) (bool, error) { | 572 | + | func (c *client) RemoveHost(ctx context.Context, expected domain.RemoteRegistration) (bool, error) { |
| 550 | - | result, err := c.mutate(ctx, func(operation ports.BrokerOperationID) brokerwire.ClientMessage { | 573 | + | result, err := c.mutate(ctx, brokerwire.MutationKindRemoveHost, func(operation ports.BrokerOperationID) brokerwire.ClientMessage { |
| 551 | - | return brokerwire.RemoveHost{Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation, Endpoint: target} | 574 | + | return brokerwire.RemoveHost{Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation, Registration: expected} |
| 552 | }) | 575 | }) | ||
| 553 | if err != nil { | 576 | if err != nil { | ||
| 554 | return false, err | 577 | return false, err | ||
| 555 | } | 578 | } | ||
| 556 | - | if err := mutationFailure(result); err != nil { | 579 | + | return result.removed, mutationFailure(result) |
| 557 | - | return false, err | 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 | // RequestReconcile asks the broker to re-observe one endpoint. The port carries | 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 | // error and records a failed outcome; once the wire attempt was launched, every | 662 | // error and records a failed outcome; once the wire attempt was launched, every | ||
| 632 | // failure is reported as outcome-unknown, so a caller refreshes authoritative | 663 | // failure is reported as outcome-unknown, so a caller refreshes authoritative | ||
| 633 | // state instead of replaying a non-idempotent mutation blindly. | 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 | operation, err := newOperationID() | 666 | operation, err := newOperationID() | ||
| 636 | if err != nil { | 667 | if err != nil { | ||
| 637 | return operationResult{}, err | 668 | return operationResult{}, err | ||
| @@ -652,14 +683,16 @@ func (c *client) mutate(ctx context.Context, build func(ports.BrokerOperationID) | |||||
| 652 | return operationResult{}, admissionError(err) | 683 | return operationResult{}, admissionError(err) | ||
| 653 | } | 684 | } | ||
| 654 | c.pending[operation] = reply | 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 | c.mu.Unlock() | 690 | c.mu.Unlock() | ||
| 656 | // No result observed is exactly the outcome-unknown case; a request that | 691 | // No result observed is exactly the outcome-unknown case; a request that | ||
| 657 | // was never launched is recorded as failed instead. | 692 | // was never launched is recorded as failed instead. | ||
| 658 | outcome := ports.BrokerOutcomeUnknown | 693 | outcome := ports.BrokerOutcomeUnknown | ||
| 659 | defer func() { | 694 | defer func() { | ||
| 660 | - | c.mu.Lock() | 695 | + | c.releaseOperation(operation) |
| 661 | - | delete(c.pending, operation) | |||
| 662 | - | c.mu.Unlock() | |||
| 663 | _ = c.conn.CompleteOperation(operation, outcome) | 696 | _ = c.conn.CompleteOperation(operation, outcome) | ||
| 664 | }() | 697 | }() | ||
| 665 | attempted, err := c.sendContext(ctx, build(operation)) | 698 | attempted, err := c.sendContext(ctx, build(operation)) | ||
M internal/adapters/brokeripc/client_mutation_test.go +443 -61
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| 12 | 12 | ||||
| 13 | "github.com/bnema/vev/internal/adapters/brokerwire" | 13 | "github.com/bnema/vev/internal/adapters/brokerwire" | ||
| 14 | + | "github.com/bnema/vev/internal/domain" | |||
| 14 | "github.com/bnema/vev/internal/ports" | 15 | "github.com/bnema/vev/internal/ports" | ||
| 15 | "github.com/bnema/vev/internal/protocol/wire" | 16 | "github.com/bnema/vev/internal/protocol/wire" | ||
| 16 | ) | 17 | ) | ||
| @@ -122,6 +123,7 @@ func newGatedClient(t *testing.T, cfg Config) (*client, *gatedTransport) { | |||||
| 122 | ctx: ctx, | 123 | ctx: ctx, | ||
| 123 | cancel: cancel, | 124 | cancel: cancel, | ||
| 124 | pending: make(map[ports.BrokerOperationID]chan operationResult), | 125 | pending: make(map[ports.BrokerOperationID]chan operationResult), | ||
| 126 | + | kinds: make(map[ports.BrokerOperationID]brokerwire.RegisterMutationKind), | |||
| 125 | streams: make(map[ports.BrokerStreamID]*clientStream), | 127 | streams: make(map[ports.BrokerStreamID]*clientStream), | ||
| 126 | done: make(chan struct{}), | 128 | done: make(chan struct{}), | ||
| 127 | } | 129 | } | ||
| @@ -129,38 +131,104 @@ func newGatedClient(t *testing.T, cfg Config) (*client, *gatedTransport) { | |||||
| 129 | return c, transport | 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 | type mutationOutcome struct { | 137 | type mutationOutcome struct { | ||
| 134 | - | removed bool | 138 | + | removed bool |
| 135 | - | err error | 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 | type mutationCall struct { | 146 | type mutationCall struct { | ||
| 140 | name string | 147 | name string | ||
| 141 | endpoint string | 148 | endpoint string | ||
| 142 | wantRemoved bool | 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 | var mutationCalls = []mutationCall{ | 157 | var mutationCalls = []mutationCall{ | ||
| 147 | { | 158 | { | ||
| 148 | name: "AddHost", | 159 | name: "AddHost", | ||
| 149 | endpoint: "new@host:22", | 160 | endpoint: "new@host:22", | ||
| 150 | - | call: func(ctx context.Context, c *client, endpoint string) (bool, error) { | 161 | + | kind: brokerwire.MutationKindAddHost, |
| 151 | - | return false, c.AddHost(ctx, endpoint) | 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 | name: "RemoveHost", | 174 | name: "RemoveHost", | ||
| 156 | endpoint: "known", | 175 | endpoint: "known", | ||
| 157 | wantRemoved: true, | 176 | wantRemoved: true, | ||
| 158 | - | call: func(ctx context.Context, c *client, endpoint string) (bool, error) { | 177 | + | kind: brokerwire.MutationKindRemoveHost, |
| 159 | - | return c.RemoveHost(ctx, endpoint) | 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 | // awaitResult waits for one mutation call to return. | 232 | // awaitResult waits for one mutation call to return. | ||
| 165 | func awaitResult(t *testing.T, results <-chan mutationOutcome) mutationOutcome { | 233 | func awaitResult(t *testing.T, results <-chan mutationOutcome) mutationOutcome { | ||
| 166 | t.Helper() | 234 | t.Helper() | ||
| @@ -195,26 +263,60 @@ func operationOf(t *testing.T, message brokerwire.ClientMessage) ports.BrokerOpe | |||||
| 195 | return m.Operation | 263 | return m.Operation | ||
| 196 | case brokerwire.RemoveHost: | 264 | case brokerwire.RemoveHost: | ||
| 197 | return m.Operation | 265 | return m.Operation | ||
| 266 | + | case brokerwire.UpdateHostPolicy: | |||
| 267 | + | return m.Operation | |||
| 198 | default: | 268 | default: | ||
| 199 | t.Fatalf("unexpected client message %T", message) | 269 | t.Fatalf("unexpected client message %T", message) | ||
| 200 | return ports.BrokerOperationID{} | 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 | func endpointOf(t *testing.T, message brokerwire.ClientMessage) string { | 277 | func endpointOf(t *testing.T, message brokerwire.ClientMessage) string { | ||
| 206 | t.Helper() | 278 | t.Helper() | ||
| 207 | switch m := message.(type) { | 279 | switch m := message.(type) { | ||
| 208 | case brokerwire.AddHost: | 280 | case brokerwire.AddHost: | ||
| 209 | return m.Endpoint | 281 | return m.Endpoint | ||
| 210 | case brokerwire.RemoveHost: | 282 | case brokerwire.RemoveHost: | ||
| 211 | - | return m.Endpoint | 283 | + | return m.Registration.Endpoint |
| 284 | + | case brokerwire.UpdateHostPolicy: | |||
| 285 | + | return m.Registration.Endpoint | |||
| 212 | default: | 286 | default: | ||
| 213 | t.Fatalf("unexpected client message %T", message) | 287 | t.Fatalf("unexpected client message %T", message) | ||
| 214 | return "" | 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 | // pendingOperations snapshots the client's in-flight waiters. | 320 | // pendingOperations snapshots the client's in-flight waiters. | ||
| 219 | func pendingOperations(c *client) []ports.BrokerOperationID { | 321 | func pendingOperations(c *client) []ports.BrokerOperationID { | ||
| 220 | c.mu.Lock() | 322 | c.mu.Lock() | ||
| @@ -226,19 +328,75 @@ func pendingOperations(c *client) []ports.BrokerOperationID { | |||||
| 226 | return out | 328 | return out | ||
| 227 | } | 329 | } | ||
| 228 | 330 | ||||
| 229 | - | // deliverResult plays one broker reply for a captured request. | 331 | + | func requireNoPendingMutationState(t *testing.T, c *client) { |
| 230 | - | func deliverResult(t *testing.T, c *client, operation ports.BrokerOperationID, outcome ports.BrokerMutationOutcome, removed bool) { | |||
| 231 | t.Helper() | 332 | t.Helper() | ||
| 232 | - | require.NoError(t, c.dispatch(brokerwire.OperationResult{ | 333 | + | c.mu.Lock() |
| 233 | - | Epoch: c.scope.Epoch, Connection: c.scope.Connection, | 334 | + | defer c.mu.Unlock() |
| 234 | - | Operation: operation, Outcome: outcome, Removed: removed, | 335 | + | require.Empty(t, c.pending, "pending replies must be released") |
| 235 | - | })) | 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 | 396 | + | // TestMutationSendUncertaintyTaxonomy pins the decision table for every mutating |
| 239 | - | // operations: a request that definitely never left is a definite error with zero | 397 | + | // operation: a request that definitely never left is a definite error with zero |
| 240 | // sends; any failure observed after the attempt was launched is outcome-unknown | 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 | func TestMutationSendUncertaintyTaxonomy(t *testing.T) { | 400 | func TestMutationSendUncertaintyTaxonomy(t *testing.T) { | ||
| 243 | scenarios := []struct { | 401 | scenarios := []struct { | ||
| 244 | name string | 402 | name string | ||
| @@ -272,6 +430,18 @@ func TestMutationSendUncertaintyTaxonomy(t *testing.T) { | |||||
| 272 | wantSends: 1, | 430 | wantSends: 1, | ||
| 273 | wantUnknown: true, | 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 | name: "Send fails after the request was captured", | 446 | name: "Send fails after the request was captured", | ||
| 277 | drive: func(t *testing.T, c *client, g *gatedTransport, cancel context.CancelFunc) { | 447 | drive: func(t *testing.T, c *client, g *gatedTransport, cancel context.CancelFunc) { | ||
| @@ -281,6 +451,15 @@ func TestMutationSendUncertaintyTaxonomy(t *testing.T) { | |||||
| 281 | wantSends: 1, | 451 | wantSends: 1, | ||
| 282 | wantUnknown: true, | 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 | for _, call := range mutationCalls { | 464 | for _, call := range mutationCalls { | ||
| 286 | for _, tt := range scenarios { | 465 | for _, tt := range scenarios { | ||
| @@ -293,14 +472,16 @@ func TestMutationSendUncertaintyTaxonomy(t *testing.T) { | |||||
| 293 | } | 472 | } | ||
| 294 | results := make(chan mutationOutcome, 1) | 473 | results := make(chan mutationOutcome, 1) | ||
| 295 | go func() { | 474 | go func() { | ||
| 296 | - | removed, err := call.call(ctx, c, call.endpoint) | 475 | + | removed, registration, err := call.run(ctx, c) |
| 297 | - | results <- mutationOutcome{removed: removed, err: err} | 476 | + | results <- mutationOutcome{removed: removed, registration: registration, err: err} |
| 298 | }() | 477 | }() | ||
| 299 | if tt.drive != nil { | 478 | if tt.drive != nil { | ||
| 300 | tt.drive(t, c, g, cancel) | 479 | tt.drive(t, c, g, cancel) | ||
| 301 | } | 480 | } | ||
| 302 | got := awaitResult(t, results) | 481 | got := awaitResult(t, results) | ||
| 303 | require.False(t, got.removed, "no reply means the target is never reported removed") | 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 | if tt.wantUnknown { | 485 | if tt.wantUnknown { | ||
| 305 | var failure ports.BrokerError | 486 | var failure ports.BrokerError | ||
| 306 | require.ErrorAs(t, got.err, &failure) | 487 | require.ErrorAs(t, got.err, &failure) | ||
| @@ -313,7 +494,7 @@ func TestMutationSendUncertaintyTaxonomy(t *testing.T) { | |||||
| 313 | "a request that never left must not be reported as a broker outcome") | 494 | "a request that never left must not be reported as a broker outcome") | ||
| 314 | } | 495 | } | ||
| 315 | require.Equal(t, tt.wantSends, g.sendCount(), "the request must travel at most once") | 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 | ctx, cancel := context.WithCancel(context.Background()) | 508 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 328 | cancel() | 509 | cancel() | ||
| 329 | 510 | ||||
| 330 | - | // Driving mutate directly makes the operation identity observable even | 511 | + | // Driving the public API with an already-cancelled caller is the definite |
| 331 | - | // though the request is never encoded for the wire. | 512 | + | // no-dispatch case: no request is encoded for the wire. |
| 332 | - | var operation ports.BrokerOperationID | 513 | + | added, err := c.AddHost(ctx, "pre@host:22", testPolicy()) |
| 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 | - | }) | |||
| 339 | require.ErrorIs(t, err, context.Canceled) | 514 | require.ErrorIs(t, err, context.Canceled) | ||
| 340 | - | require.Equal(t, operationResult{}, result) | 515 | + | require.Equal(t, domain.RemoteRegistration{}, added) |
| 341 | - | require.NotZero(t, operation) | |||
| 342 | require.Zero(t, g.sendCount(), "a pre-cancelled caller never enters the carriage") | 516 | require.Zero(t, g.sendCount(), "a pre-cancelled caller never enters the carriage") | ||
| 343 | require.Empty(t, pendingOperations(c), "the definite failure releases its pending slot") | 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 | // The bound of one proves the slot is genuinely free: a fresh mutation is | 519 | // The bound of one proves the slot is genuinely free: a fresh mutation is | ||
| 350 | // admitted and completes normally. | 520 | // admitted and completes normally. | ||
| 351 | fresh, cancelFresh := context.WithCancel(context.Background()) | 521 | fresh, cancelFresh := context.WithCancel(context.Background()) | ||
| 352 | defer cancelFresh() | 522 | defer cancelFresh() | ||
| 353 | results := make(chan mutationOutcome, 1) | 523 | results := make(chan mutationOutcome, 1) | ||
| 354 | go func() { | 524 | go func() { | ||
| 355 | - | removed, callErr := c.RemoveHost(fresh, "known") | 525 | + | removed, registration, callErr := mutationCalls[1].run(fresh, c) |
| 356 | - | results <- mutationOutcome{removed: removed, err: callErr} | 526 | + | results <- mutationOutcome{removed: removed, registration: registration, err: callErr} |
| 357 | }() | 527 | }() | ||
| 358 | request := awaitSend(t, g) | 528 | request := awaitSend(t, g) | ||
| 529 | + | operation := operationOf(t, request) | |||
| 359 | g.releaseSend(nil) | 530 | g.releaseSend(nil) | ||
| 360 | - | deliverResult(t, c, operationOf(t, request), ports.BrokerOutcomeOK, true) | 531 | + | mutationCalls[1].deliverSuccess(t, c, operation) |
| 361 | got := awaitResult(t, results) | 532 | got := awaitResult(t, results) | ||
| 362 | require.NoError(t, got.err) | 533 | require.NoError(t, got.err) | ||
| 363 | require.True(t, got.removed) | 534 | require.True(t, got.removed) | ||
| @@ -376,12 +547,13 @@ func TestMutationUnknownOutcomeReleasesWithoutResend(t *testing.T) { | |||||
| 376 | 547 | ||||
| 377 | results := make(chan mutationOutcome, 1) | 548 | results := make(chan mutationOutcome, 1) | ||
| 378 | go func() { | 549 | go func() { | ||
| 379 | - | removed, err := call.call(ctx, c, call.endpoint) | 550 | + | removed, registration, err := call.run(ctx, c) |
| 380 | - | results <- mutationOutcome{removed: removed, err: err} | 551 | + | results <- mutationOutcome{removed: removed, registration: registration, err: err} |
| 381 | }() | 552 | }() | ||
| 382 | request := awaitSend(t, g) | 553 | request := awaitSend(t, g) | ||
| 383 | operation := operationOf(t, request) | 554 | operation := operationOf(t, request) | ||
| 384 | require.Equal(t, call.endpoint, endpointOf(t, request), "the captured request is the caller's own") | 555 | require.Equal(t, call.endpoint, endpointOf(t, request), "the captured request is the caller's own") | ||
| 556 | + | g.releaseSend(nil) | |||
| 385 | cancel() | 557 | cancel() | ||
| 386 | got := awaitResult(t, results) | 558 | got := awaitResult(t, results) | ||
| 387 | var failure ports.BrokerError | 559 | var failure ports.BrokerError | ||
| @@ -389,19 +561,18 @@ func TestMutationUnknownOutcomeReleasesWithoutResend(t *testing.T) { | |||||
| 389 | require.Equal(t, ports.BrokerErrorOutcomeUnknown, failure.Code) | 561 | require.Equal(t, ports.BrokerErrorOutcomeUnknown, failure.Code) | ||
| 390 | require.False(t, got.removed) | 562 | require.False(t, got.removed) | ||
| 391 | require.Equal(t, 1, g.sendCount(), "an uncertain attempt is never resent") | 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 | // The identity was completed once, so its pending slot is released | 565 | // The identity was completed once, so its pending slot is released | ||
| 398 | // rather than left in flight. | 566 | // rather than left in flight. | ||
| 399 | require.ErrorIs(t, c.conn.AdmitOperation(operation), brokerwire.ErrOperationCompleted) | 567 | require.ErrorIs(t, c.conn.AdmitOperation(operation), brokerwire.ErrOperationCompleted) | ||
| 400 | require.Empty(t, pendingOperations(c)) | 568 | require.Empty(t, pendingOperations(c)) | ||
| 401 | 569 | ||||
| 402 | - | // A late result for the completed identity is dropped, and it never | 570 | + | // A late legal result for the completed identity is dropped, and it |
| 403 | - | // provokes another wire attempt. | 571 | + | // never provokes another wire attempt. |
| 404 | - | deliverResult(t, c, operation, ports.BrokerOutcomeOK, true) | 572 | + | late := call.success(operation) |
| 573 | + | late.Epoch = c.scope.Epoch | |||
| 574 | + | late.Connection = c.scope.Connection | |||
| 575 | + | deliverResult(t, c, late) | |||
| 405 | require.Empty(t, pendingOperations(c)) | 576 | require.Empty(t, pendingOperations(c)) | ||
| 406 | require.Equal(t, 1, g.sendCount()) | 577 | require.Equal(t, 1, g.sendCount()) | ||
| 407 | 578 | ||||
| @@ -411,12 +582,12 @@ func TestMutationUnknownOutcomeReleasesWithoutResend(t *testing.T) { | |||||
| 411 | defer cancelFresh() | 582 | defer cancelFresh() | ||
| 412 | next := make(chan mutationOutcome, 1) | 583 | next := make(chan mutationOutcome, 1) | ||
| 413 | go func() { | 584 | go func() { | ||
| 414 | - | removed, err := call.call(fresh, c, call.endpoint) | 585 | + | removed, registration, err := call.run(fresh, c) |
| 415 | - | next <- mutationOutcome{removed: removed, err: err} | 586 | + | next <- mutationOutcome{removed: removed, registration: registration, err: err} |
| 416 | }() | 587 | }() | ||
| 417 | second := awaitSend(t, g) | 588 | second := awaitSend(t, g) | ||
| 418 | g.releaseSend(nil) | 589 | g.releaseSend(nil) | ||
| 419 | - | deliverResult(t, c, operationOf(t, second), ports.BrokerOutcomeOK, call.wantRemoved) | 590 | + | call.deliverSuccess(t, c, operationOf(t, second)) |
| 420 | done := awaitResult(t, next) | 591 | done := awaitResult(t, next) | ||
| 421 | require.NoError(t, done.err) | 592 | require.NoError(t, done.err) | ||
| 422 | require.Equal(t, call.wantRemoved, done.removed) | 593 | require.Equal(t, call.wantRemoved, done.removed) | ||
| @@ -426,7 +597,7 @@ func TestMutationUnknownOutcomeReleasesWithoutResend(t *testing.T) { | |||||
| 426 | } | 597 | } | ||
| 427 | 598 | ||||
| 428 | // TestMutationDeliveredReplyIsDefinite proves an ordinary delivered reply stays | 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 | // identity changes nothing. | 601 | // identity changes nothing. | ||
| 431 | func TestMutationDeliveredReplyIsDefinite(t *testing.T) { | 602 | func TestMutationDeliveredReplyIsDefinite(t *testing.T) { | ||
| 432 | for _, call := range mutationCalls { | 603 | for _, call := range mutationCalls { | ||
| @@ -437,14 +608,14 @@ func TestMutationDeliveredReplyIsDefinite(t *testing.T) { | |||||
| 437 | 608 | ||||
| 438 | results := make(chan mutationOutcome, 1) | 609 | results := make(chan mutationOutcome, 1) | ||
| 439 | go func() { | 610 | go func() { | ||
| 440 | - | removed, err := call.call(ctx, c, call.endpoint) | 611 | + | removed, registration, err := call.run(ctx, c) |
| 441 | - | results <- mutationOutcome{removed: removed, err: err} | 612 | + | results <- mutationOutcome{removed: removed, registration: registration, err: err} |
| 442 | }() | 613 | }() | ||
| 443 | request := awaitSend(t, g) | 614 | request := awaitSend(t, g) | ||
| 444 | operation := operationOf(t, request) | 615 | operation := operationOf(t, request) | ||
| 445 | require.Equal(t, call.endpoint, endpointOf(t, request)) | 616 | require.Equal(t, call.endpoint, endpointOf(t, request)) | ||
| 446 | g.releaseSend(nil) | 617 | g.releaseSend(nil) | ||
| 447 | - | deliverResult(t, c, operation, ports.BrokerOutcomeOK, call.wantRemoved) | 618 | + | call.deliverSuccess(t, c, operation) |
| 448 | got := awaitResult(t, results) | 619 | got := awaitResult(t, results) | ||
| 449 | require.NoError(t, got.err) | 620 | require.NoError(t, got.err) | ||
| 450 | require.Equal(t, call.wantRemoved, got.removed) | 621 | require.Equal(t, call.wantRemoved, got.removed) | ||
| @@ -452,13 +623,224 @@ func TestMutationDeliveredReplyIsDefinite(t *testing.T) { | |||||
| 452 | require.Empty(t, pendingOperations(c)) | 623 | require.Empty(t, pendingOperations(c)) | ||
| 453 | 624 | ||||
| 454 | // A replayed result for an already settled identity is dropped. | 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 | require.Empty(t, pendingOperations(c)) | 630 | require.Empty(t, pendingOperations(c)) | ||
| 457 | require.Equal(t, 1, g.sendCount()) | 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 | // TestSendContextPreflightClassification pins the private helper's taxonomy | 844 | // TestSendContextPreflightClassification pins the private helper's taxonomy | ||
| 463 | // directly: an unencodable frame, an already-cancelled caller, and an | 845 | // directly: an unencodable frame, an already-cancelled caller, and an | ||
| 464 | // already-terminal connection are definitely not dispatched; only a launched | 846 | // already-terminal connection are definitely not dispatched; only a launched | ||
| @@ -478,7 +860,7 @@ func TestSendContextPreflightClassification(t *testing.T) { | |||||
| 478 | cancel() | 860 | cancel() | ||
| 479 | attempted, err := c.sendContext(ctx, brokerwire.AddHost{ | 861 | attempted, err := c.sendContext(ctx, brokerwire.AddHost{ | ||
| 480 | Epoch: c.scope.Epoch, Connection: c.scope.Connection, | 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 | require.False(t, attempted) | 865 | require.False(t, attempted) | ||
| 484 | require.ErrorIs(t, err, context.Canceled) | 866 | require.ErrorIs(t, err, context.Canceled) | ||
| @@ -490,7 +872,7 @@ func TestSendContextPreflightClassification(t *testing.T) { | |||||
| 490 | require.NoError(t, c.Close()) | 872 | require.NoError(t, c.Close()) | ||
| 491 | attempted, err := c.sendContext(context.Background(), brokerwire.AddHost{ | 873 | attempted, err := c.sendContext(context.Background(), brokerwire.AddHost{ | ||
| 492 | Epoch: c.scope.Epoch, Connection: c.scope.Connection, | 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 | require.False(t, attempted) | 877 | require.False(t, attempted) | ||
| 496 | require.ErrorIs(t, err, ErrConnectionClosed) | 878 | require.ErrorIs(t, err, ErrConnectionClosed) | ||
| @@ -507,7 +889,7 @@ func TestSendContextPreflightClassification(t *testing.T) { | |||||
| 507 | go func() { | 889 | go func() { | ||
| 508 | attempted, err := c.sendContext(context.Background(), brokerwire.RemoveHost{ | 890 | attempted, err := c.sendContext(context.Background(), brokerwire.RemoveHost{ | ||
| 509 | Epoch: c.scope.Epoch, Connection: c.scope.Connection, | 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 | done <- sendResult{attempted: attempted, err: err} | 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 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/binary" | 5 | "encoding/binary" | ||
| 6 | + | "errors" | |||
| 6 | "fmt" | 7 | "fmt" | ||
| 7 | "io" | 8 | "io" | ||
| 8 | "os" | 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 | // testDaemonAt builds one catalogue-valid remote daemon projection for index i. | 124 | // testDaemonAt builds one catalogue-valid remote daemon projection for index i. | ||
| 49 | func testDaemonAt(i int) ports.BrokerDaemonObservation { | 125 | func testDaemonAt(i int) ports.BrokerDaemonObservation { | ||
| 50 | endpoint := fmt.Sprintf("user%d@host%d:22", i, i) | 126 | endpoint := fmt.Sprintf("user%d@host%d:22", i, i) | ||
| @@ -238,6 +314,32 @@ func (c *fakeLogicalConn) fail(err error) { | |||||
| 238 | _ = c.Close() | 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 | // fakeCore is one admitted per-connection broker service. | 343 | // fakeCore is one admitted per-connection broker service. | ||
| 242 | type fakeCore struct { | 344 | type fakeCore struct { | ||
| 243 | mu sync.Mutex | 345 | mu sync.Mutex | ||
| @@ -246,14 +348,25 @@ type fakeCore struct { | |||||
| 246 | streams map[ports.BrokerStreamID]*fakeLogicalConn | 348 | streams map[ports.BrokerStreamID]*fakeLogicalConn | ||
| 247 | opens []ports.BrokerOpenStreamRequest | 349 | opens []ports.BrokerOpenStreamRequest | ||
| 248 | closedIDs []ports.BrokerStreamID | 350 | closedIDs []ports.BrokerStreamID | ||
| 249 | - | added []string | 351 | + | added []addHostCall |
| 250 | - | removed []string | 352 | + | removed []removeHostCall |
| 353 | + | updated []updateHostPolicyCall | |||
| 251 | reconcile []string | 354 | reconcile []string | ||
| 252 | openErr error | 355 | openErr error | ||
| 253 | - | blockAdd chan struct{} | |||
| 254 | closed bool | 356 | closed bool | ||
| 255 | done chan struct{} | 357 | done chan struct{} | ||
| 256 | once sync.Once | 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 | func (c *fakeCore) ConnectionID() ports.BrokerConnectionID { return c.id } | 372 | func (c *fakeCore) ConnectionID() ports.BrokerConnectionID { return c.id } | ||
| @@ -298,31 +411,146 @@ func (c *fakeCore) CloseStream(connection ports.BrokerConnectionID, stream ports | |||||
| 298 | return conn.Close() | 411 | return conn.Close() | ||
| 299 | } | 412 | } | ||
| 300 | 413 | ||||
| 301 | - | func (c *fakeCore) AddHost(ctx context.Context, target string) error { | 414 | + | // mapMembershipError mirrors the admitted core's own mapping of a registry |
| 302 | - | if c.blockAdd != nil { | 415 | + | // refusal onto the closed broker error taxonomy. The real broker.Service already |
| 303 | - | select { | 416 | + | // returns these typed codes, so the fake applies the same rule: a caller of the |
| 304 | - | case <-c.blockAdd: | 417 | + | // adapter must never see a bare registry sentinel as if it were an untyped |
| 305 | - | case <-ctx.Done(): | 418 | + | // failure. |
| 306 | - | return ctx.Err() | 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 | return err | 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 | c.mu.Lock() | 449 | c.mu.Lock() | ||
| 313 | - | c.added = append(c.added, target) | 450 | + | defer c.mu.Unlock() |
| 314 | - | c.mu.Unlock() | 451 | + | registration := domain.RemoteRegistration{} |
| 315 | - | return nil | 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 | if err := ctx.Err(); err != nil { | 470 | if err := ctx.Err(); err != nil { | ||
| 320 | return false, err | 471 | return false, err | ||
| 321 | } | 472 | } | ||
| 322 | c.mu.Lock() | 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 | c.mu.Unlock() | 553 | c.mu.Unlock() | ||
| 325 | - | return target == "known", nil | |||
| 326 | } | 554 | } | ||
| 327 | 555 | ||||
| 328 | func (c *fakeCore) RequestReconcile(endpoint string) { | 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 | t.Errorf("Dial: %v", err) | 453 | t.Errorf("Dial: %v", err) | ||
| 454 | return | 454 | return | ||
| 455 | } | 455 | } | ||
| 456 | - | if err := client.AddHost(ctx, "late@host:22"); err != nil { | 456 | + | if err := connectionDelegates(ctx, client); err != nil { |
| 457 | - | t.Errorf("AddHost: %v", err) | 457 | + | t.Errorf("connection refused a membership round trip: %v", err) |
| 458 | } | 458 | } | ||
| 459 | _ = client.Close() | 459 | _ = client.Close() | ||
| 460 | }() | 460 | }() | ||
M internal/adapters/brokeripc/mutation.go +18 -5
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "errors" | 5 | "errors" | ||
| 6 | 6 | ||||
| 7 | "github.com/bnema/vev/internal/adapters/brokerwire" | 7 | "github.com/bnema/vev/internal/adapters/brokerwire" | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | "github.com/bnema/vev/internal/ports" | 9 | "github.com/bnema/vev/internal/ports" | ||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| @@ -150,7 +151,7 @@ func (s *serverSession) wakePublisher() { | |||||
| 150 | // bound and runs it in its own goroutine. A duplicate admission is dropped: | 151 | // bound and runs it in its own goroutine. A duplicate admission is dropped: | ||
| 151 | // exactly one completion ever travels for one operation identity, and the peer's | 152 | // exactly one completion ever travels for one operation identity, and the peer's | ||
| 152 | // own tracker already fences replays. | 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 | if err := s.conn.AdmitOperation(operation); err != nil { | 155 | if err := s.conn.AdmitOperation(operation); err != nil { | ||
| 155 | switch { | 156 | switch { | ||
| 156 | case errors.Is(err, brokerwire.ErrOperationCompleted), errors.Is(err, brokerwire.ErrOperationPending): | 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 | s.wg.Add(1) | 166 | s.wg.Add(1) | ||
| 166 | go func() { | 167 | go func() { | ||
| 167 | defer s.wg.Done() | 168 | defer s.wg.Done() | ||
| 168 | - | removed, err := run(s.ctx) | 169 | + | registration, removed, err := run(s.ctx) |
| 169 | outcome := ports.BrokerOutcomeOK | 170 | outcome := ports.BrokerOutcomeOK | ||
| 170 | if err != nil { | 171 | if err != nil { | ||
| 171 | outcome = ports.BrokerOutcomeFailed | 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 | _ = s.conn.CompleteOperation(operation, outcome) | 181 | _ = s.conn.CompleteOperation(operation, outcome) | ||
| 174 | if s.ctx.Err() != nil { | 182 | if s.ctx.Err() != nil { | ||
| 175 | return | 183 | return | ||
| 176 | } | 184 | } | ||
| 177 | detail := errorDetail(err) | 185 | detail := errorDetail(err) | ||
| 178 | - | if err := s.send(brokerwire.OperationResult{ | 186 | + | result := brokerwire.OperationResult{ |
| 179 | Epoch: s.epoch, Connection: s.scope.Connection, Operation: operation, | 187 | Epoch: s.epoch, Connection: s.scope.Connection, Operation: operation, | ||
| 180 | - | Outcome: outcome, Removed: removed, | 188 | + | Outcome: outcome, Removed: removed, Registration: registration, |
| 181 | Error: detail, HasError: detail != (brokerwire.ErrorDetail{}), | 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 | s.abort(err) | 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 | require.NoError(t, r.transport.Send(wire.Envelope{Payload: payload})) | 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 | func (r *rawCarriage) recv(t *testing.T) brokerwire.ServerMessage { | 48 | func (r *rawCarriage) recv(t *testing.T) brokerwire.ServerMessage { | ||
| 48 | t.Helper() | 49 | t.Helper() | ||
| 49 | - | envelope, err := r.transport.RecvBounded(r.ceilings.MaxReceiveEnvelopeBytes) | 50 | + | return r.recvWithin(t, 5*time.Second) |
| 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 | |||
| 54 | } | 51 | } | ||
| 55 | 52 | ||||
| 56 | // register completes the registration exchange and returns the assigned scope. | 53 | // register completes the registration exchange and returns the assigned scope. | ||
| @@ -127,8 +124,7 @@ func TestWrongDirectionFrameClosesConnection(t *testing.T) { | |||||
| 127 | require.NoError(t, err) | 124 | require.NoError(t, err) | ||
| 128 | require.NoError(t, raw.transport.Send(wire.Envelope{Payload: payload})) | 125 | require.NoError(t, raw.transport.Send(wire.Envelope{Payload: payload})) | ||
| 129 | 126 | ||||
| 130 | - | _, err = raw.transport.RecvBounded(raw.ceilings.MaxReceiveEnvelopeBytes) | 127 | + | require.Error(t, raw.awaitReadError(t, 5*time.Second), "a wrong-direction frame must settle the connection") |
| 131 | - | require.Error(t, err, "a wrong-direction frame must settle the connection") | |||
| 132 | 128 | ||||
| 133 | // The listener keeps serving other clients. | 129 | // The listener keeps serving other clients. | ||
| 134 | client, err := Dial(context.Background(), e.path, Config{HandshakeTimeout: time.Second}) | 130 | client, err := Dial(context.Background(), e.path, Config{HandshakeTimeout: time.Second}) | ||
| @@ -144,8 +140,7 @@ func TestMalformedApplicationFrameClosesConnection(t *testing.T) { | |||||
| 144 | raw.register(t) | 140 | raw.register(t) | ||
| 145 | 141 | ||||
| 146 | require.NoError(t, raw.transport.Send(wire.Envelope{Payload: []byte{0x78, 0x01}})) | 142 | require.NoError(t, raw.transport.Send(wire.Envelope{Payload: []byte{0x78, 0x01}})) | ||
| 147 | - | _, err := raw.transport.RecvBounded(raw.ceilings.MaxReceiveEnvelopeBytes) | 143 | + | require.Error(t, raw.awaitReadError(t, 5*time.Second)) |
| 148 | - | require.Error(t, err) | |||
| 149 | 144 | ||||
| 150 | client, err := Dial(context.Background(), e.path, Config{HandshakeTimeout: time.Second}) | 145 | client, err := Dial(context.Background(), e.path, Config{HandshakeTimeout: time.Second}) | ||
| 151 | require.NoError(t, err) | 146 | require.NoError(t, err) | ||
| @@ -214,8 +209,7 @@ func TestDuplicateRegisterClosesConnection(t *testing.T) { | |||||
| 214 | raw.register(t) | 209 | raw.register(t) | ||
| 215 | 210 | ||||
| 216 | raw.send(t, brokerwire.Register{}) | 211 | raw.send(t, brokerwire.Register{}) | ||
| 217 | - | _, err := raw.transport.RecvBounded(raw.ceilings.MaxReceiveEnvelopeBytes) | 212 | + | require.Error(t, raw.awaitReadError(t, 5*time.Second)) |
| 218 | - | require.Error(t, err) | |||
| 219 | } | 213 | } | ||
| 220 | 214 | ||||
| 221 | // TestMismatchedScopeIsFenced proves a frame carrying a connection identity that | 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 | "github.com/bnema/vev/internal/adapters/brokerwire" | 15 | "github.com/bnema/vev/internal/adapters/brokerwire" | ||
| 16 | "github.com/bnema/vev/internal/adapters/ipc" | 16 | "github.com/bnema/vev/internal/adapters/ipc" | ||
| 17 | + | "github.com/bnema/vev/internal/domain" | |||
| 17 | "github.com/bnema/vev/internal/ports" | 18 | "github.com/bnema/vev/internal/ports" | ||
| 18 | "github.com/bnema/vev/internal/protocol/wire" | 19 | "github.com/bnema/vev/internal/protocol/wire" | ||
| 19 | ) | 20 | ) | ||
| @@ -705,16 +706,25 @@ func (s *serverSession) dispatch(message brokerwire.ClientMessage) error { | |||||
| 705 | if !s.scopeMatches(m.Epoch, m.Connection) { | 706 | if !s.scopeMatches(m.Epoch, m.Connection) { | ||
| 706 | return s.refuseScope() | 707 | return s.refuseScope() | ||
| 707 | } | 708 | } | ||
| 708 | - | return s.startMutation(m.Operation, func(ctx context.Context) (bool, error) { | 709 | + | return s.startMutation(m.Operation, brokerwire.MutationKindAddHost, func(ctx context.Context) (domain.RemoteRegistration, bool, error) { |
| 709 | - | return false, s.core.AddHost(ctx, m.Endpoint) | 710 | + | registration, err := s.core.AddHost(ctx, m.Endpoint, m.Policy) |
| 711 | + | return registration, false, err | |||
| 710 | }) | 712 | }) | ||
| 711 | case brokerwire.RemoveHost: | 713 | case brokerwire.RemoveHost: | ||
| 712 | if !s.scopeMatches(m.Epoch, m.Connection) { | 714 | if !s.scopeMatches(m.Epoch, m.Connection) { | ||
| 713 | return s.refuseScope() | 715 | return s.refuseScope() | ||
| 714 | } | 716 | } | ||
| 715 | - | return s.startMutation(m.Operation, func(ctx context.Context) (bool, error) { | 717 | + | return s.startMutation(m.Operation, brokerwire.MutationKindRemoveHost, func(ctx context.Context) (domain.RemoteRegistration, bool, error) { |
| 716 | - | removed, err := s.core.RemoveHost(ctx, m.Endpoint) | 718 | + | removed, err := s.core.RemoveHost(ctx, m.Registration) |
| 717 | - | return removed, err | 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 | case brokerwire.Reconcile: | 729 | case brokerwire.Reconcile: | ||
| 720 | if !s.scopeMatches(m.Epoch, m.Connection) { | 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 | // AddHost delegates one mutating operation to the admitted core service. | 953 | // AddHost delegates one mutating operation to the admitted core service. | ||
| 944 | - | func (s *serverSession) AddHost(ctx context.Context, target string) error { | 954 | + | func (s *serverSession) AddHost(ctx context.Context, endpoint string, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) { |
| 945 | - | return s.core.AddHost(ctx, target) | 955 | + | return s.core.AddHost(ctx, endpoint, policy) |
| 946 | } | 956 | } | ||
| 947 | 957 | ||||
| 948 | // RemoveHost delegates one mutating operation to the admitted core service. | 958 | // RemoveHost delegates one mutating operation to the admitted core service. | ||
| 949 | - | func (s *serverSession) RemoveHost(ctx context.Context, target string) (bool, error) { | 959 | + | func (s *serverSession) RemoveHost(ctx context.Context, expected domain.RemoteRegistration) (bool, error) { |
| 950 | - | return s.core.RemoveHost(ctx, target) | 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 | // RequestReconcile delegates one reconcile hint to the admitted core service. | 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 | require.ErrorAs(t, err, &failure) | 362 | require.ErrorAs(t, err, &failure) | ||
| 363 | require.Equal(t, ports.BrokerErrorIncompatible, failure.Code) | 363 | require.Equal(t, ports.BrokerErrorIncompatible, failure.Code) | ||
| 364 | 364 | ||||
| 365 | - | // The connection survives a stream-local refusal: a later operation still | 365 | + | // The connection survives a stream-local refusal: once the core admits |
| 366 | - | // completes. | 366 | + | // streams again, a later operation still completes. |
| 367 | - | require.NoError(t, client.AddHost(ctx, "after@refusal:22")) | 367 | + | core.mu.Lock() |
| 368 | + | core.openErr = nil | |||
| 369 | + | core.mu.Unlock() | |||
| 370 | + | requireConnectionDelegates(t, client) | |||
| 368 | } | 371 | } | ||
| 369 | 372 | ||||
| 370 | // TestTerminalStreamFailureFailsOnlyThatStream proves one stream's terminal | 373 | // TestTerminalStreamFailureFailsOnlyThatStream proves one stream's terminal | ||
| @@ -404,7 +407,7 @@ func TestTerminalStreamFailureFailsOnlyThatStream(t *testing.T) { | |||||
| 404 | case <-time.After(5 * time.Second): | 407 | case <-time.After(5 * time.Second): | ||
| 405 | t.Fatal("sibling stream stopped carrying traffic") | 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 | // TestCloseStreamRetiresBothSides proves a client close reaches the broker core | 413 | // TestCloseStreamRetiresBothSides proves a client close reaches the broker core | ||
| @@ -433,46 +436,263 @@ func TestCloseStreamRetiresBothSides(t *testing.T) { | |||||
| 433 | require.NoError(t, stream.Close(), "closing an already retired stream is idempotent") | 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 | 439 | + | // TestMembershipMutationsDelegateEndToEnd proves the whole membership contract |
| 437 | - | // typed results and reach the admitted core service. | 440 | + | // travels the real AF_UNIX carriage in both directions: AddHost returns the |
| 438 | - | func TestMutatingOperationsComplete(t *testing.T) { | 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 | e := startEndpoint(t, Config{}) | 447 | e := startEndpoint(t, Config{}) | ||
| 440 | client, _ := e.pair() | 448 | client, _ := e.pair() | ||
| 441 | core := e.authority.last() | 449 | core := e.authority.last() | ||
| 442 | 450 | ||||
| 443 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | 451 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | ||
| 444 | defer cancel() | 452 | defer cancel() | ||
| 445 | - | require.NoError(t, client.AddHost(ctx, "new@host:22")) | 453 | + | |
| 446 | - | removed, err := client.RemoveHost(ctx, "known") | 454 | + | added, err := client.AddHost(ctx, "new@host:22", testPolicy()) |
| 447 | require.NoError(t, err) | 455 | require.NoError(t, err) | ||
| 448 | - | require.True(t, removed) | 456 | + | require.Equal(t, "new@host:22", added.Endpoint) |
| 449 | - | removed, err = client.RemoveHost(ctx, "missing") | 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 | require.NoError(t, err) | 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() | 467 | + | core.reportRemoved(true) |
| 454 | - | defer core.mu.Unlock() | 468 | + | removed, err := client.RemoveHost(ctx, updated) |
| 455 | - | require.Equal(t, []string{"new@host:22"}, core.added) | 469 | + | require.NoError(t, err) |
| 456 | - | require.Equal(t, []string{"known", "missing"}, core.removed) | 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 | 618 | + | // TestMembershipStoreOutcomeUnknownMapsToOutcomeUnknown proves an indeterminate |
| 460 | - | // never arrives is reported as outcome-unknown rather than retried or reported | 619 | + | // durable write is never presented as a definite refusal: both the value and the |
| 461 | - | // as a plain failure. | 620 | + | // pointer form of the store's outcome-unknown error become an outcome-unknown |
| 462 | - | func TestLostOperationReplyIsOutcomeUnknown(t *testing.T) { | 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 | e := startEndpoint(t, Config{}) | 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 | core := e.authority.last() | 678 | core := e.authority.last() | ||
| 466 | - | core.blockAdd = make(chan struct{}) | 679 | + | core.zeroAddRegistration() |
| 467 | - | t.Cleanup(func() { close(core.blockAdd) }) | |||
| 468 | 680 | ||||
| 469 | - | ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) | 681 | + | raw.send(t, brokerwire.AddHost{ |
| 470 | - | defer cancel() | 682 | + | Epoch: scope.Epoch, Connection: scope.Connection, |
| 471 | - | err := client.AddHost(ctx, "slow@host:22") | 683 | + | Operation: ports.BrokerOperationID{0x51}, Endpoint: "new@host:22", Policy: testPolicy(), |
| 472 | - | require.Error(t, err) | 684 | + | }) |
| 473 | - | var failure ports.BrokerError | 685 | + | |
| 474 | - | require.ErrorAs(t, err, &failure) | 686 | + | // No result travels; the connection settles as a protocol violation. |
| 475 | - | require.Equal(t, ports.BrokerErrorOutcomeUnknown, failure.Code) | 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 | // TestDisconnectCleanupReleasesEveryOwnedResource proves a client disconnect | 698 | // TestDisconnectCleanupReleasesEveryOwnedResource proves a client disconnect | ||
| @@ -532,9 +752,7 @@ func TestStalledSubscriberDoesNotBlockOtherClients(t *testing.T) { | |||||
| 532 | } | 752 | } | ||
| 533 | require.Eventually(t, func() bool { return active.Snapshot().Revision == 20 }, 5*time.Second, 10*time.Millisecond) | 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) | 755 | + | requireConnectionDelegates(t, active) |
| 536 | - | defer cancel() | |||
| 537 | - | require.NoError(t, active.AddHost(ctx, "active@host:22")) | |||
| 538 | } | 756 | } | ||
| 539 | 757 | ||||
| 540 | // TestClientStreamBackpressureSettlesOnlyThatStream proves a local consumer that | 758 | // TestClientStreamBackpressureSettlesOnlyThatStream proves a local consumer that | ||
| @@ -573,7 +791,7 @@ func TestClientStreamBackpressureSettlesOnlyThatStream(t *testing.T) { | |||||
| 573 | }, 5*time.Second, 10*time.Millisecond, "the broker must be told to retire the stalled stream") | 791 | }, 5*time.Second, 10*time.Millisecond, "the broker must be told to retire the stalled stream") | ||
| 574 | 792 | ||||
| 575 | // The connection survives a stream-local stall. | 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 | // TestOrderlyStreamCloseReportsNoError proves a local close is an orderly end: | 797 | // TestOrderlyStreamCloseReportsNoError proves a local close is an orderly end: | ||
| @@ -703,7 +921,7 @@ func TestSessionHandshakeFailureIsStreamLocal(t *testing.T) { | |||||
| 703 | case <-time.After(5 * time.Second): | 921 | case <-time.After(5 * time.Second): | ||
| 704 | t.Fatal("a malformed inner frame must settle its stream") | 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 | // The broker core sees the stream retired. | 926 | // The broker core sees the stream retired. | ||
| 709 | require.Eventually(t, func() bool { | 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 | return nil, ErrInvalidMessage | 175 | return nil, ErrInvalidMessage | ||
| 176 | } | 176 | } | ||
| 177 | return encodeClientEnvelope(*m, maxChunkBytes) | 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 | case Reconcile: | 189 | case Reconcile: | ||
| 179 | converted, err := reconcileToWire(m) | 190 | converted, err := reconcileToWire(m) | ||
| 180 | if err != nil { | 191 | if err != nil { | ||
| @@ -251,6 +262,8 @@ func decodeClientEnvelope(envelope *wire.BrokerClientEnvelope, maxChunkBytes uin | |||||
| 251 | return addHostFromWire(payload.AddHost) | 262 | return addHostFromWire(payload.AddHost) | ||
| 252 | case *wire.BrokerClientEnvelope_RemoveHost: | 263 | case *wire.BrokerClientEnvelope_RemoveHost: | ||
| 253 | return removeHostFromWire(payload.RemoveHost) | 264 | return removeHostFromWire(payload.RemoveHost) | ||
| 265 | + | case *wire.BrokerClientEnvelope_UpdateHostPolicy: | |||
| 266 | + | return updateHostPolicyFromWire(payload.UpdateHostPolicy) | |||
| 254 | case *wire.BrokerClientEnvelope_Reconcile: | 267 | case *wire.BrokerClientEnvelope_Reconcile: | ||
| 255 | return reconcileFromWire(payload.Reconcile) | 268 | return reconcileFromWire(payload.Reconcile) | ||
| 256 | case *wire.BrokerClientEnvelope_OpenStream: | 269 | case *wire.BrokerClientEnvelope_OpenStream: | ||
| @@ -284,10 +297,17 @@ func addHostToWire(m AddHost) (*wire.AddHost, error) { | |||||
| 284 | if err := validateBrokerEndpoint(m.Endpoint); err != nil { | 297 | if err := validateBrokerEndpoint(m.Endpoint); err != nil { | ||
| 285 | return nil, err | 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 | return &wire.AddHost{ | 306 | return &wire.AddHost{ | ||
| 288 | Scope: scopeToWire(m.Epoch, m.Connection), | 307 | Scope: scopeToWire(m.Epoch, m.Connection), | ||
| 289 | OperationId: operationToWire(m.Operation), | 308 | OperationId: operationToWire(m.Operation), | ||
| 290 | Endpoint: m.Endpoint, | 309 | Endpoint: m.Endpoint, | ||
| 310 | + | Policy: policyToWire(m.Policy), | |||
| 291 | }, nil | 311 | }, nil | ||
| 292 | } | 312 | } | ||
| 293 | 313 | ||||
| @@ -307,7 +327,11 @@ func addHostFromWire(message *wire.AddHost) (AddHost, error) { | |||||
| 307 | if err := validateBrokerEndpoint(message.GetEndpoint()); err != nil { | 327 | if err := validateBrokerEndpoint(message.GetEndpoint()); err != nil { | ||
| 308 | return AddHost{}, err | 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 | func removeHostToWire(m RemoveHost) (*wire.RemoveHost, error) { | 337 | func removeHostToWire(m RemoveHost) (*wire.RemoveHost, error) { | ||
| @@ -320,13 +344,15 @@ func removeHostToWire(m RemoveHost) (*wire.RemoveHost, error) { | |||||
| 320 | if err := m.Operation.Validate(); err != nil { | 344 | if err := m.Operation.Validate(); err != nil { | ||
| 321 | return nil, ErrInvalidMessage | 345 | return nil, ErrInvalidMessage | ||
| 322 | } | 346 | } | ||
| 323 | - | if err := validateBrokerEndpoint(m.Endpoint); err != nil { | 347 | + | // Removal carries exact authority, never a bare endpoint: a zero or |
| 324 | - | return nil, err | 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 | return &wire.RemoveHost{ | 352 | return &wire.RemoveHost{ | ||
| 327 | - | Scope: scopeToWire(m.Epoch, m.Connection), | 353 | + | Scope: scopeToWire(m.Epoch, m.Connection), |
| 328 | - | OperationId: operationToWire(m.Operation), | 354 | + | OperationId: operationToWire(m.Operation), |
| 329 | - | Endpoint: m.Endpoint, | 355 | + | Registration: registrationToWire(m.Registration), |
| 330 | }, nil | 356 | }, nil | ||
| 331 | } | 357 | } | ||
| 332 | 358 | ||||
| @@ -343,10 +369,59 @@ func removeHostFromWire(message *wire.RemoveHost) (RemoveHost, error) { | |||||
| 343 | if err != nil { | 369 | if err != nil { | ||
| 344 | return RemoveHost{}, ErrInvalidMessage | 370 | return RemoveHost{}, ErrInvalidMessage | ||
| 345 | } | 371 | } | ||
| 346 | - | if err := validateBrokerEndpoint(message.GetEndpoint()); err != nil { | 372 | + | registration, err := registrationFromWire(message.GetRegistration()) |
| 347 | - | return RemoveHost{}, err | 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 | func reconcileToWire(m Reconcile) (*wire.Reconcile, error) { | 427 | func reconcileToWire(m Reconcile) (*wire.Reconcile, error) { | ||
| @@ -1220,10 +1295,29 @@ func operationResultToWire(m OperationResult) (*wire.OperationResult, error) { | |||||
| 1220 | if err := result.Validate(); err != nil { | 1295 | if err := result.Validate(); err != nil { | ||
| 1221 | return nil, ErrInvalidMessage | 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 | out := &wire.OperationResult{ | 1316 | out := &wire.OperationResult{ | ||
| 1224 | Scope: scopeToWire(m.Epoch, m.Connection), | 1317 | Scope: scopeToWire(m.Epoch, m.Connection), | ||
| 1225 | OperationId: operationToWire(m.Operation), | 1318 | OperationId: operationToWire(m.Operation), | ||
| 1226 | Outcome: outcome, Removed: m.Removed, | 1319 | Outcome: outcome, Removed: m.Removed, | ||
| 1320 | + | Registration: registration, | |||
| 1227 | } | 1321 | } | ||
| 1228 | if m.HasError { | 1322 | if m.HasError { | ||
| 1229 | out.Error = errorDetailToWire(m.Error) | 1323 | out.Error = errorDetailToWire(m.Error) | ||
| @@ -1255,9 +1349,18 @@ func operationResultFromWire(message *wire.OperationResult) (OperationResult, er | |||||
| 1255 | default: | 1349 | default: | ||
| 1256 | return OperationResult{}, ErrInvalidMessage | 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 | result := OperationResult{ | 1360 | result := OperationResult{ | ||
| 1259 | Epoch: epoch, Connection: connection, Operation: operation, | 1361 | Epoch: epoch, Connection: connection, Operation: operation, | ||
| 1260 | Outcome: outcome, Removed: message.GetRemoved(), | 1362 | Outcome: outcome, Removed: message.GetRemoved(), | ||
| 1363 | + | Registration: registration, | |||
| 1261 | } | 1364 | } | ||
| 1262 | if message.GetError() != nil { | 1365 | if message.GetError() != nil { | ||
| 1263 | detail, err := errorDetailFromWire(message.GetError()) | 1366 | detail, err := errorDetailFromWire(message.GetError()) | ||
| @@ -1267,6 +1370,9 @@ func operationResultFromWire(message *wire.OperationResult) (OperationResult, er | |||||
| 1267 | result.Error = detail | 1370 | result.Error = detail | ||
| 1268 | result.HasError = true | 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 | semantic := ports.BrokerOperationResult{Operation: result.Operation, Outcome: result.Outcome} | 1376 | semantic := ports.BrokerOperationResult{Operation: result.Operation, Outcome: result.Outcome} | ||
| 1271 | if result.HasError { | 1377 | if result.HasError { | ||
| 1272 | semantic.Text = result.Error.Text | 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 | {"subscribe", Subscribe{Epoch: 7, Connection: connection, Generation: 1}, 102}, | 351 | {"subscribe", Subscribe{Epoch: 7, Connection: connection, Generation: 1}, 102}, | ||
| 352 | {"resync", Resync{Epoch: 7, Connection: connection, Generation: 2}, 103}, | 352 | {"resync", Resync{Epoch: 7, Connection: connection, Generation: 2}, 103}, | ||
| 353 | {"unsubscribe", Unsubscribe{Epoch: 7, Connection: connection, Generation: 3}, 104}, | 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}, | 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, Endpoint: "dev@host:22"}, 106}, | 355 | + | {"remove_host", RemoveHost{Epoch: 7, Connection: connection, Operation: operation, Registration: testRegistration()}, 106}, |
| 356 | {"reconcile", Reconcile{Epoch: 7, Connection: connection, Registration: testRegistration()}, 107}, | 356 | {"reconcile", Reconcile{Epoch: 7, Connection: connection, Registration: testRegistration()}, 107}, | ||
| 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}, | 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 | {"client_stream_data", ClientStreamData{Epoch: 7, Connection: connection, Stream: 3, Data: []byte("frame")}, 109}, | 358 | {"client_stream_data", ClientStreamData{Epoch: 7, Connection: connection, Stream: 3, Data: []byte("frame")}, 109}, | ||
| 359 | {"close_stream", CloseStream{Epoch: 7, Connection: connection, Stream: 3}, 110}, | 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 | for _, tc := range messages { | 363 | for _, tc := range messages { | ||
| 363 | t.Run(tc.name, func(t *testing.T) { | 364 | t.Run(tc.name, func(t *testing.T) { | ||
| 364 | raw := mustEncodeClient(t, tc.message) | 365 | raw := mustEncodeClient(t, tc.message) | ||
| @@ -525,7 +526,9 @@ func TestBrokerServerVariants(t *testing.T) { | |||||
| 525 | {"snapshot_session_local", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 4, Part: SnapshotSessionPart{HostIndex: 0, SessionIndex: 0, Local: true, Session: testCatalogSession()}}, 202}, | 526 | {"snapshot_session_local", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 4, Part: SnapshotSessionPart{HostIndex: 0, SessionIndex: 0, Local: true, Session: testCatalogSession()}}, 202}, | ||
| 526 | {"snapshot_tombstone", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 5, Part: SnapshotTombstonePart{TombstoneIndex: 0, Registration: testRegistration(), RetiredRevision: 4}}, 202}, | 527 | {"snapshot_tombstone", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 5, Part: SnapshotTombstonePart{TombstoneIndex: 0, Registration: testRegistration(), RetiredRevision: 4}}, 202}, | ||
| 527 | {"snapshot_end", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 6, Part: SnapshotEnd{}}, 202}, | 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 | {"stream_opened", StreamOpened{Epoch: 7, Connection: connection, Stream: 3}, 204}, | 532 | {"stream_opened", StreamOpened{Epoch: 7, Connection: connection, Stream: 3}, 204}, | ||
| 530 | {"server_stream_data", ServerStreamData{Epoch: 7, Connection: connection, Stream: 3, Data: []byte("frame")}, 205}, | 533 | {"server_stream_data", ServerStreamData{Epoch: 7, Connection: connection, Stream: 3, Data: []byte("frame")}, 205}, | ||
| 531 | {"stream_closed", StreamClosed{Epoch: 7, Connection: connection, Stream: 3, Error: testErrorDetail(), HasError: true}, 206}, | 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 | // declared detail still round-trips exactly. | 814 | // declared detail still round-trips exactly. | ||
| 812 | _, err := EncodeServer(StreamClosed{Epoch: 7, Connection: connection, Stream: 3}, testEnvelopeCeiling, testChunkCeiling) | 815 | _, err := EncodeServer(StreamClosed{Epoch: 7, Connection: connection, Stream: 3}, testEnvelopeCeiling, testChunkCeiling) | ||
| 813 | require.NoError(t, err) | 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 | require.NoError(t, err) | 818 | require.NoError(t, err) | ||
| 816 | declared := OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: ports.BrokerOutcomeFailed, Error: detail, HasError: true} | 819 | declared := OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: ports.BrokerOutcomeFailed, Error: detail, HasError: true} | ||
| 817 | decoded, err := DecodeServer(mustEncodeServer(t, declared), testEnvelopeCeiling, testChunkCeiling) | 820 | decoded, err := DecodeServer(mustEncodeServer(t, declared), testEnvelopeCeiling, testChunkCeiling) | ||
| @@ -846,8 +849,103 @@ func TestBrokerValidationNegatives(t *testing.T) { | |||||
| 846 | _, err := EncodeServer(BrokerErrorMessage{Epoch: 7, Connection: connection, Error: boundary}, testEnvelopeCeiling, testChunkCeiling) | 849 | _, err := EncodeServer(BrokerErrorMessage{Epoch: 7, Connection: connection, Error: boundary}, testEnvelopeCeiling, testChunkCeiling) | ||
| 847 | require.NoError(t, err) | 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 | t.Run("bad endpoint", func(t *testing.T) { | 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 | require.ErrorIs(t, err, ErrInvalidMessage) | 949 | require.ErrorIs(t, err, ErrInvalidMessage) | ||
| 852 | }) | 950 | }) | ||
| 853 | t.Run("taxonomy switches", func(t *testing.T) { | 951 | t.Run("taxonomy switches", func(t *testing.T) { | ||
| @@ -974,8 +1072,12 @@ func TestBrokerWrongDirection(t *testing.T) { | |||||
| 974 | func TestBrokerCorruptInputSmoke(t *testing.T) { | 1072 | func TestBrokerCorruptInputSmoke(t *testing.T) { | ||
| 975 | seeds := [][]byte{ | 1073 | seeds := [][]byte{ | ||
| 976 | mustEncodeClient(t, Register{}), | 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 | mustEncodeClient(t, OpenStream{Epoch: 7, Connection: testConnectionID(2), Stream: 3, Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}), | 1078 | mustEncodeClient(t, OpenStream{Epoch: 7, Connection: testConnectionID(2), Stream: 3, Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}), | ||
| 978 | mustEncodeServer(t, SnapshotPart{Epoch: 7, Connection: testConnectionID(2), Generation: 1, Revision: 1, Index: 0, Part: SnapshotEnd{}}), | 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 | mustEncodeServer(t, Shutdown{Epoch: 7, Connection: testConnectionID(2), Reason: ShutdownIdleExit}), | 1081 | mustEncodeServer(t, Shutdown{Epoch: 7, Connection: testConnectionID(2), Reason: ShutdownIdleExit}), | ||
| 980 | } | 1082 | } | ||
| 981 | for _, seed := range seeds { | 1083 | for _, seed := range seeds { | ||
| @@ -1003,6 +1105,9 @@ func fuzzClientSeeds(t testing.TB) [][]byte { | |||||
| 1003 | for _, message := range []ClientMessage{ | 1105 | for _, message := range []ClientMessage{ | ||
| 1004 | Register{}, | 1106 | Register{}, | ||
| 1005 | Subscribe{Epoch: 7, Connection: testConnectionID(0x11), Generation: 1}, | 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 | OpenStream{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: testPolicy()}, | 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 | ClientStreamData{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3, Data: []byte("frame")}, | 1112 | ClientStreamData{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3, Data: []byte("frame")}, | ||
| 1008 | CloseStream{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3}, | 1113 | CloseStream{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3}, | ||
| @@ -1021,6 +1126,7 @@ func fuzzServerSeeds(t testing.TB) [][]byte { | |||||
| 1021 | Registered{Epoch: 7, Connection: testConnectionID(0x11)}, | 1126 | Registered{Epoch: 7, Connection: testConnectionID(0x11)}, | ||
| 1022 | SnapshotPart{Epoch: 7, Connection: testConnectionID(0x11), Generation: 2, Revision: 5, Index: 0, Part: SnapshotEnd{}}, | 1127 | SnapshotPart{Epoch: 7, Connection: testConnectionID(0x11), Generation: 2, Revision: 5, Index: 0, Part: SnapshotEnd{}}, | ||
| 1023 | SnapshotPart{Epoch: 7, Connection: testConnectionID(0x11), Generation: 2, Revision: 5, Index: 2, Part: SnapshotSessionPart{HostIndex: 0, SessionIndex: 0, Session: testCatalogSession()}}, | 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 | BrokerErrorMessage{Epoch: 7, Connection: testConnectionID(0x11), Error: testErrorDetail()}, | 1130 | BrokerErrorMessage{Epoch: 7, Connection: testConnectionID(0x11), Error: testErrorDetail()}, | ||
| 1025 | Shutdown{Epoch: 7, Connection: testConnectionID(0x11), Reason: ShutdownTerminating}, | 1131 | Shutdown{Epoch: 7, Connection: testConnectionID(0x11), Reason: ShutdownTerminating}, | ||
| 1026 | } { | 1132 | } { | ||
M internal/adapters/brokerwire/doc.go +1 -1
| @@ -1,7 +1,7 @@ | |||||
| 1 | // Package brokerwire adapts typed broker messages to Protobuf envelopes. | 1 | // Package brokerwire adapts typed broker messages to Protobuf envelopes. | ||
| 2 | // | 2 | // | ||
| 3 | // The broker is a separate conversation from the session protocol: client | 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 | // and each direction is its own closed oneof union. Every connection starts | 5 | // and each direction is its own closed oneof union. Every connection starts | ||
| 6 | // with the bounded broker preamble (roles 3/4); magic, epoch, exact | 6 | // with the bounded broker preamble (roles 3/4); magic, epoch, exact | ||
| 7 | // protocol version, and negotiated limits are validated before application | 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 | func (Unsubscribe) brokerClientMessage() {} | 68 | func (Unsubscribe) brokerClientMessage() {} | ||
| 69 | 69 | ||||
| 70 | - | // AddHost adds one configured host registration. Operation is the | 70 | + | // AddHost adds one configured host registration under an exact policy. |
| 71 | - | // idempotency identity of this mutating operation. | 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 | type AddHost struct { | 75 | type AddHost struct { | ||
| 73 | Epoch ports.BrokerEpoch | 76 | Epoch ports.BrokerEpoch | ||
| 74 | Connection ports.BrokerConnectionID | 77 | Connection ports.BrokerConnectionID | ||
| 75 | Operation ports.BrokerOperationID | 78 | Operation ports.BrokerOperationID | ||
| 76 | Endpoint string | 79 | Endpoint string | ||
| 80 | + | Policy ports.BrokerPolicy | |||
| 77 | } | 81 | } | ||
| 78 | 82 | ||||
| 79 | func (AddHost) brokerClientMessage() {} | 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 | type RemoveHost struct { | 89 | type RemoveHost struct { | ||
| 83 | - | Epoch ports.BrokerEpoch | 90 | + | Epoch ports.BrokerEpoch |
| 84 | - | Connection ports.BrokerConnectionID | 91 | + | Connection ports.BrokerConnectionID |
| 85 | - | Operation ports.BrokerOperationID | 92 | + | Operation ports.BrokerOperationID |
| 86 | - | Endpoint string | 93 | + | Registration domain.RemoteRegistration |
| 87 | } | 94 | } | ||
| 88 | 95 | ||||
| 89 | func (RemoveHost) brokerClientMessage() {} | 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 | // Reconcile hands the broker one exact registration to probe. | 112 | // Reconcile hands the broker one exact registration to probe. | ||
| 92 | type Reconcile struct { | 113 | type Reconcile struct { | ||
| 93 | Epoch ports.BrokerEpoch | 114 | Epoch ports.BrokerEpoch | ||
| @@ -216,21 +237,165 @@ type SnapshotEnd struct{} | |||||
| 216 | func (SnapshotEnd) snapshotPartPayload() {} | 237 | func (SnapshotEnd) snapshotPartPayload() {} | ||
| 217 | 238 | ||||
| 218 | // OperationResult is exactly one completion for one admitted mutating | 239 | // OperationResult is exactly one completion for one admitted mutating | ||
| 219 | - | // operation. Removed reports the host is gone; Error carries the typed | 240 | + | // operation. Removed and Registration are presence-carrying authority: a |
| 220 | - | // failure when Outcome is failed or outcome-unknown. HasError=false means no | 241 | + | // successful addition or policy update carries the resulting registration, |
| 221 | - | // detail at all: encode refuses a nonzero Error rather than dropping it. | 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 | type OperationResult struct { | 248 | type OperationResult struct { | ||
| 223 | - | Epoch ports.BrokerEpoch | 249 | + | Epoch ports.BrokerEpoch |
| 224 | - | Connection ports.BrokerConnectionID | 250 | + | Connection ports.BrokerConnectionID |
| 225 | - | Operation ports.BrokerOperationID | 251 | + | Operation ports.BrokerOperationID |
| 226 | - | Outcome ports.BrokerMutationOutcome | 252 | + | Outcome ports.BrokerMutationOutcome |
| 227 | - | Removed bool | 253 | + | Removed bool |
| 228 | - | Error ErrorDetail | 254 | + | Registration domain.RemoteRegistration |
| 229 | - | HasError bool | 255 | + | Error ErrorDetail |
| 256 | + | HasError bool | |||
| 230 | } | 257 | } | ||
| 231 | 258 | ||||
| 232 | func (OperationResult) brokerServerMessage() {} | 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 | // StreamOpened confirms one logical stream is established. | 399 | // StreamOpened confirms one logical stream is established. | ||
| 235 | type StreamOpened struct { | 400 | type StreamOpened struct { | ||
| 236 | Epoch ports.BrokerEpoch | 401 | Epoch ports.BrokerEpoch | ||
M internal/ports/broker.go +14 -3
| @@ -563,6 +563,8 @@ const ( | |||||
| 563 | BrokerErrorConflictingPolicy | 563 | BrokerErrorConflictingPolicy | ||
| 564 | BrokerErrorExplicitExit | 564 | BrokerErrorExplicitExit | ||
| 565 | BrokerErrorFatalTerminal | 565 | BrokerErrorFatalTerminal | ||
| 566 | + | BrokerErrorHostConflict | |||
| 567 | + | BrokerErrorMembershipImmutable | |||
| 566 | ) | 568 | ) | ||
| 567 | 569 | ||||
| 568 | func (c BrokerErrorCode) String() string { | 570 | func (c BrokerErrorCode) String() string { | ||
| @@ -587,6 +589,10 @@ func (c BrokerErrorCode) String() string { | |||||
| 587 | return "explicit_exit" | 589 | return "explicit_exit" | ||
| 588 | case BrokerErrorFatalTerminal: | 590 | case BrokerErrorFatalTerminal: | ||
| 589 | return "fatal_terminal" | 591 | return "fatal_terminal" | ||
| 592 | + | case BrokerErrorHostConflict: | |||
| 593 | + | return "host_conflict" | |||
| 594 | + | case BrokerErrorMembershipImmutable: | |||
| 595 | + | return "membership_immutable" | |||
| 590 | default: | 596 | default: | ||
| 591 | return "unknown" | 597 | return "unknown" | ||
| 592 | } | 598 | } | ||
| @@ -597,7 +603,8 @@ func (c BrokerErrorCode) Validate() error { | |||||
| 597 | case BrokerErrorUnavailable, BrokerErrorIncompatible, BrokerErrorTimeout, | 603 | case BrokerErrorUnavailable, BrokerErrorIncompatible, BrokerErrorTimeout, | ||
| 598 | BrokerErrorCancelled, BrokerErrorOutcomeUnknown, BrokerErrorAttachmentLost, | 604 | BrokerErrorCancelled, BrokerErrorOutcomeUnknown, BrokerErrorAttachmentLost, | ||
| 599 | BrokerErrorStaleEpoch, BrokerErrorConflictingPolicy, | 605 | BrokerErrorStaleEpoch, BrokerErrorConflictingPolicy, | ||
| 600 | - | BrokerErrorExplicitExit, BrokerErrorFatalTerminal: | 606 | + | BrokerErrorExplicitExit, BrokerErrorFatalTerminal, |
| 607 | + | BrokerErrorHostConflict, BrokerErrorMembershipImmutable: | |||
| 601 | return nil | 608 | return nil | ||
| 602 | default: | 609 | default: | ||
| 603 | return errors.New("ports: invalid broker error code") | 610 | return errors.New("ports: invalid broker error code") | ||
| @@ -698,8 +705,12 @@ type BrokerService interface { | |||||
| 698 | Subscribe() (BrokerSubscription, error) | 705 | Subscribe() (BrokerSubscription, error) | ||
| 699 | OpenStream(ctx context.Context, request BrokerOpenStreamRequest) (BrokerLogicalConnection, error) | 706 | OpenStream(ctx context.Context, request BrokerOpenStreamRequest) (BrokerLogicalConnection, error) | ||
| 700 | CloseStream(connection BrokerConnectionID, stream BrokerStreamID) error | 707 | CloseStream(connection BrokerConnectionID, stream BrokerStreamID) error | ||
| 701 | - | AddHost(ctx context.Context, target string) error | 708 | + | // AddHost durably adds or pins endpoint under policy and returns its authority. |
| 702 | - | RemoveHost(ctx context.Context, target string) (bool, error) | 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 | RequestReconcile(endpoint string) | 714 | RequestReconcile(endpoint string) | ||
| 704 | Close() error | 715 | Close() error | ||
| 705 | } | 716 | } | ||
M internal/ports/broker_test.go +2 -0
| @@ -510,6 +510,8 @@ func TestBrokerErrorTerminalOutcomes(t *testing.T) { | |||||
| 510 | {code: BrokerErrorConflictingPolicy, wantTerminal: false}, | 510 | {code: BrokerErrorConflictingPolicy, wantTerminal: false}, | ||
| 511 | {code: BrokerErrorExplicitExit, wantTerminal: true}, | 511 | {code: BrokerErrorExplicitExit, wantTerminal: true}, | ||
| 512 | {code: BrokerErrorFatalTerminal, wantTerminal: true}, | 512 | {code: BrokerErrorFatalTerminal, wantTerminal: true}, | ||
| 513 | + | {code: BrokerErrorHostConflict, wantTerminal: false}, | |||
| 514 | + | {code: BrokerErrorMembershipImmutable, wantTerminal: false}, | |||
| 513 | } | 515 | } | ||
| 514 | for _, tt := range tests { | 516 | for _, tt := range tests { | ||
| 515 | t.Run(tt.code.String(), func(t *testing.T) { | 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 | // AddHost provides a mock function for the type MockBrokerService | 148 | // AddHost provides a mock function for the type MockBrokerService | ||
| 149 | - | func (_mock *MockBrokerService) AddHost(ctx context.Context, target string) error { | 149 | + | func (_mock *MockBrokerService) AddHost(ctx context.Context, endpoint string, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) { |
| 150 | - | ret := _mock.Called(ctx, target) | 150 | + | ret := _mock.Called(ctx, endpoint, policy) |
| 151 | 151 | ||||
| 152 | if len(ret) == 0 { | 152 | if len(ret) == 0 { | ||
| 153 | panic("no return value specified for AddHost") | 153 | panic("no return value specified for AddHost") | ||
| 154 | } | 154 | } | ||
| 155 | 155 | ||||
| 156 | - | var r0 error | 156 | + | var r0 domain.RemoteRegistration |
| 157 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { | 157 | + | var r1 error |
| 158 | - | r0 = returnFunc(ctx, target) | 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 | } else { | 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 | // MockBrokerService_AddHost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddHost' | 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 | // AddHost is a helper method to define mock.On call | 179 | // AddHost is a helper method to define mock.On call | ||
| 171 | // - ctx context.Context | 180 | // - ctx context.Context | ||
| 172 | - | // - target string | 181 | + | // - endpoint string |
| 173 | - | func (_e *MockBrokerService_Expecter) AddHost(ctx any, target any) *MockBrokerService_AddHost_Call { | 182 | + | // - policy ports.BrokerPolicy |
| 174 | - | return &MockBrokerService_AddHost_Call{Call: _e.mock.On("AddHost", ctx, target)} | 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 | _c.Call.Run(func(args mock.Arguments) { | 188 | _c.Call.Run(func(args mock.Arguments) { | ||
| 179 | var arg0 context.Context | 189 | var arg0 context.Context | ||
| 180 | if args[0] != nil { | 190 | if args[0] != nil { | ||
| @@ -184,20 +194,25 @@ func (_c *MockBrokerService_AddHost_Call) Run(run func(ctx context.Context, targ | |||||
| 184 | if args[1] != nil { | 194 | if args[1] != nil { | ||
| 185 | arg1 = args[1].(string) | 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 | run( | 201 | run( | ||
| 188 | arg0, | 202 | arg0, | ||
| 189 | arg1, | 203 | arg1, | ||
| 204 | + | arg2, | |||
| 190 | ) | 205 | ) | ||
| 191 | }) | 206 | }) | ||
| 192 | return _c | 207 | return _c | ||
| 193 | } | 208 | } | ||
| 194 | 209 | ||||
| 195 | - | func (_c *MockBrokerService_AddHost_Call) Return(err error) *MockBrokerService_AddHost_Call { | 210 | + | func (_c *MockBrokerService_AddHost_Call) Return(remoteRegistration domain.RemoteRegistration, err error) *MockBrokerService_AddHost_Call { |
| 196 | - | _c.Call.Return(err) | 211 | + | _c.Call.Return(remoteRegistration, err) |
| 197 | return _c | 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 | _c.Call.Return(run) | 216 | _c.Call.Return(run) | ||
| 202 | return _c | 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 | // RemoveHost provides a mock function for the type MockBrokerService | 525 | // RemoveHost provides a mock function for the type MockBrokerService | ||
| 511 | - | func (_mock *MockBrokerService) RemoveHost(ctx context.Context, target string) (bool, error) { | 526 | + | func (_mock *MockBrokerService) RemoveHost(ctx context.Context, expected domain.RemoteRegistration) (bool, error) { |
| 512 | - | ret := _mock.Called(ctx, target) | 527 | + | ret := _mock.Called(ctx, expected) |
| 513 | 528 | ||||
| 514 | if len(ret) == 0 { | 529 | if len(ret) == 0 { | ||
| 515 | panic("no return value specified for RemoveHost") | 530 | panic("no return value specified for RemoveHost") | ||
| @@ -517,16 +532,16 @@ func (_mock *MockBrokerService) RemoveHost(ctx context.Context, target string) ( | |||||
| 517 | 532 | ||||
| 518 | var r0 bool | 533 | var r0 bool | ||
| 519 | var r1 error | 534 | var r1 error | ||
| 520 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (bool, error)); ok { | 535 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RemoteRegistration) (bool, error)); ok { |
| 521 | - | return returnFunc(ctx, target) | 536 | + | return returnFunc(ctx, expected) |
| 522 | } | 537 | } | ||
| 523 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string) bool); ok { | 538 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RemoteRegistration) bool); ok { |
| 524 | - | r0 = returnFunc(ctx, target) | 539 | + | r0 = returnFunc(ctx, expected) |
| 525 | } else { | 540 | } else { | ||
| 526 | r0 = ret.Get(0).(bool) | 541 | r0 = ret.Get(0).(bool) | ||
| 527 | } | 542 | } | ||
| 528 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | 543 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RemoteRegistration) error); ok { |
| 529 | - | r1 = returnFunc(ctx, target) | 544 | + | r1 = returnFunc(ctx, expected) |
| 530 | } else { | 545 | } else { | ||
| 531 | r1 = ret.Error(1) | 546 | r1 = ret.Error(1) | ||
| 532 | } | 547 | } | ||
| @@ -540,20 +555,20 @@ type MockBrokerService_RemoveHost_Call struct { | |||||
| 540 | 555 | ||||
| 541 | // RemoveHost is a helper method to define mock.On call | 556 | // RemoveHost is a helper method to define mock.On call | ||
| 542 | // - ctx context.Context | 557 | // - ctx context.Context | ||
| 543 | - | // - target string | 558 | + | // - expected domain.RemoteRegistration |
| 544 | - | func (_e *MockBrokerService_Expecter) RemoveHost(ctx any, target any) *MockBrokerService_RemoveHost_Call { | 559 | + | func (_e *MockBrokerService_Expecter) RemoveHost(ctx any, expected any) *MockBrokerService_RemoveHost_Call { |
| 545 | - | return &MockBrokerService_RemoveHost_Call{Call: _e.mock.On("RemoveHost", ctx, target)} | 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 | _c.Call.Run(func(args mock.Arguments) { | 564 | _c.Call.Run(func(args mock.Arguments) { | ||
| 550 | var arg0 context.Context | 565 | var arg0 context.Context | ||
| 551 | if args[0] != nil { | 566 | if args[0] != nil { | ||
| 552 | arg0 = args[0].(context.Context) | 567 | arg0 = args[0].(context.Context) | ||
| 553 | } | 568 | } | ||
| 554 | - | var arg1 string | 569 | + | var arg1 domain.RemoteRegistration |
| 555 | if args[1] != nil { | 570 | if args[1] != nil { | ||
| 556 | - | arg1 = args[1].(string) | 571 | + | arg1 = args[1].(domain.RemoteRegistration) |
| 557 | } | 572 | } | ||
| 558 | run( | 573 | run( | ||
| 559 | arg0, | 574 | arg0, | ||
| @@ -568,7 +583,7 @@ func (_c *MockBrokerService_RemoveHost_Call) Return(b bool, err error) *MockBrok | |||||
| 568 | return _c | 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 | _c.Call.Return(run) | 587 | _c.Call.Return(run) | ||
| 573 | return _c | 588 | return _c | ||
| 574 | } | 589 | } | ||
| @@ -712,6 +727,78 @@ func (_c *MockBrokerService_Subscribe_Call) RunAndReturn(run func() (ports.Broke | |||||
| 712 | return _c | 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 | // 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. | 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 | // The first argument is typically a *testing.T value. | 803 | // The first argument is typically a *testing.T value. | ||
| 717 | func NewMockBrokerSnapshotStore(t interface { | 804 | func NewMockBrokerSnapshotStore(t interface { | ||
M internal/protocol/session.go +1 -1
| @@ -16,7 +16,7 @@ import ( | |||||
| 16 | // daemonmux physical preamble conversation (multiplex.proto), whose | 16 | // daemonmux physical preamble conversation (multiplex.proto), whose | ||
| 17 | // negotiated ceilings and accepted daemon binding ride the same wire | 17 | // negotiated ceilings and accepted daemon binding ride the same wire | ||
| 18 | // version as the session and broker conversations. | 18 | // version as the session and broker conversations. | ||
| 19 | - | const Version uint16 = 55 | 19 | + | const Version uint16 = 56 |
| 20 | 20 | ||||
| 21 | // HandshakeTimeout bounds every transport handshake from connect through the | 21 | // HandshakeTimeout bounds every transport handshake from connect through the | ||
| 22 | // first committed publication. It excludes the preceding client-local | 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 | func TestVersionRemainsWireCompatible(t *testing.T) { | 12 | func TestVersionRemainsWireCompatible(t *testing.T) { | ||
| 13 | - | require.Equal(t, uint16(55), Version) | 13 | + | require.Equal(t, uint16(56), Version) |
| 14 | require.Equal(t, (16<<20)-407, MaxOutputDataLen) | 14 | require.Equal(t, (16<<20)-407, MaxOutputDataLen) | ||
| 15 | } | 15 | } | ||
| 16 | 16 | ||||
M internal/protocol/wire/broker.pb.go +289 -161 generated file: collapsed
M internal/protocol/wire/broker_test.go +75 -5
| @@ -19,7 +19,7 @@ import ( | |||||
| 19 | var brokerClientTags = map[string]protoreflect.FieldNumber{ | 19 | var brokerClientTags = map[string]protoreflect.FieldNumber{ | ||
| 20 | "register": 101, "subscribe": 102, "resync": 103, "unsubscribe": 104, | 20 | "register": 101, "subscribe": 102, "resync": 103, "unsubscribe": 104, | ||
| 21 | "add_host": 105, "remove_host": 106, "reconcile": 107, "open_stream": 108, | 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 | // brokerServerTags is the frozen server tag inventory. | 25 | // brokerServerTags is the frozen server tag inventory. | ||
| @@ -151,9 +151,14 @@ func brokerClientSamples() map[string]*BrokerClientEnvelope { | |||||
| 151 | "unsubscribe": {Payload: &BrokerClientEnvelope_Unsubscribe{Unsubscribe: &Unsubscribe{Scope: brokerScope(), Generation: 1 << 42}}}, | 151 | "unsubscribe": {Payload: &BrokerClientEnvelope_Unsubscribe{Unsubscribe: &Unsubscribe{Scope: brokerScope(), Generation: 1 << 42}}}, | ||
| 152 | "add_host": {Payload: &BrokerClientEnvelope_AddHost{AddHost: &AddHost{ | 152 | "add_host": {Payload: &BrokerClientEnvelope_AddHost{AddHost: &AddHost{ | ||
| 153 | Scope: brokerScope(), OperationId: bytes.Repeat([]byte{0x44}, 16), Endpoint: "dev@host:22", | 153 | Scope: brokerScope(), OperationId: bytes.Repeat([]byte{0x44}, 16), Endpoint: "dev@host:22", | ||
| 154 | + | Policy: brokerPolicy(), | |||
| 154 | }}}, | 155 | }}}, | ||
| 155 | "remove_host": {Payload: &BrokerClientEnvelope_RemoveHost{RemoveHost: &RemoveHost{ | 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 | "reconcile": {Payload: &BrokerClientEnvelope_Reconcile{Reconcile: &Reconcile{ | 163 | "reconcile": {Payload: &BrokerClientEnvelope_Reconcile{Reconcile: &Reconcile{ | ||
| 159 | Scope: brokerScope(), Registration: brokerRegistration(), | 164 | Scope: brokerScope(), Registration: brokerRegistration(), | ||
| @@ -180,9 +185,9 @@ func brokerServerSamples() map[string]*BrokerServerEnvelope { | |||||
| 180 | Scope: brokerScope(), Generation: 2, Revision: 5, Index: 9, | 185 | Scope: brokerScope(), Generation: 2, Revision: 5, Index: 9, | ||
| 181 | Part: &SnapshotPart_Daemon{Daemon: &SnapshotDaemon{ | 186 | Part: &SnapshotPart_Daemon{Daemon: &SnapshotDaemon{ | ||
| 182 | HostIndex: 1, Endpoint: "dev@host:22", DisplayOrigin: "dev@host", | 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 | DaemonIdentity: "authed-daemon", DaemonIncarnation: bytes.Repeat([]byte{0x9}, 16), | 189 | DaemonIdentity: "authed-daemon", DaemonIncarnation: bytes.Repeat([]byte{0x9}, 16), | ||
| 185 | - | ProtocolVersion: 55, Capabilities: 1, | 190 | + | ProtocolVersion: 56, Capabilities: 1, |
| 186 | Availability: 1, | 191 | Availability: 1, | ||
| 187 | Checking: true, | 192 | Checking: true, | ||
| 188 | LastAttempt: &BrokerTimestamp{Seconds: 1700000000, Nanos: 1}, | 193 | LastAttempt: &BrokerTimestamp{Seconds: 1700000000, Nanos: 1}, | ||
| @@ -193,7 +198,7 @@ func brokerServerSamples() map[string]*BrokerServerEnvelope { | |||||
| 193 | }}, | 198 | }}, | ||
| 194 | }}}, | 199 | }}}, | ||
| 195 | "operation_result": {Payload: &BrokerServerEnvelope_OperationResult{OperationResult: &OperationResult{ | 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 | Error: &BrokerErrorDetail{Code: 2, Text: "incompatible", AdmissionCode: 1, FailureKind: 3}, | 202 | Error: &BrokerErrorDetail{Code: 2, Text: "incompatible", AdmissionCode: 1, FailureKind: 3}, | ||
| 198 | }}}, | 203 | }}}, | ||
| 199 | "stream_opened": {Payload: &BrokerServerEnvelope_StreamOpened{StreamOpened: &StreamOpened{Ref: brokerRef()}}}, | 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 | // shares a payload type except route navigation failure, and the preamble | 15 | // shares a payload type except route navigation failure, and the preamble | ||
| 16 | // constants agree with the schema authority. | 16 | // constants agree with the schema authority. | ||
| 17 | func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) { | 17 | func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) { | ||
| 18 | - | if protocol.Version != 55 { | 18 | + | if protocol.Version != 56 { |
| 19 | - | t.Fatalf("protocol.Version = %d, want 55", protocol.Version) | 19 | + | t.Fatalf("protocol.Version = %d, want 56", protocol.Version) |
| 20 | } | 20 | } | ||
| 21 | if ProtocolEpoch != 1 { | 21 | if ProtocolEpoch != 1 { | ||
| 22 | t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch) | 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 | // Broker wire schema: the client/server broker conversations. | 1 | // Broker wire schema: the client/server broker conversations. | ||
| 2 | // | 2 | // | ||
| 3 | // The broker is an independently versioned conversation from the session | 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 | // every session tag, and each direction is its own closed oneof union. | 5 | // every session tag, and each direction is its own closed oneof union. | ||
| 6 | // Scope (broker epoch plus client connection) is carried explicitly on | 6 | // Scope (broker epoch plus client connection) is carried explicitly on | ||
| 7 | // every payload so stream IDs and events never cross a scope. Semantic | 7 | // every payload so stream IDs and events never cross a scope. Semantic | ||
| @@ -52,7 +52,7 @@ message BrokerErrorDetail { | |||||
| 52 | // Closed taxonomy: 1 = unavailable, 2 = incompatible, 3 = timeout, | 52 | // Closed taxonomy: 1 = unavailable, 2 = incompatible, 3 = timeout, | ||
| 53 | // 4 = cancelled, 5 = outcome_unknown, 6 = attachment_lost, | 53 | // 4 = cancelled, 5 = outcome_unknown, 6 = attachment_lost, | ||
| 54 | // 7 = stale_epoch, 8 = conflicting_policy, 9 = explicit_exit, | 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 | uint32 code = 1; | 56 | uint32 code = 1; | ||
| 57 | string text = 2; | 57 | string text = 2; | ||
| 58 | // Closed admission-refusal taxonomy; 0 = none. Codes are assigned by the | 58 | // Closed admission-refusal taxonomy; 0 = none. Codes are assigned by the | ||
| @@ -85,6 +85,7 @@ message BrokerClientEnvelope { | |||||
| 85 | OpenStream open_stream = 108; | 85 | OpenStream open_stream = 108; | ||
| 86 | ClientStreamData client_stream_data = 109; | 86 | ClientStreamData client_stream_data = 109; | ||
| 87 | CloseStream close_stream = 110; | 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 | // AddHost adds one configured host registration. operation_id is the | 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 | message AddHost { | 134 | message AddHost { | ||
| 132 | BrokerScope scope = 1; | 135 | BrokerScope scope = 1; | ||
| 133 | bytes operation_id = 2; // exactly 16 bytes | 136 | bytes operation_id = 2; // exactly 16 bytes | ||
| 134 | string endpoint = 3; | 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 | message RemoveHost { | 145 | message RemoveHost { | ||
| 139 | BrokerScope scope = 1; | 146 | BrokerScope scope = 1; | ||
| 140 | bytes operation_id = 2; // exactly 16 bytes | 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 | // Reconcile hands the broker one exact registration to probe. | 161 | // Reconcile hands the broker one exact registration to probe. | ||
| @@ -279,7 +296,10 @@ message SnapshotTombstone { | |||||
| 279 | message SnapshotEnd {} | 296 | message SnapshotEnd {} | ||
| 280 | 297 | ||||
| 281 | // OperationResult is exactly one completion for one admitted mutating | 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 | message OperationResult { | 303 | message OperationResult { | ||
| 284 | BrokerScope scope = 1; | 304 | BrokerScope scope = 1; | ||
| 285 | bytes operation_id = 2; // exactly 16 bytes | 305 | bytes operation_id = 2; // exactly 16 bytes | ||
| @@ -287,6 +307,7 @@ message OperationResult { | |||||
| 287 | uint32 outcome = 3; | 307 | uint32 outcome = 3; | ||
| 288 | bool removed = 4; | 308 | bool removed = 4; | ||
| 289 | BrokerErrorDetail error = 5; | 309 | BrokerErrorDetail error = 5; | ||
| 310 | + | RemoteRegistration registration = 6; | |||
| 290 | } | 311 | } | ||
| 291 | 312 | ||||
| 292 | // StreamOpened confirms one logical stream is established. | 313 | // StreamOpened confirms one logical stream is established. | ||
M internal/protocol/wire/schema/multiplex.proto +1 -1
| @@ -3,7 +3,7 @@ | |||||
| 3 | // physical connection. | 3 | // physical connection. | ||
| 4 | // | 4 | // | ||
| 5 | // daemonmux is a third conversation, separate from the session protocol | 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 | // client tags 301-304 and server tags 401-405 are disjoint from every other | 7 | // client tags 301-304 and server tags 401-405 are disjoint from every other | ||
| 8 | // tag, and each direction is its own closed oneof union. The physical | 8 | // tag, and each direction is its own closed oneof union. The physical | ||
| 9 | // preamble is a separate adapter message pair, not an envelope variant: | 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 | return nil | 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 | // dispatchLocalLocked admits at most one local probe attempt when its schedule | 78 | // dispatchLocalLocked admits at most one local probe attempt when its schedule | ||
| 90 | // says it is due. Callers must hold r.mu. It reports whether an attempt | 79 | // says it is due. Callers must hold r.mu. It reports whether an attempt | ||
| 91 | // started, so dispatch publishes the transient Checking state exactly once. | 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 | "errors" | 5 | "errors" | ||
| 6 | "sync" | 6 | "sync" | ||
| 7 | 7 | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | "github.com/bnema/vev/internal/ports" | 9 | "github.com/bnema/vev/internal/ports" | ||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| @@ -21,24 +22,9 @@ import ( | |||||
| 21 | // admission only and is never retained; the admitted service owns a | 22 | // admission only and is never retained; the admitted service owns a | ||
| 22 | // connection-lived context derived from the supervisor root. | 23 | // connection-lived context derived from the supervisor root. | ||
| 23 | // | 24 | // | ||
| 24 | - | // This composition is deliberately offline and immutable: its registry runs | 25 | + | // Registry construction determines observation and membership policy. The |
| 25 | - | // observation-disabled, so the returned service publishes and subscribes to | 26 | + | // authority preserves those guards and exposes admitted mutations through the |
| 26 | - | // durable snapshots but never probes or reconciles, and host membership is | 27 | + | // connection-scoped service. |
| 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 | - | } | |||
| 42 | 28 | ||||
| 43 | // Authority admits one accepted client connection to the broker core. It owns | 29 | // Authority admits one accepted client connection to the broker core. It owns | ||
| 44 | // no lifetime of its own: the supervisor owns the broker lifetime, the registry | 30 | // no lifetime of its own: the supervisor owns the broker lifetime, the registry | ||
| @@ -60,13 +46,6 @@ var _ ports.BrokerAuthority = (*Authority)(nil) | |||||
| 60 | // NewAuthority composes one connection-scoped broker authority over an existing | 46 | // NewAuthority composes one connection-scoped broker authority over an existing | ||
| 61 | // registry, pool, and supervisor. All three must be live and share epoch. | 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 | // The caller owns the dependencies' lifecycles. In particular it must start the | 49 | // The caller owns the dependencies' lifecycles. In particular it must start the | ||
| 71 | // registry's single Registry.Run to own and drain the durable writer, and must | 50 | // registry's single Registry.Run to own and drain the durable writer, and must | ||
| 72 | // cancel that run context and join Run before the store is closed. NewAuthority | 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 | if registry.epoch != epoch || pool.epoch != epoch { | 59 | if registry.epoch != epoch || pool.epoch != epoch { | ||
| 81 | return nil, errors.New("broker: authority epoch mismatch") | 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 | return &Authority{epoch: epoch, registry: registry, pool: pool, supervisor: supervisor}, nil | 62 | return &Authority{epoch: epoch, registry: registry, pool: pool, supervisor: supervisor}, nil | ||
| 87 | } | 63 | } | ||
| 88 | 64 | ||||
| @@ -132,8 +108,8 @@ type Service struct { | |||||
| 132 | mu sync.Mutex | 108 | mu sync.Mutex | ||
| 133 | closed bool | 109 | closed bool | ||
| 134 | subs map[*serviceSubscription]struct{} | 110 | subs map[*serviceSubscription]struct{} | ||
| 135 | - | // wg counts in-flight OpenStream calls so Close drains them, and their | 111 | + | // wg counts in-flight stream opens and membership mutations so Close drains |
| 136 | - | // operation leases, before it releases the connection resources. | 112 | + | // them, and their operation leases, before releasing connection resources. |
| 137 | wg sync.WaitGroup | 113 | wg sync.WaitGroup | ||
| 138 | 114 | ||||
| 139 | closeOnce sync.Once | 115 | closeOnce sync.Once | ||
| @@ -265,18 +241,79 @@ func (s *Service) CloseStream(connection ports.BrokerConnectionID, stream ports. | |||||
| 265 | return s.pool.CloseStream(s.id, stream) | 241 | return s.pool.CloseStream(s.id, stream) | ||
| 266 | } | 242 | } | ||
| 267 | 243 | ||||
| 268 | - | // AddHost refuses host admission: this connection has immutable offline | 244 | + | // AddHost delegates one admitted membership mutation to the registry. |
| 269 | - | // membership, so no mutation is attempted. | 245 | + | func (s *Service) AddHost(ctx context.Context, endpoint string, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) { |
| 270 | - | func (s *Service) AddHost(context.Context, string) error { return offlineMembershipRefusal() } | 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 | 288 | + | func membershipError(err error) error { |
| 273 | - | // explicit false/refusal pair. | 289 | + | if err == nil { |
| 274 | - | func (s *Service) RemoveHost(context.Context, string) (bool, error) { | 290 | + | return nil |
| 275 | - | return false, offlineMembershipRefusal() | 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 | 315 | + | // RequestReconcile remains a no-op until the observation service exposes a |
| 279 | - | // observation to reconcile, so the hint starts no probe, timer, or reconcile. | 316 | + | // connection-safe reconcile seam. |
| 280 | func (s *Service) RequestReconcile(string) {} | 317 | func (s *Service) RequestReconcile(string) {} | ||
| 281 | 318 | ||||
| 282 | // Close cancels and drains every owned resource exactly once: in-flight setup | 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 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | "fmt" | 6 | "fmt" | ||
| 7 | + | "strings" | |||
| 6 | "sync" | 8 | "sync" | ||
| 7 | "testing" | 9 | "testing" | ||
| 8 | "time" | 10 | "time" | ||
| 11 | + | "unicode/utf8" | |||
| 9 | 12 | ||||
| 10 | "github.com/bnema/vev/internal/domain" | 13 | "github.com/bnema/vev/internal/domain" | ||
| 11 | "github.com/bnema/vev/internal/ports" | 14 | "github.com/bnema/vev/internal/ports" | ||
| @@ -13,23 +16,32 @@ import ( | |||||
| 13 | ) | 16 | ) | ||
| 14 | 17 | ||||
| 15 | // newTestAuthority composes one offline authority over an observation-disabled | 18 | // newTestAuthority composes one offline authority over an observation-disabled | ||
| 16 | - | // registry, a bounded pool, and a supervisor on a long grace. Every dependency | 19 | + | // registry, a bounded pool, and a supervisor on a long grace. |
| 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. | |||
| 20 | func newTestAuthority(t testing.TB, epoch ports.BrokerEpoch, probe ports.BrokerHostProbe, connector poolConnector) (*Authority, *Registry, *Pool, *Supervisor, *manualClock, *testStore) { | 20 | func newTestAuthority(t testing.TB, epoch ports.BrokerEpoch, probe ports.BrokerHostProbe, connector poolConnector) (*Authority, *Registry, *Pool, *Supervisor, *manualClock, *testStore) { | ||
| 21 | t.Helper() | 21 | t.Helper() | ||
| 22 | clock := newManualClock(time.Unix(0, 0)) | 22 | clock := newManualClock(time.Unix(0, 0)) | ||
| 23 | store := newTestStore() | 23 | store := newTestStore() | ||
| 24 | registry, err := NewRegistryWithConfig(epoch, store, probe, clock, nil, RegistryConfig{ObservationDisabled: true}) | 24 | registry, err := NewRegistryWithConfig(epoch, store, probe, clock, nil, RegistryConfig{ObservationDisabled: true}) | ||
| 25 | require.NoError(t, err) | 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 | resolver := poolResolver(func(_ context.Context, r ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) { | 38 | resolver := poolResolver(func(_ context.Context, r ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) { | ||
| 27 | return ports.BrokerResolvedEndpoint{Identity: "canonical", Policy: r.Policy, Address: "fake"}, nil | 39 | return ports.BrokerResolvedEndpoint{Identity: "canonical", Policy: r.Policy, Address: "fake"}, nil | ||
| 28 | }) | 40 | }) | ||
| 29 | pool, err := NewPool(epoch, resolver, connector, clock, PoolLimits{Physical: 4, Clients: 8, Streams: 16, StreamsPerClient: 16, Idle: time.Minute}) | 41 | pool, err := NewPool(epoch, resolver, connector, clock, PoolLimits{Physical: 4, Clients: 8, Streams: 16, StreamsPerClient: 16, Idle: time.Minute}) | ||
| 30 | require.NoError(t, err) | 42 | require.NoError(t, err) | ||
| 31 | t.Cleanup(func() { require.NoError(t, pool.Close()) }) | 43 | t.Cleanup(func() { require.NoError(t, pool.Close()) }) | ||
| 32 | - | supervisor, _ := newTestSupervisor(t, time.Hour) | 44 | + | supervisor, idleClock := newTestSupervisor(t, time.Hour) |
| 33 | authority, err := NewAuthority(epoch, registry, pool, supervisor) | 45 | authority, err := NewAuthority(epoch, registry, pool, supervisor) | ||
| 34 | require.NoError(t, err) | 46 | require.NoError(t, err) | ||
| 35 | // The caller owns Registry.Run (see NewAuthority): start the single run and | 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 | registryDone := make(chan struct{}) | 51 | registryDone := make(chan struct{}) | ||
| 40 | go func() { defer close(registryDone); registry.Run(registryCtx) }() | 52 | go func() { defer close(registryDone); registry.Run(registryCtx) }() | ||
| 41 | t.Cleanup(func() { stopRegistry(); <-registryDone }) | 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 | // immediateConnector returns one healthy physical connection for any endpoint. | 120 | // immediateConnector returns one healthy physical connection for any endpoint. | ||
| @@ -121,34 +196,38 @@ func TestAuthorityAdmissionContextNotRetained(t *testing.T) { | |||||
| 121 | func TestAuthorityValidation(t *testing.T) { | 196 | func TestAuthorityValidation(t *testing.T) { | ||
| 122 | _, _, pool, supervisor, clock, _ := newTestAuthority(t, 1, nil, immediateConnector) | 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 | require.NoError(t, err) | 203 | require.NoError(t, err) | ||
| 126 | - | _, err = NewAuthority(1, observing, pool, supervisor) | 204 | + | t.Cleanup(mutableRemote.settle) |
| 127 | - | require.Error(t, err, "an observing registry must be refused by an offline authority") | 205 | + | require.NoError(t, mutableRemote.setHosts(hostRecords(registration(t, "user@host:22", 1)))) |
| 128 | - | 206 | + | _, err = NewAuthority(1, mutableRemote, pool, supervisor) | |
| 129 | - | // An observer that is limited to the broker's own daemon is served: it has | 207 | + | require.NoError(t, err, "a mutable remote registry must be served") |
| 130 | - | // no remote membership for the authority's immutable-membership connections | 208 | + | |
| 131 | - | // to manage, and its local entry is configured authority rather than | 209 | + | // A local-only observer is served: its entry is configured authority rather |
| 132 | - | // membership. | 210 | + | // than membership, so it has nothing for the authority's connections to |
| 211 | + | // manage either. | |||
| 133 | localOnly, err := NewRegistryWithConfig(1, newTestStore(), nil, clock, nil, RegistryConfig{Local: &LocalObservation{ | 212 | localOnly, err := NewRegistryWithConfig(1, newTestStore(), nil, clock, nil, RegistryConfig{Local: &LocalObservation{ | ||
| 134 | DisplayOrigin: "local", | 213 | DisplayOrigin: "local", | ||
| 135 | Policy: poolPolicy(), | 214 | Policy: poolPolicy(), | ||
| 136 | Probe: newScriptedLocalProbe(1), | 215 | Probe: newScriptedLocalProbe(1), | ||
| 137 | }}) | 216 | }}) | ||
| 138 | require.NoError(t, err) | 217 | require.NoError(t, err) | ||
| 218 | + | t.Cleanup(localOnly.settle) | |||
| 139 | _, err = NewAuthority(1, localOnly, pool, supervisor) | 219 | _, err = NewAuthority(1, localOnly, pool, supervisor) | ||
| 140 | require.NoError(t, err, "a local-only observer must be served") | 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 | 222 | + | // Construction still rejects remote observation without a probe: a registry |
| 143 | - | // the authority's connections could never manage. | 223 | + | // that would observe remotes but owns no probe cannot be constructed, so no |
| 144 | - | observerWithMembership, err := NewRegistry(1, newTestStore(), newTestProbe(1), clock, nil) | 224 | + | // authority ever receives one. |
| 145 | - | require.NoError(t, err) | 225 | + | _, err = NewRegistry(1, newTestStore(), nil, clock, nil) |
| 146 | - | require.NoError(t, observerWithMembership.setHosts(hostRecords(registration(t, "user@host:22", 1)))) | 226 | + | require.ErrorContains(t, err, "invalid registry dependencies", "remote observation without a probe must be refused") |
| 147 | - | _, err = NewAuthority(1, observerWithMembership, pool, supervisor) | |||
| 148 | - | require.Error(t, err, "an observer that owns remote membership must be refused") | |||
| 149 | 227 | ||||
| 150 | disabled, err := NewRegistryWithConfig(1, newTestStore(), nil, clock, nil, RegistryConfig{ObservationDisabled: true}) | 228 | disabled, err := NewRegistryWithConfig(1, newTestStore(), nil, clock, nil, RegistryConfig{ObservationDisabled: true}) | ||
| 151 | require.NoError(t, err) | 229 | require.NoError(t, err) | ||
| 230 | + | t.Cleanup(disabled.settle) | |||
| 152 | _, err = NewAuthority(2, disabled, pool, supervisor) | 231 | _, err = NewAuthority(2, disabled, pool, supervisor) | ||
| 153 | require.Error(t, err, "an epoch mismatch must be refused") | 232 | require.Error(t, err, "an epoch mismatch must be refused") | ||
| 154 | 233 | ||||
| @@ -449,27 +528,274 @@ func TestServiceShutdownTerminatesOwnedWork(t *testing.T) { | |||||
| 449 | require.Zero(t, clients) | 528 | require.Zero(t, clients) | ||
| 450 | } | 529 | } | ||
| 451 | 530 | ||||
| 452 | - | func TestServiceOfflineMembershipRefusal(t *testing.T) { | 531 | + | // TestServiceImmutableMembershipRefusesEveryMutation proves the immutable |
| 453 | - | authority, registry, _, _, _, _ := newTestAuthority(t, 1, nil, immediateConnector) | 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 | service, err := authority.AdmitClient(context.Background()) | 538 | service, err := authority.AdmitClient(context.Background()) | ||
| 455 | require.NoError(t, err) | 539 | require.NoError(t, err) | ||
| 456 | t.Cleanup(func() { require.NoError(t, service.Close()) }) | 540 | t.Cleanup(func() { require.NoError(t, service.Close()) }) | ||
| 457 | 541 | ||||
| 458 | - | err = service.AddHost(context.Background(), "new@host:22") | 542 | + | before := registry.Snapshot() |
| 459 | - | require.ErrorIs(t, err, ErrOfflineMembership) | 543 | + | _, err = service.AddHost(context.Background(), "new@host:22", poolPolicy()) |
| 460 | - | var typed ports.BrokerError | 544 | + | requireImmutableRefusal(t, err) |
| 461 | - | require.ErrorAs(t, err, &typed) | |||
| 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 | require.False(t, removed) | 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 | service.RequestReconcile("new@host:22") | 557 | service.RequestReconcile("new@host:22") | ||
| 470 | require.Equal(t, before, registry.Snapshot(), "reconcile must be a no-op") | 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 | func TestRegistryObservationDisabledRestoresAndPublishes(t *testing.T) { | 799 | func TestRegistryObservationDisabledRestoresAndPublishes(t *testing.T) { | ||
| 474 | clock := newManualClock(time.Unix(0, 0)) | 800 | clock := newManualClock(time.Unix(0, 0)) | ||
| 475 | store := newTestStore() | 801 | store := newTestStore() | ||
M internal/usecase/client/supervisor_integration_test.go +11 -3
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| 14 | 14 | ||||
| 15 | "github.com/bnema/vev/internal/adapters/brokeripc" | 15 | "github.com/bnema/vev/internal/adapters/brokeripc" | ||
| 16 | + | "github.com/bnema/vev/internal/domain" | |||
| 16 | "github.com/bnema/vev/internal/ports" | 17 | "github.com/bnema/vev/internal/ports" | ||
| 17 | ) | 18 | ) | ||
| 18 | 19 | ||||
| @@ -55,9 +56,16 @@ func (c *integrationCore) OpenStream(context.Context, ports.BrokerOpenStreamRequ | |||||
| 55 | func (c *integrationCore) CloseStream(ports.BrokerConnectionID, ports.BrokerStreamID) error { | 56 | func (c *integrationCore) CloseStream(ports.BrokerConnectionID, ports.BrokerStreamID) error { | ||
| 56 | return nil | 57 | return nil | ||
| 57 | } | 58 | } | ||
| 58 | - | func (c *integrationCore) AddHost(context.Context, string) error { return nil } | 59 | + | func (c *integrationCore) AddHost(context.Context, string, ports.BrokerPolicy) (domain.RemoteRegistration, error) { |
| 59 | - | func (c *integrationCore) RemoveHost(context.Context, string) (bool, error) { return false, nil } | 60 | + | return domain.RemoteRegistration{}, nil |
| 60 | - | func (c *integrationCore) RequestReconcile(string) {} | 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 | func (c *integrationCore) Close() error { | 70 | func (c *integrationCore) Close() error { | ||
| 63 | c.once.Do(func() { | 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 | return nil | 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 | return false, nil | 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 | func (s *supervisorTestService) RequestReconcile(string) {} | 405 | func (s *supervisorTestService) RequestReconcile(string) {} | ||
| 400 | 406 | ||||
| 401 | func (s *supervisorTestService) Close() error { | 407 | func (s *supervisorTestService) Close() error { | ||