feat(broker): own the local route and local observation
Give the broker its own carriage to this machine's daemon, so local and remote logical streams reach a daemon the same way. Offline and unactivated: the sandbox composition keeps running observation-disabled. - brokerconfig accepts an optional top-level local object (identity, optional displayOrigin, route, policy). A local request carries no endpoint and no registration, so the binding is the only authority a local stream can be fenced against: identity and policy are checked before any dial, the route must be a Unix daemonmux carriage this process dials directly, and a refusal is a typed broker error that never dials or spawns. - Local streams pool under (identity, policy) on that one carriage, so every local logical stream shares a single physical daemonmux connection. - The registry can observe the local daemon: the entry is published at index 0, its configured authority comes from the binding, and observed identity, incarnation, protocol version, availability, freshness, and catalogue come only from the probe. It is never durable and never invented. - The composition's probe completes the daemonmux physical preamble and closes the carriage: it opens no logical stream and has no spawn path, so observing never attaches and never starts a stopped daemon. It re-checks the binding's route for itself rather than trusting the exported value. - docs/architecture.md documents the local route and corrects the claim that the local entry's authority comes from durable membership.
- commit
- 5d9061b112efccd710fa91df57fc09eaafea760d
- author
- brice · · 2026-09-19 09:53 UTC
- parent
- b6528f0
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +1802 -34
M docs/architecture.md +50 -10
| @@ -270,25 +270,65 @@ These are P2.2 port contracts, not a wire migration or production activation. | |||
| 270 | 270 | `internal/usecase/broker.Registry` owns configured hosts and their immutable | |
| 271 | 271 | observation projections. `ports.BrokerDaemonObservation` is the broker-native | |
| 272 | 272 | projection of one daemon, local or remote: configured authority (local flag, | |
| 273 | - | endpoint, registration, policy, display origin, rank) is stamped by the | |
| 274 | - | registry from durable membership and never taken from a probe, while observed | |
| 275 | - | identity, process incarnation, protocol version, capability bitmask, | |
| 276 | - | availability, failure/freshness state, and the exact session catalogue come | |
| 277 | - | only from probe results. Unknown identity or version is zero, never invented; | |
| 278 | - | an observed identity always carries a protocol version, and availability is | |
| 279 | - | never zero (an unobserved daemon carries `RemoteAvailabilityUnknown`). A | |
| 273 | + | endpoint, registration, policy, display origin, rank) is never taken from a | |
| 274 | + | probe, while observed identity, process incarnation, protocol version, | |
| 275 | + | capability bitmask, availability, failure/freshness state, and the exact | |
| 276 | + | session catalogue come only from probe results. A remote host's authority is | |
| 277 | + | stamped by the registry from durable membership; the local entry's authority is | |
| 278 | + | stamped from the broker's configured local binding, because the local daemon | |
| 279 | + | has no registration and no durable membership. Unknown identity or version is | |
| 280 | + | zero, never invented; an observed identity always carries a protocol version, | |
| 281 | + | and availability is never zero (an unobserved daemon carries | |
| 282 | + | `RemoteAvailabilityUnknown`). A | |
| 280 | 283 | `ports.BrokerSnapshot` publishes these observations (`Daemons`, local entry | |
| 281 | 284 | first when present, then remotes in registration order) plus the tombstone | |
| 282 | 285 | set; `brokerwire` mirrors them through `SnapshotBegin`/`SnapshotDaemon`/ | |
| 283 | 286 | `SnapshotSession` parts. Local observations are never durable: the snapshot | |
| 284 | - | store persists and reloads remote observations only, with policy re-stamped | |
| 285 | - | from membership on load, so membership stays the single policy authority. | |
| 286 | - | Observing a daemon never attaches to it and never starts a stopped one. | |
| 287 | + | store persists and reloads remote observations only, with remote policy | |
| 288 | + | re-stamped from membership on load, so membership stays the single policy | |
| 289 | + | authority for remotes. Observing a daemon never attaches to it and never starts | |
| 290 | + | a stopped one. | |
| 287 | 291 | Attachment streams declare one closed admission variant | |
| 288 | 292 | (`ports.BrokerStreamAdmission`: exact attach/resume, named creation, ephemeral | |
| 289 | 293 | creation) validated identically at the ports, broker IPC, and daemonmux | |
| 290 | 294 | boundaries. | |
| 291 | 295 | ||
| 296 | + | ## Broker-owned local route (Plan 001 P5.3a, not activated) | |
| 297 | + | ||
| 298 | + | The broker reaches its own machine daemon, which is not a configured host, over | |
| 299 | + | one broker-owned local binding. `internal/adapters/brokerconfig` parses an | |
| 300 | + | optional top-level `local` object beside the registrations: the required | |
| 301 | + | `identity`, `route`, and `policy`, plus an optional `displayOrigin` that is | |
| 302 | + | sanitized and defaults to `local`. The route must be a Unix daemonmux carriage | |
| 303 | + | this process dials directly, so an SSH or QUIC route is refused and the binding | |
| 304 | + | can never name another machine. The identity, policy, and route are the only | |
| 305 | + | source of local authority, and `Config.LocalBinding` alone publishes them. The | |
| 306 | + | binding's own carriage is deliberately excluded from `Config.LocalMuxRoute`: | |
| 307 | + | that route is the single registration carriage a remote-side mux helper bridges | |
| 308 | + | to, and no helper ever bridges the broker's own local carriage. | |
| 309 | + | ||
| 310 | + | A local open-stream request carries no endpoint and no registration, so the | |
| 311 | + | resolver fences it against the binding's identity and policy instead: a request | |
| 312 | + | whose policy conflicts with the provisioned local policy is refused, and a | |
| 313 | + | configuration with no local object provisions no local route. The resolved | |
| 314 | + | (identity, policy) pair is the pool key, so every local logical stream shares | |
| 315 | + | one physical daemonmux carriage to the local daemon, exactly as every stream to | |
| 316 | + | one remote identity does; the route's opaque address selects that carriage and | |
| 317 | + | never participates in the key. | |
| 318 | + | ||
| 319 | + | The registry publishes a local observation whenever a local producer is | |
| 320 | + | configured, always at index zero ahead of every remote and never more than one. | |
| 321 | + | Its configured authority (local flag, display origin, policy, rank) is stamped | |
| 322 | + | from the binding and never from durable membership, since the local daemon has | |
| 323 | + | no membership; its observed identity, incarnation, protocol version, | |
| 324 | + | availability, and session catalogue come only from the local probe. The local | |
| 325 | + | entry is never durable: the snapshot store persists and reloads remote | |
| 326 | + | observations only. Observing the local daemon completes only the daemonmux | |
| 327 | + | physical preamble and closes the carriage, so it never opens a logical stream | |
| 328 | + | (never attaches) and only ever dials an existing local Unix socket, so it never | |
| 329 | + | starts a stopped daemon. Nothing in `internal/app` or `main` activates this | |
| 330 | + | composition for ordinary operation; P7 performs the coordinated cutover. | |
| 331 | + | ||
| 292 | 332 | ## Broker local IPC (Plan 001 P3.3, not activated) | |
| 293 | 333 | ||
| 294 | 334 | `internal/adapters/brokeripc` carries the P3.1 broker conversation over one | |
| @@ -270,25 +270,65 @@ These are P2.2 port contracts, not a wire migration or production activation. | |||||
| 270 | `internal/usecase/broker.Registry` owns configured hosts and their immutable | 270 | `internal/usecase/broker.Registry` owns configured hosts and their immutable | ||
| 271 | observation projections. `ports.BrokerDaemonObservation` is the broker-native | 271 | observation projections. `ports.BrokerDaemonObservation` is the broker-native | ||
| 272 | projection of one daemon, local or remote: configured authority (local flag, | 272 | projection of one daemon, local or remote: configured authority (local flag, | ||
| 273 | - | endpoint, registration, policy, display origin, rank) is stamped by the | 273 | + | endpoint, registration, policy, display origin, rank) is never taken from a |
| 274 | - | registry from durable membership and never taken from a probe, while observed | 274 | + | probe, while observed identity, process incarnation, protocol version, |
| 275 | - | identity, process incarnation, protocol version, capability bitmask, | 275 | + | capability bitmask, availability, failure/freshness state, and the exact |
| 276 | - | availability, failure/freshness state, and the exact session catalogue come | 276 | + | session catalogue come only from probe results. A remote host's authority is |
| 277 | - | only from probe results. Unknown identity or version is zero, never invented; | 277 | + | stamped by the registry from durable membership; the local entry's authority is |
| 278 | - | an observed identity always carries a protocol version, and availability is | 278 | + | stamped from the broker's configured local binding, because the local daemon |
| 279 | - | never zero (an unobserved daemon carries `RemoteAvailabilityUnknown`). A | 279 | + | has no registration and no durable membership. Unknown identity or version is |
| 280 | + | zero, never invented; an observed identity always carries a protocol version, | |||
| 281 | + | and availability is never zero (an unobserved daemon carries | |||
| 282 | + | `RemoteAvailabilityUnknown`). A | |||
| 280 | `ports.BrokerSnapshot` publishes these observations (`Daemons`, local entry | 283 | `ports.BrokerSnapshot` publishes these observations (`Daemons`, local entry | ||
| 281 | first when present, then remotes in registration order) plus the tombstone | 284 | first when present, then remotes in registration order) plus the tombstone | ||
| 282 | set; `brokerwire` mirrors them through `SnapshotBegin`/`SnapshotDaemon`/ | 285 | set; `brokerwire` mirrors them through `SnapshotBegin`/`SnapshotDaemon`/ | ||
| 283 | `SnapshotSession` parts. Local observations are never durable: the snapshot | 286 | `SnapshotSession` parts. Local observations are never durable: the snapshot | ||
| 284 | - | store persists and reloads remote observations only, with policy re-stamped | 287 | + | store persists and reloads remote observations only, with remote policy |
| 285 | - | from membership on load, so membership stays the single policy authority. | 288 | + | re-stamped from membership on load, so membership stays the single policy |
| 286 | - | Observing a daemon never attaches to it and never starts a stopped one. | 289 | + | authority for remotes. Observing a daemon never attaches to it and never starts |
| 290 | + | a stopped one. | |||
| 287 | Attachment streams declare one closed admission variant | 291 | Attachment streams declare one closed admission variant | ||
| 288 | (`ports.BrokerStreamAdmission`: exact attach/resume, named creation, ephemeral | 292 | (`ports.BrokerStreamAdmission`: exact attach/resume, named creation, ephemeral | ||
| 289 | creation) validated identically at the ports, broker IPC, and daemonmux | 293 | creation) validated identically at the ports, broker IPC, and daemonmux | ||
| 290 | boundaries. | 294 | boundaries. | ||
| 291 | 295 | ||||
| 296 | + | ## Broker-owned local route (Plan 001 P5.3a, not activated) | |||
| 297 | + | ||||
| 298 | + | The broker reaches its own machine daemon, which is not a configured host, over | |||
| 299 | + | one broker-owned local binding. `internal/adapters/brokerconfig` parses an | |||
| 300 | + | optional top-level `local` object beside the registrations: the required | |||
| 301 | + | `identity`, `route`, and `policy`, plus an optional `displayOrigin` that is | |||
| 302 | + | sanitized and defaults to `local`. The route must be a Unix daemonmux carriage | |||
| 303 | + | this process dials directly, so an SSH or QUIC route is refused and the binding | |||
| 304 | + | can never name another machine. The identity, policy, and route are the only | |||
| 305 | + | source of local authority, and `Config.LocalBinding` alone publishes them. The | |||
| 306 | + | binding's own carriage is deliberately excluded from `Config.LocalMuxRoute`: | |||
| 307 | + | that route is the single registration carriage a remote-side mux helper bridges | |||
| 308 | + | to, and no helper ever bridges the broker's own local carriage. | |||
| 309 | + | ||||
| 310 | + | A local open-stream request carries no endpoint and no registration, so the | |||
| 311 | + | resolver fences it against the binding's identity and policy instead: a request | |||
| 312 | + | whose policy conflicts with the provisioned local policy is refused, and a | |||
| 313 | + | configuration with no local object provisions no local route. The resolved | |||
| 314 | + | (identity, policy) pair is the pool key, so every local logical stream shares | |||
| 315 | + | one physical daemonmux carriage to the local daemon, exactly as every stream to | |||
| 316 | + | one remote identity does; the route's opaque address selects that carriage and | |||
| 317 | + | never participates in the key. | |||
| 318 | + | ||||
| 319 | + | The registry publishes a local observation whenever a local producer is | |||
| 320 | + | configured, always at index zero ahead of every remote and never more than one. | |||
| 321 | + | Its configured authority (local flag, display origin, policy, rank) is stamped | |||
| 322 | + | from the binding and never from durable membership, since the local daemon has | |||
| 323 | + | no membership; its observed identity, incarnation, protocol version, | |||
| 324 | + | availability, and session catalogue come only from the local probe. The local | |||
| 325 | + | entry is never durable: the snapshot store persists and reloads remote | |||
| 326 | + | observations only. Observing the local daemon completes only the daemonmux | |||
| 327 | + | physical preamble and closes the carriage, so it never opens a logical stream | |||
| 328 | + | (never attaches) and only ever dials an existing local Unix socket, so it never | |||
| 329 | + | starts a stopped daemon. Nothing in `internal/app` or `main` activates this | |||
| 330 | + | composition for ordinary operation; P7 performs the coordinated cutover. | |||
| 331 | + | ||||
| 292 | ## Broker local IPC (Plan 001 P3.3, not activated) | 332 | ## Broker local IPC (Plan 001 P3.3, not activated) | ||
| 293 | 333 | ||||
| 294 | `internal/adapters/brokeripc` carries the P3.1 broker conversation over one | 334 | `internal/adapters/brokeripc` carries the P3.1 broker conversation over one | ||
M internal/adapters/brokerconfig/config.go +126 -6
| @@ -15,7 +15,10 @@ | |||
| 15 | 15 | // addresses, identities, and policies. A client request can name a configured | |
| 16 | 16 | // endpoint and must carry that endpoint's exact registration and compatible | |
| 17 | 17 | // policy; it can never supply an address, identity, secret, command, or policy | |
| 18 | - | // of its own, and an unknown or stale request is refused without dialing. The | |
| 18 | + | // of its own, and an unknown or stale request is refused without dialing. A | |
| 19 | + | // local request carries no endpoint or registration and is instead fenced | |
| 20 | + | // against the optional broker-owned local binding (identity, policy, and Unix | |
| 21 | + | // daemonmux route), so it too can never supply authority of its own. The | |
| 19 | 22 | // route's pool address is an opaque digest, so no route detail is exposed to the | |
| 20 | 23 | // pooling layer. | |
| 21 | 24 | // | |
| @@ -80,9 +83,13 @@ var ( | |||
| 80 | 83 | // errStaleRegistration reports a request whose registration no longer | |
| 81 | 84 | // matches the provisioned immutable registration. | |
| 82 | 85 | errStaleRegistration = errors.New("brokerconfig: stale broker registration") | |
| 83 | - | // errNoLocalRoute reports a local request: the offline sandbox provisions | |
| 84 | - | // remote Unix mux routes only, so it owns no local service binding. | |
| 86 | + | // errNoLocalRoute reports a local request when the configuration provisions | |
| 87 | + | // no broker-owned local binding, so the broker owns no local route. | |
| 85 | 88 | errNoLocalRoute = errors.New("brokerconfig: offline broker has no local route") | |
| 89 | + | // errLocalPolicyConflict reports a local request whose policy is not exactly | |
| 90 | + | // compatible with the provisioned local policy. The provisioned policy stays | |
| 91 | + | // authoritative: the request can never widen, narrow, or replace it. | |
| 92 | + | errLocalPolicyConflict = errors.New("brokerconfig: local request policy conflicts with the provisioned local policy") | |
| 86 | 93 | ) | |
| 87 | 94 | ||
| 88 | 95 | // Layout names the sandbox-owned paths derived from one offline root. Every | |
| @@ -218,6 +225,30 @@ type Registration struct { | |||
| 218 | 225 | Route Route | |
| 219 | 226 | } | |
| 220 | 227 | ||
| 228 | + | // LocalBinding is the broker-owned binding for the broker's own machine | |
| 229 | + | // daemon (Plan 001 P5.3a). Unlike a remote Registration it carries no | |
| 230 | + | // registration: the local daemon is not a configured host, so a local stream | |
| 231 | + | // request is fenced only against this binding's identity, policy, and route. | |
| 232 | + | // The identity and policy are authoritative here and are the exact source of | |
| 233 | + | // the resolved endpoint and of the published local observation's configured | |
| 234 | + | // authority, so a local request can never select an identity or policy of its | |
| 235 | + | // own. | |
| 236 | + | type LocalBinding struct { | |
| 237 | + | // Identity is the stable authenticated daemon identity the local | |
| 238 | + | // daemonmux carriage must present during the physical preamble. | |
| 239 | + | Identity ports.BrokerDaemonIdentity | |
| 240 | + | // DisplayOrigin is the presentation hint published for the local | |
| 241 | + | // observation. It is sanitized at load time and is never routing authority. | |
| 242 | + | DisplayOrigin string | |
| 243 | + | // Policy is the exact connection policy the local carriage negotiates. | |
| 244 | + | Policy ports.BrokerPolicy | |
| 245 | + | // Route is the broker-owned local daemonmux carriage. It is always a Unix | |
| 246 | + | // route this process dials directly (Route.IsLocal() is true). The broker | |
| 247 | + | // dials it itself, so it is never a remote-side mux helper route | |
| 248 | + | // (Config.LocalMuxRoute). | |
| 249 | + | Route Route | |
| 250 | + | } | |
| 251 | + | ||
| 221 | 252 | // pooledIdentity is the exact (authenticated identity, policy) pair the broker | |
| 222 | 253 | // pool keys one physical transport by. The pool deliberately ignores the opaque | |
| 223 | 254 | // route address, so two registrations that share a pair share one pooled | |
| @@ -233,6 +264,10 @@ type Config struct { | |||
| 233 | 264 | byAddress map[string]Route | |
| 234 | 265 | unixRoutes []Route | |
| 235 | 266 | order []string | |
| 267 | + | // local is the optional broker-owned local binding. It is nil when the | |
| 268 | + | // configuration provisions no local route, in which case a local request is | |
| 269 | + | // refused and no local observation is produced. | |
| 270 | + | local *LocalBinding | |
| 236 | 271 | // idleGrace is the provisioned effective idle grace; hasIdleGrace records | |
| 237 | 272 | // whether the file provisioned one, so a caller can distinguish "no | |
| 238 | 273 | // provisioning, use the built-in default" from "provisioned". | |
| @@ -279,7 +314,7 @@ func Load(layout Layout) (*Config, error) { | |||
| 279 | 314 | } | |
| 280 | 315 | config.idleGrace, config.hasIdleGrace = grace, true | |
| 281 | 316 | } | |
| 282 | - | pooledRoutes := make(map[pooledIdentity]string, len(document.Registrations)) | |
| 317 | + | pooledRoutes := make(map[pooledIdentity]string, len(document.Registrations)+1) | |
| 283 | 318 | for i, entry := range document.Registrations { | |
| 284 | 319 | registration, err := entry.validate() | |
| 285 | 320 | if err != nil { | |
| @@ -310,9 +345,66 @@ func Load(layout Layout) (*Config, error) { | |||
| 310 | 345 | } | |
| 311 | 346 | config.order = append(config.order, registration.Registration.Endpoint) | |
| 312 | 347 | } | |
| 348 | + | local, err := parseLocal(document.Local) | |
| 349 | + | if err != nil { | |
| 350 | + | return nil, fmt.Errorf("brokerconfig: %s: local: %w", ConfigFileName, err) | |
| 351 | + | } | |
| 352 | + | if local != nil { | |
| 353 | + | if err := rejectRouteOverlap(local.Route.Path(), layout.reserved); err != nil { | |
| 354 | + | return nil, fmt.Errorf("brokerconfig: %s: local: %w", ConfigFileName, err) | |
| 355 | + | } | |
| 356 | + | // The pool keys one physical transport by (identity, policy) and ignores | |
| 357 | + | // the route address, so a local binding that shares a remote (or another | |
| 358 | + | // local) pair must agree on the one route that transport dials. Refuse a | |
| 359 | + | // second route for the same pair, exactly as the registration loop does. | |
| 360 | + | key := pooledIdentity{identity: local.Identity, policy: local.Policy} | |
| 361 | + | address := local.Route.Address() | |
| 362 | + | if existing, present := pooledRoutes[key]; present && existing != address { | |
| 363 | + | return nil, fmt.Errorf("brokerconfig: %s: local identity %q and policy already provisioned on a different route", ConfigFileName, local.Identity) | |
| 364 | + | } | |
| 365 | + | pooledRoutes[key] = address | |
| 366 | + | config.byAddress[address] = local.Route | |
| 367 | + | config.local = local | |
| 368 | + | } | |
| 313 | 369 | return config, nil | |
| 314 | 370 | } | |
| 315 | 371 | ||
| 372 | + | // parseLocal strictly parses the optional local binding. A nil document | |
| 373 | + | // provisions no local route. The identity and policy are required and | |
| 374 | + | // validated; the route must be a local Unix daemonmux carriage, because the | |
| 375 | + | // local daemon is reached in-process over a private Unix socket and never | |
| 376 | + | // through an SSH or QUIC carriage. The display origin is sanitized and | |
| 377 | + | // defaults to the broker-owned value "local", so an absent or wholly | |
| 378 | + | // undisplayable origin never yields an empty publication. | |
| 379 | + | func parseLocal(document *localDocument) (*LocalBinding, error) { | |
| 380 | + | if document == nil { | |
| 381 | + | return nil, nil | |
| 382 | + | } | |
| 383 | + | identity := ports.BrokerDaemonIdentity(document.Identity) | |
| 384 | + | if err := identity.Validate(); err != nil { | |
| 385 | + | return nil, err | |
| 386 | + | } | |
| 387 | + | policy, err := document.Policy.validate() | |
| 388 | + | if err != nil { | |
| 389 | + | return nil, err | |
| 390 | + | } | |
| 391 | + | route, err := parseRoute(document.Route) | |
| 392 | + | if err != nil { | |
| 393 | + | return nil, err | |
| 394 | + | } | |
| 395 | + | if !route.IsLocal() { | |
| 396 | + | return nil, errors.New("local route must be a Unix daemonmux carriage") | |
| 397 | + | } | |
| 398 | + | origin := ports.SanitizeBrokerDisplayText(document.DisplayOrigin) | |
| 399 | + | if origin == "" { | |
| 400 | + | origin = LocalDisplayOrigin | |
| 401 | + | } | |
| 402 | + | if len(origin) > ports.BrokerMaxDisplayOriginBytes { | |
| 403 | + | return nil, fmt.Errorf("local display origin exceeds %d bytes", ports.BrokerMaxDisplayOriginBytes) | |
| 404 | + | } | |
| 405 | + | return &LocalBinding{Identity: identity, DisplayOrigin: origin, Policy: policy, Route: route}, nil | |
| 406 | + | } | |
| 407 | + | ||
| 316 | 408 | // appendUnixRouteOnce adds a local Unix route unless an identical route (the | |
| 317 | 409 | // same opaque address, hence the same bounded contents) is already present, so | |
| 318 | 410 | // endpoint aliases of one route never make LocalMuxRoute report more than one | |
| @@ -326,6 +418,20 @@ func appendUnixRouteOnce(routes []Route, route Route) []Route { | |||
| 326 | 418 | return append(routes, route) | |
| 327 | 419 | } | |
| 328 | 420 | ||
| 421 | + | // LocalDisplayOrigin is the broker-owned default presentation hint for the | |
| 422 | + | // broker's own machine daemon when the configuration provisions none. | |
| 423 | + | const LocalDisplayOrigin = "local" | |
| 424 | + | ||
| 425 | + | // LocalBinding returns the provisioned broker-owned local binding and whether | |
| 426 | + | // one exists. A nil configuration or one without a local object provisions | |
| 427 | + | // nothing, and a local request is then refused. | |
| 428 | + | func (c *Config) LocalBinding() (LocalBinding, bool) { | |
| 429 | + | if c == nil || c.local == nil { | |
| 430 | + | return LocalBinding{}, false | |
| 431 | + | } | |
| 432 | + | return *c.local, true | |
| 433 | + | } | |
| 434 | + | ||
| 329 | 435 | // Endpoints returns the configured endpoints in file order. The result is a | |
| 330 | 436 | // copy; the configuration is immutable. | |
| 331 | 437 | func (c *Config) Endpoints() []string { | |
| @@ -358,7 +464,8 @@ func (c *Config) RouteByAddress(address string) (Route, bool) { | |||
| 358 | 464 | // LocalMuxRoute returns the single provisioned Unix route a remote-side mux | |
| 359 | 465 | // helper bridges to. A configuration that provisions no Unix route, or more | |
| 360 | 466 | // than one, is refused: a helper must never guess which daemon-mux endpoint it | |
| 361 | - | // serves. | |
| 467 | + | // serves. The local binding's own carriage is deliberately excluded: the broker | |
| 468 | + | // dials it itself, and no remote-side helper ever bridges it. | |
| 362 | 469 | func (c *Config) LocalMuxRoute() (Route, error) { | |
| 363 | 470 | if c == nil { | |
| 364 | 471 | return Route{}, errors.New("brokerconfig: no configuration") | |
| @@ -378,13 +485,16 @@ func (c *Config) Resolver() *Resolver { | |||
| 378 | 485 | if c == nil { | |
| 379 | 486 | return &Resolver{byEndpoint: map[string]Registration{}} | |
| 380 | 487 | } | |
| 381 | - | return &Resolver{byEndpoint: c.byEndpoint} | |
| 488 | + | return &Resolver{byEndpoint: c.byEndpoint, local: c.local} | |
| 382 | 489 | } | |
| 383 | 490 | ||
| 384 | 491 | // configDocument is the strict on-disk shape. Unknown fields are refused by the | |
| 385 | 492 | // decoder, so the file cannot smuggle additional behavior. | |
| 386 | 493 | type configDocument struct { | |
| 387 | 494 | Marker string `json:"marker"` | |
| 495 | + | // Local optionally provisions the broker-owned local binding. It is the | |
| 496 | + | // only source of the local identity, policy, and daemonmux route. | |
| 497 | + | Local *localDocument `json:"local,omitempty"` | |
| 388 | 498 | // IdleGrace optionally provisions the sandbox's effective idle grace as a Go | |
| 389 | 499 | // duration string (for example "90s"). It is the single deterministic source | |
| 390 | 500 | // for the idle grace, because a status probe cannot read a running broker's | |
| @@ -393,6 +503,16 @@ type configDocument struct { | |||
| 393 | 503 | Registrations []registrationRaw `json:"registrations"` | |
| 394 | 504 | } | |
| 395 | 505 | ||
| 506 | + | // localDocument is the strict on-disk shape of the broker-owned local binding. | |
| 507 | + | // The route must be a local Unix daemonmux carriage, the identity and policy | |
| 508 | + | // are required, and the display origin is optional (defaulting to "local"). | |
| 509 | + | type localDocument struct { | |
| 510 | + | Identity string `json:"identity"` | |
| 511 | + | DisplayOrigin string `json:"displayOrigin,omitempty"` | |
| 512 | + | Route json.RawMessage `json:"route"` | |
| 513 | + | Policy policyRaw `json:"policy"` | |
| 514 | + | } | |
| 515 | + | ||
| 396 | 516 | // registrationRaw is the raw JSON registration before semantic validation. | |
| 397 | 517 | type registrationRaw struct { | |
| 398 | 518 | Endpoint string `json:"endpoint"` | |
| @@ -15,7 +15,10 @@ | |||||
| 15 | // addresses, identities, and policies. A client request can name a configured | 15 | // addresses, identities, and policies. A client request can name a configured | ||
| 16 | // endpoint and must carry that endpoint's exact registration and compatible | 16 | // endpoint and must carry that endpoint's exact registration and compatible | ||
| 17 | // policy; it can never supply an address, identity, secret, command, or policy | 17 | // policy; it can never supply an address, identity, secret, command, or policy | ||
| 18 | - | // of its own, and an unknown or stale request is refused without dialing. The | 18 | + | // of its own, and an unknown or stale request is refused without dialing. A |
| 19 | + | // local request carries no endpoint or registration and is instead fenced | |||
| 20 | + | // against the optional broker-owned local binding (identity, policy, and Unix | |||
| 21 | + | // daemonmux route), so it too can never supply authority of its own. The | |||
| 19 | // route's pool address is an opaque digest, so no route detail is exposed to the | 22 | // route's pool address is an opaque digest, so no route detail is exposed to the | ||
| 20 | // pooling layer. | 23 | // pooling layer. | ||
| 21 | // | 24 | // | ||
| @@ -80,9 +83,13 @@ var ( | |||||
| 80 | // errStaleRegistration reports a request whose registration no longer | 83 | // errStaleRegistration reports a request whose registration no longer | ||
| 81 | // matches the provisioned immutable registration. | 84 | // matches the provisioned immutable registration. | ||
| 82 | errStaleRegistration = errors.New("brokerconfig: stale broker registration") | 85 | errStaleRegistration = errors.New("brokerconfig: stale broker registration") | ||
| 83 | - | // errNoLocalRoute reports a local request: the offline sandbox provisions | 86 | + | // errNoLocalRoute reports a local request when the configuration provisions |
| 84 | - | // remote Unix mux routes only, so it owns no local service binding. | 87 | + | // no broker-owned local binding, so the broker owns no local route. |
| 85 | errNoLocalRoute = errors.New("brokerconfig: offline broker has no local route") | 88 | errNoLocalRoute = errors.New("brokerconfig: offline broker has no local route") | ||
| 89 | + | // errLocalPolicyConflict reports a local request whose policy is not exactly | |||
| 90 | + | // compatible with the provisioned local policy. The provisioned policy stays | |||
| 91 | + | // authoritative: the request can never widen, narrow, or replace it. | |||
| 92 | + | errLocalPolicyConflict = errors.New("brokerconfig: local request policy conflicts with the provisioned local policy") | |||
| 86 | ) | 93 | ) | ||
| 87 | 94 | ||||
| 88 | // Layout names the sandbox-owned paths derived from one offline root. Every | 95 | // Layout names the sandbox-owned paths derived from one offline root. Every | ||
| @@ -218,6 +225,30 @@ type Registration struct { | |||||
| 218 | Route Route | 225 | Route Route | ||
| 219 | } | 226 | } | ||
| 220 | 227 | ||||
| 228 | + | // LocalBinding is the broker-owned binding for the broker's own machine | |||
| 229 | + | // daemon (Plan 001 P5.3a). Unlike a remote Registration it carries no | |||
| 230 | + | // registration: the local daemon is not a configured host, so a local stream | |||
| 231 | + | // request is fenced only against this binding's identity, policy, and route. | |||
| 232 | + | // The identity and policy are authoritative here and are the exact source of | |||
| 233 | + | // the resolved endpoint and of the published local observation's configured | |||
| 234 | + | // authority, so a local request can never select an identity or policy of its | |||
| 235 | + | // own. | |||
| 236 | + | type LocalBinding struct { | |||
| 237 | + | // Identity is the stable authenticated daemon identity the local | |||
| 238 | + | // daemonmux carriage must present during the physical preamble. | |||
| 239 | + | Identity ports.BrokerDaemonIdentity | |||
| 240 | + | // DisplayOrigin is the presentation hint published for the local | |||
| 241 | + | // observation. It is sanitized at load time and is never routing authority. | |||
| 242 | + | DisplayOrigin string | |||
| 243 | + | // Policy is the exact connection policy the local carriage negotiates. | |||
| 244 | + | Policy ports.BrokerPolicy | |||
| 245 | + | // Route is the broker-owned local daemonmux carriage. It is always a Unix | |||
| 246 | + | // route this process dials directly (Route.IsLocal() is true). The broker | |||
| 247 | + | // dials it itself, so it is never a remote-side mux helper route | |||
| 248 | + | // (Config.LocalMuxRoute). | |||
| 249 | + | Route Route | |||
| 250 | + | } | |||
| 251 | + | ||||
| 221 | // pooledIdentity is the exact (authenticated identity, policy) pair the broker | 252 | // pooledIdentity is the exact (authenticated identity, policy) pair the broker | ||
| 222 | // pool keys one physical transport by. The pool deliberately ignores the opaque | 253 | // pool keys one physical transport by. The pool deliberately ignores the opaque | ||
| 223 | // route address, so two registrations that share a pair share one pooled | 254 | // route address, so two registrations that share a pair share one pooled | ||
| @@ -233,6 +264,10 @@ type Config struct { | |||||
| 233 | byAddress map[string]Route | 264 | byAddress map[string]Route | ||
| 234 | unixRoutes []Route | 265 | unixRoutes []Route | ||
| 235 | order []string | 266 | order []string | ||
| 267 | + | // local is the optional broker-owned local binding. It is nil when the | |||
| 268 | + | // configuration provisions no local route, in which case a local request is | |||
| 269 | + | // refused and no local observation is produced. | |||
| 270 | + | local *LocalBinding | |||
| 236 | // idleGrace is the provisioned effective idle grace; hasIdleGrace records | 271 | // idleGrace is the provisioned effective idle grace; hasIdleGrace records | ||
| 237 | // whether the file provisioned one, so a caller can distinguish "no | 272 | // whether the file provisioned one, so a caller can distinguish "no | ||
| 238 | // provisioning, use the built-in default" from "provisioned". | 273 | // provisioning, use the built-in default" from "provisioned". | ||
| @@ -279,7 +314,7 @@ func Load(layout Layout) (*Config, error) { | |||||
| 279 | } | 314 | } | ||
| 280 | config.idleGrace, config.hasIdleGrace = grace, true | 315 | config.idleGrace, config.hasIdleGrace = grace, true | ||
| 281 | } | 316 | } | ||
| 282 | - | pooledRoutes := make(map[pooledIdentity]string, len(document.Registrations)) | 317 | + | pooledRoutes := make(map[pooledIdentity]string, len(document.Registrations)+1) |
| 283 | for i, entry := range document.Registrations { | 318 | for i, entry := range document.Registrations { | ||
| 284 | registration, err := entry.validate() | 319 | registration, err := entry.validate() | ||
| 285 | if err != nil { | 320 | if err != nil { | ||
| @@ -310,9 +345,66 @@ func Load(layout Layout) (*Config, error) { | |||||
| 310 | } | 345 | } | ||
| 311 | config.order = append(config.order, registration.Registration.Endpoint) | 346 | config.order = append(config.order, registration.Registration.Endpoint) | ||
| 312 | } | 347 | } | ||
| 348 | + | local, err := parseLocal(document.Local) | |||
| 349 | + | if err != nil { | |||
| 350 | + | return nil, fmt.Errorf("brokerconfig: %s: local: %w", ConfigFileName, err) | |||
| 351 | + | } | |||
| 352 | + | if local != nil { | |||
| 353 | + | if err := rejectRouteOverlap(local.Route.Path(), layout.reserved); err != nil { | |||
| 354 | + | return nil, fmt.Errorf("brokerconfig: %s: local: %w", ConfigFileName, err) | |||
| 355 | + | } | |||
| 356 | + | // The pool keys one physical transport by (identity, policy) and ignores | |||
| 357 | + | // the route address, so a local binding that shares a remote (or another | |||
| 358 | + | // local) pair must agree on the one route that transport dials. Refuse a | |||
| 359 | + | // second route for the same pair, exactly as the registration loop does. | |||
| 360 | + | key := pooledIdentity{identity: local.Identity, policy: local.Policy} | |||
| 361 | + | address := local.Route.Address() | |||
| 362 | + | if existing, present := pooledRoutes[key]; present && existing != address { | |||
| 363 | + | return nil, fmt.Errorf("brokerconfig: %s: local identity %q and policy already provisioned on a different route", ConfigFileName, local.Identity) | |||
| 364 | + | } | |||
| 365 | + | pooledRoutes[key] = address | |||
| 366 | + | config.byAddress[address] = local.Route | |||
| 367 | + | config.local = local | |||
| 368 | + | } | |||
| 313 | return config, nil | 369 | return config, nil | ||
| 314 | } | 370 | } | ||
| 315 | 371 | ||||
| 372 | + | // parseLocal strictly parses the optional local binding. A nil document | |||
| 373 | + | // provisions no local route. The identity and policy are required and | |||
| 374 | + | // validated; the route must be a local Unix daemonmux carriage, because the | |||
| 375 | + | // local daemon is reached in-process over a private Unix socket and never | |||
| 376 | + | // through an SSH or QUIC carriage. The display origin is sanitized and | |||
| 377 | + | // defaults to the broker-owned value "local", so an absent or wholly | |||
| 378 | + | // undisplayable origin never yields an empty publication. | |||
| 379 | + | func parseLocal(document *localDocument) (*LocalBinding, error) { | |||
| 380 | + | if document == nil { | |||
| 381 | + | return nil, nil | |||
| 382 | + | } | |||
| 383 | + | identity := ports.BrokerDaemonIdentity(document.Identity) | |||
| 384 | + | if err := identity.Validate(); err != nil { | |||
| 385 | + | return nil, err | |||
| 386 | + | } | |||
| 387 | + | policy, err := document.Policy.validate() | |||
| 388 | + | if err != nil { | |||
| 389 | + | return nil, err | |||
| 390 | + | } | |||
| 391 | + | route, err := parseRoute(document.Route) | |||
| 392 | + | if err != nil { | |||
| 393 | + | return nil, err | |||
| 394 | + | } | |||
| 395 | + | if !route.IsLocal() { | |||
| 396 | + | return nil, errors.New("local route must be a Unix daemonmux carriage") | |||
| 397 | + | } | |||
| 398 | + | origin := ports.SanitizeBrokerDisplayText(document.DisplayOrigin) | |||
| 399 | + | if origin == "" { | |||
| 400 | + | origin = LocalDisplayOrigin | |||
| 401 | + | } | |||
| 402 | + | if len(origin) > ports.BrokerMaxDisplayOriginBytes { | |||
| 403 | + | return nil, fmt.Errorf("local display origin exceeds %d bytes", ports.BrokerMaxDisplayOriginBytes) | |||
| 404 | + | } | |||
| 405 | + | return &LocalBinding{Identity: identity, DisplayOrigin: origin, Policy: policy, Route: route}, nil | |||
| 406 | + | } | |||
| 407 | + | ||||
| 316 | // appendUnixRouteOnce adds a local Unix route unless an identical route (the | 408 | // appendUnixRouteOnce adds a local Unix route unless an identical route (the | ||
| 317 | // same opaque address, hence the same bounded contents) is already present, so | 409 | // same opaque address, hence the same bounded contents) is already present, so | ||
| 318 | // endpoint aliases of one route never make LocalMuxRoute report more than one | 410 | // endpoint aliases of one route never make LocalMuxRoute report more than one | ||
| @@ -326,6 +418,20 @@ func appendUnixRouteOnce(routes []Route, route Route) []Route { | |||||
| 326 | return append(routes, route) | 418 | return append(routes, route) | ||
| 327 | } | 419 | } | ||
| 328 | 420 | ||||
| 421 | + | // LocalDisplayOrigin is the broker-owned default presentation hint for the | |||
| 422 | + | // broker's own machine daemon when the configuration provisions none. | |||
| 423 | + | const LocalDisplayOrigin = "local" | |||
| 424 | + | ||||
| 425 | + | // LocalBinding returns the provisioned broker-owned local binding and whether | |||
| 426 | + | // one exists. A nil configuration or one without a local object provisions | |||
| 427 | + | // nothing, and a local request is then refused. | |||
| 428 | + | func (c *Config) LocalBinding() (LocalBinding, bool) { | |||
| 429 | + | if c == nil || c.local == nil { | |||
| 430 | + | return LocalBinding{}, false | |||
| 431 | + | } | |||
| 432 | + | return *c.local, true | |||
| 433 | + | } | |||
| 434 | + | ||||
| 329 | // Endpoints returns the configured endpoints in file order. The result is a | 435 | // Endpoints returns the configured endpoints in file order. The result is a | ||
| 330 | // copy; the configuration is immutable. | 436 | // copy; the configuration is immutable. | ||
| 331 | func (c *Config) Endpoints() []string { | 437 | func (c *Config) Endpoints() []string { | ||
| @@ -358,7 +464,8 @@ func (c *Config) RouteByAddress(address string) (Route, bool) { | |||||
| 358 | // LocalMuxRoute returns the single provisioned Unix route a remote-side mux | 464 | // LocalMuxRoute returns the single provisioned Unix route a remote-side mux | ||
| 359 | // helper bridges to. A configuration that provisions no Unix route, or more | 465 | // helper bridges to. A configuration that provisions no Unix route, or more | ||
| 360 | // than one, is refused: a helper must never guess which daemon-mux endpoint it | 466 | // than one, is refused: a helper must never guess which daemon-mux endpoint it | ||
| 361 | - | // serves. | 467 | + | // serves. The local binding's own carriage is deliberately excluded: the broker |
| 468 | + | // dials it itself, and no remote-side helper ever bridges it. | |||
| 362 | func (c *Config) LocalMuxRoute() (Route, error) { | 469 | func (c *Config) LocalMuxRoute() (Route, error) { | ||
| 363 | if c == nil { | 470 | if c == nil { | ||
| 364 | return Route{}, errors.New("brokerconfig: no configuration") | 471 | return Route{}, errors.New("brokerconfig: no configuration") | ||
| @@ -378,13 +485,16 @@ func (c *Config) Resolver() *Resolver { | |||||
| 378 | if c == nil { | 485 | if c == nil { | ||
| 379 | return &Resolver{byEndpoint: map[string]Registration{}} | 486 | return &Resolver{byEndpoint: map[string]Registration{}} | ||
| 380 | } | 487 | } | ||
| 381 | - | return &Resolver{byEndpoint: c.byEndpoint} | 488 | + | return &Resolver{byEndpoint: c.byEndpoint, local: c.local} |
| 382 | } | 489 | } | ||
| 383 | 490 | ||||
| 384 | // configDocument is the strict on-disk shape. Unknown fields are refused by the | 491 | // configDocument is the strict on-disk shape. Unknown fields are refused by the | ||
| 385 | // decoder, so the file cannot smuggle additional behavior. | 492 | // decoder, so the file cannot smuggle additional behavior. | ||
| 386 | type configDocument struct { | 493 | type configDocument struct { | ||
| 387 | Marker string `json:"marker"` | 494 | Marker string `json:"marker"` | ||
| 495 | + | // Local optionally provisions the broker-owned local binding. It is the | |||
| 496 | + | // only source of the local identity, policy, and daemonmux route. | |||
| 497 | + | Local *localDocument `json:"local,omitempty"` | |||
| 388 | // IdleGrace optionally provisions the sandbox's effective idle grace as a Go | 498 | // IdleGrace optionally provisions the sandbox's effective idle grace as a Go | ||
| 389 | // duration string (for example "90s"). It is the single deterministic source | 499 | // duration string (for example "90s"). It is the single deterministic source | ||
| 390 | // for the idle grace, because a status probe cannot read a running broker's | 500 | // for the idle grace, because a status probe cannot read a running broker's | ||
| @@ -393,6 +503,16 @@ type configDocument struct { | |||||
| 393 | Registrations []registrationRaw `json:"registrations"` | 503 | Registrations []registrationRaw `json:"registrations"` | ||
| 394 | } | 504 | } | ||
| 395 | 505 | ||||
| 506 | + | // localDocument is the strict on-disk shape of the broker-owned local binding. | |||
| 507 | + | // The route must be a local Unix daemonmux carriage, the identity and policy | |||
| 508 | + | // are required, and the display origin is optional (defaulting to "local"). | |||
| 509 | + | type localDocument struct { | |||
| 510 | + | Identity string `json:"identity"` | |||
| 511 | + | DisplayOrigin string `json:"displayOrigin,omitempty"` | |||
| 512 | + | Route json.RawMessage `json:"route"` | |||
| 513 | + | Policy policyRaw `json:"policy"` | |||
| 514 | + | } | |||
| 515 | + | ||||
| 396 | // registrationRaw is the raw JSON registration before semantic validation. | 516 | // registrationRaw is the raw JSON registration before semantic validation. | ||
| 397 | type registrationRaw struct { | 517 | type registrationRaw struct { | ||
| 398 | Endpoint string `json:"endpoint"` | 518 | Endpoint string `json:"endpoint"` | ||
A internal/adapters/brokerconfig/local_test.go +332 -0
| @@ -0,0 +1,332 @@ | |||
| 1 | + | package brokerconfig | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/ports" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | const ( | |
| 15 | + | localTestIdentity = "offline-local-daemon" | |
| 16 | + | localTestSocket = "/tmp/vev-brokerconfig-test/local-mux.sock" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // localDocument returns the raw local object provisioned by the fixture. The | |
| 20 | + | // caller may override or delete fields through the returned map. | |
| 21 | + | func localTestDocument() map[string]any { | |
| 22 | + | policy := testPolicy() | |
| 23 | + | return map[string]any{ | |
| 24 | + | "identity": localTestIdentity, | |
| 25 | + | "route": localTestSocket, | |
| 26 | + | "policy": map[string]any{ | |
| 27 | + | "protocolVersion": policy.ProtocolVersion, | |
| 28 | + | "catalogSchemaVersion": policy.CatalogSchemaVersion, | |
| 29 | + | "environmentPolicy": "client-owned", | |
| 30 | + | "transport": policy.Transport, | |
| 31 | + | "trust": policy.Trust, | |
| 32 | + | "launch": policy.Launch, | |
| 33 | + | "isolation": policy.Isolation, | |
| 34 | + | }, | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // documentWithLocal returns a valid registration document carrying one local | |
| 39 | + | // binding, with any field overrides applied. | |
| 40 | + | func documentWithLocal(t *testing.T, local map[string]any) map[string]any { | |
| 41 | + | t.Helper() | |
| 42 | + | document := registrationDocument(nil) | |
| 43 | + | document["local"] = local | |
| 44 | + | return document | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // loadDocument writes one document into a fresh offline root and loads it. | |
| 48 | + | func loadDocument(t *testing.T, document any, reserved []string) (*Config, error) { | |
| 49 | + | t.Helper() | |
| 50 | + | root := t.TempDir() | |
| 51 | + | writeConfig(t, root, document, 0o600) | |
| 52 | + | layout, err := ResolveLayout(root, reserved) | |
| 53 | + | require.NoError(t, err) | |
| 54 | + | return Load(layout) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // localRequest builds one local open-stream request under the fixture policy. | |
| 58 | + | func localRequest() ports.BrokerOpenStreamRequest { | |
| 59 | + | return ports.BrokerOpenStreamRequest{ | |
| 60 | + | Purpose: ports.BrokerStreamControl, | |
| 61 | + | Local: true, | |
| 62 | + | Policy: testPolicy(), | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // TestLoadLocalBinding pins the provisioned local binding: the identity, | |
| 67 | + | // policy, sanitized display origin, and local Unix route are configuration | |
| 68 | + | // authority, and the local route is resolvable by its opaque pool address. | |
| 69 | + | func TestLoadLocalBinding(t *testing.T) { | |
| 70 | + | config, err := loadDocument(t, documentWithLocal(t, localTestDocument()), nil) | |
| 71 | + | require.NoError(t, err) | |
| 72 | + | ||
| 73 | + | binding, ok := config.LocalBinding() | |
| 74 | + | require.True(t, ok) | |
| 75 | + | require.Equal(t, ports.BrokerDaemonIdentity(localTestIdentity), binding.Identity) | |
| 76 | + | require.Equal(t, testPolicy(), binding.Policy) | |
| 77 | + | require.Equal(t, LocalDisplayOrigin, binding.DisplayOrigin) | |
| 78 | + | require.True(t, binding.Route.IsLocal()) | |
| 79 | + | require.Equal(t, localTestSocket, binding.Route.Path()) | |
| 80 | + | ||
| 81 | + | resolved, err := config.Resolver().Resolve(context.Background(), localRequest()) | |
| 82 | + | require.NoError(t, err) | |
| 83 | + | require.Equal(t, binding.Identity, resolved.Identity) | |
| 84 | + | require.Equal(t, binding.Policy, resolved.Policy) | |
| 85 | + | require.Equal(t, binding.Route.Address(), resolved.Address) | |
| 86 | + | ||
| 87 | + | route, ok := config.RouteByAddress(resolved.Address) | |
| 88 | + | require.True(t, ok) | |
| 89 | + | require.Equal(t, localTestSocket, route.Path()) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // TestLoadLocalBindingDefaultsDisplayOrigin pins the broker-owned default and | |
| 93 | + | // the sanitizer: an absent origin becomes "local", and a hostile origin is | |
| 94 | + | // sanitized rather than published raw. | |
| 95 | + | func TestLoadLocalBindingDefaultsDisplayOrigin(t *testing.T) { | |
| 96 | + | tests := []struct { | |
| 97 | + | name string | |
| 98 | + | origin any | |
| 99 | + | want string | |
| 100 | + | }{ | |
| 101 | + | {name: "absent defaults to the broker-owned origin", origin: nil, want: LocalDisplayOrigin}, | |
| 102 | + | {name: "explicit origin is kept", origin: "this-host", want: "this-host"}, | |
| 103 | + | {name: "bidi and control runes are stripped", origin: "lo\u202ecal", want: "local"}, | |
| 104 | + | {name: "wholly undisplayable defaults", origin: "\u202e\u2028", want: LocalDisplayOrigin}, | |
| 105 | + | } | |
| 106 | + | for _, tt := range tests { | |
| 107 | + | t.Run(tt.name, func(t *testing.T) { | |
| 108 | + | local := localTestDocument() | |
| 109 | + | if tt.origin == nil { | |
| 110 | + | delete(local, "displayOrigin") | |
| 111 | + | } else { | |
| 112 | + | local["displayOrigin"] = tt.origin | |
| 113 | + | } | |
| 114 | + | config, err := loadDocument(t, documentWithLocal(t, local), nil) | |
| 115 | + | require.NoError(t, err) | |
| 116 | + | binding, ok := config.LocalBinding() | |
| 117 | + | require.True(t, ok) | |
| 118 | + | require.Equal(t, tt.want, binding.DisplayOrigin) | |
| 119 | + | }) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // TestLoadRejectsInvalidLocalBinding pins every local-binding refusal at the | |
| 124 | + | // load boundary. A binding the broker could not fence is never provisioned. | |
| 125 | + | func TestLoadRejectsInvalidLocalBinding(t *testing.T) { | |
| 126 | + | tests := []struct { | |
| 127 | + | name string | |
| 128 | + | mutate func(t *testing.T, local map[string]any) | |
| 129 | + | wantErr string | |
| 130 | + | }{ | |
| 131 | + | { | |
| 132 | + | name: "missing identity", | |
| 133 | + | mutate: func(_ *testing.T, local map[string]any) { delete(local, "identity") }, | |
| 134 | + | wantErr: "identity", | |
| 135 | + | }, | |
| 136 | + | { | |
| 137 | + | name: "missing policy", | |
| 138 | + | mutate: func(_ *testing.T, local map[string]any) { delete(local, "policy") }, | |
| 139 | + | wantErr: "policy", | |
| 140 | + | }, | |
| 141 | + | { | |
| 142 | + | name: "missing route", | |
| 143 | + | mutate: func(_ *testing.T, local map[string]any) { delete(local, "route") }, | |
| 144 | + | wantErr: "route", | |
| 145 | + | }, | |
| 146 | + | { | |
| 147 | + | name: "ssh route is refused", | |
| 148 | + | mutate: func(_ *testing.T, local map[string]any) { | |
| 149 | + | local["route"] = map[string]any{"kind": "ssh-stdio", "target": "user@host", "argv": []string{"vev"}} | |
| 150 | + | }, | |
| 151 | + | wantErr: "Unix daemonmux", | |
| 152 | + | }, | |
| 153 | + | { | |
| 154 | + | name: "relative route is refused", | |
| 155 | + | mutate: func(_ *testing.T, local map[string]any) { local["route"] = "relative/mux.sock" }, | |
| 156 | + | wantErr: "not absolute", | |
| 157 | + | }, | |
| 158 | + | { | |
| 159 | + | name: "unknown field is refused", | |
| 160 | + | mutate: func(_ *testing.T, local map[string]any) { local["extra"] = 1 }, | |
| 161 | + | wantErr: "unknown field", | |
| 162 | + | }, | |
| 163 | + | } | |
| 164 | + | for _, tt := range tests { | |
| 165 | + | t.Run(tt.name, func(t *testing.T) { | |
| 166 | + | local := localTestDocument() | |
| 167 | + | tt.mutate(t, local) | |
| 168 | + | _, err := loadDocument(t, documentWithLocal(t, local), nil) | |
| 169 | + | require.Error(t, err) | |
| 170 | + | require.Contains(t, err.Error(), tt.wantErr) | |
| 171 | + | }) | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // TestLoadLocalRouteFencesProduction proves the local route is fenced against | |
| 176 | + | // the reserved production runtime and state directories exactly like a remote | |
| 177 | + | // Unix route: an isolated sandbox can never reach a production endpoint | |
| 178 | + | // through its own local binding. | |
| 179 | + | func TestLoadLocalRouteFencesProduction(t *testing.T) { | |
| 180 | + | reservedDir := t.TempDir() | |
| 181 | + | local := localTestDocument() | |
| 182 | + | local["route"] = filepath.Join(reservedDir, "mux.sock") | |
| 183 | + | _, err := loadDocument(t, documentWithLocal(t, local), []string{reservedDir}) | |
| 184 | + | require.Error(t, err) | |
| 185 | + | require.Contains(t, err.Error(), "overlaps production") | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // TestLoadLocalRouteAliasesRegistrationRoute proves the local binding may | |
| 189 | + | // reuse the exact Unix mux route a registration already provisions: the pool | |
| 190 | + | // address is identical only when the route is identical. | |
| 191 | + | func TestLoadLocalRouteAliasesRegistrationRoute(t *testing.T) { | |
| 192 | + | local := localTestDocument() | |
| 193 | + | local["route"] = "/tmp/vev-brokerconfig-test/mux.sock" // the fixture registration route | |
| 194 | + | config, err := loadDocument(t, documentWithLocal(t, local), nil) | |
| 195 | + | require.NoError(t, err) | |
| 196 | + | binding, ok := config.LocalBinding() | |
| 197 | + | require.True(t, ok) | |
| 198 | + | require.Equal(t, "/tmp/vev-brokerconfig-test/mux.sock", binding.Route.Path()) | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | // TestLoadRefusesLocalIdentityPoolConflict proves the pool's (identity, | |
| 202 | + | // policy) keying is honored across the local and remote provisionings: a local | |
| 203 | + | // binding that shares a registration's exact identity and policy but names a | |
| 204 | + | // different route is refused rather than silently dialed for one and ignored | |
| 205 | + | // for the other. | |
| 206 | + | func TestLoadRefusesLocalIdentityPoolConflict(t *testing.T) { | |
| 207 | + | local := localTestDocument() | |
| 208 | + | local["identity"] = testRegistrationIdentity() | |
| 209 | + | local["route"] = "/tmp/vev-brokerconfig-test/other-mux.sock" | |
| 210 | + | _, err := loadDocument(t, documentWithLocal(t, local), nil) | |
| 211 | + | require.Error(t, err) | |
| 212 | + | require.Contains(t, err.Error(), "already provisioned on a different route") | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // testRegistrationIdentity reads the identity the fixture registration | |
| 216 | + | // provisions, so the collision test is anchored to the real fixture. | |
| 217 | + | func testRegistrationIdentity() string { return "offline-daemon" } | |
| 218 | + | ||
| 219 | + | // TestResolveLocalSuccessAndRefusals pins the local resolution contract: | |
| 220 | + | // success returns the configured authority and address, a configuration with | |
| 221 | + | // no local binding refuses as unavailable, a conflicting policy is refused as | |
| 222 | + | // such, and a cancelled context is reported as cancellation. Every refusal is | |
| 223 | + | // typed as a ports.BrokerError so the pool classifies it without text matching. | |
| 224 | + | func TestResolveLocalSuccessAndRefusals(t *testing.T) { | |
| 225 | + | config, err := loadDocument(t, documentWithLocal(t, localTestDocument()), nil) | |
| 226 | + | require.NoError(t, err) | |
| 227 | + | binding, ok := config.LocalBinding() | |
| 228 | + | require.True(t, ok) | |
| 229 | + | resolver := config.Resolver() | |
| 230 | + | ||
| 231 | + | noLocalConfig, err := loadDocument(t, registrationDocument(nil), nil) | |
| 232 | + | require.NoError(t, err) | |
| 233 | + | ||
| 234 | + | tests := []struct { | |
| 235 | + | name string | |
| 236 | + | resolver *Resolver | |
| 237 | + | request ports.BrokerOpenStreamRequest | |
| 238 | + | wantCode ports.BrokerErrorCode | |
| 239 | + | wantErr error | |
| 240 | + | }{ | |
| 241 | + | { | |
| 242 | + | name: "success returns configured authority", | |
| 243 | + | resolver: resolver, | |
| 244 | + | request: localRequest(), | |
| 245 | + | }, | |
| 246 | + | { | |
| 247 | + | name: "no local binding is unavailable", | |
| 248 | + | resolver: noLocalConfig.Resolver(), | |
| 249 | + | request: localRequest(), | |
| 250 | + | wantCode: ports.BrokerErrorUnavailable, | |
| 251 | + | wantErr: errNoLocalRoute, | |
| 252 | + | }, | |
| 253 | + | { | |
| 254 | + | name: "conflicting policy is refused", | |
| 255 | + | resolver: resolver, | |
| 256 | + | request: func() ports.BrokerOpenStreamRequest { | |
| 257 | + | request := localRequest() | |
| 258 | + | request.Policy.Transport = "other-transport" | |
| 259 | + | return request | |
| 260 | + | }(), | |
| 261 | + | wantCode: ports.BrokerErrorConflictingPolicy, | |
| 262 | + | wantErr: errLocalPolicyConflict, | |
| 263 | + | }, | |
| 264 | + | { | |
| 265 | + | name: "cancelled context is cancellation", | |
| 266 | + | resolver: resolver, | |
| 267 | + | request: localRequest(), | |
| 268 | + | wantErr: context.Canceled, | |
| 269 | + | }, | |
| 270 | + | } | |
| 271 | + | for _, tt := range tests { | |
| 272 | + | t.Run(tt.name, func(t *testing.T) { | |
| 273 | + | ctx := context.Background() | |
| 274 | + | if errors.Is(tt.wantErr, context.Canceled) { | |
| 275 | + | cancelled, cancel := context.WithCancel(ctx) | |
| 276 | + | cancel() | |
| 277 | + | ctx = cancelled | |
| 278 | + | } | |
| 279 | + | resolved, err := tt.resolver.Resolve(ctx, tt.request) | |
| 280 | + | if tt.wantErr == nil { | |
| 281 | + | require.NoError(t, err) | |
| 282 | + | require.Equal(t, binding.Identity, resolved.Identity) | |
| 283 | + | require.Equal(t, binding.Policy, resolved.Policy) | |
| 284 | + | require.Equal(t, binding.Route.Address(), resolved.Address) | |
| 285 | + | return | |
| 286 | + | } | |
| 287 | + | require.Error(t, err) | |
| 288 | + | if tt.wantCode != 0 { | |
| 289 | + | var typed ports.BrokerError | |
| 290 | + | require.ErrorAs(t, err, &typed) | |
| 291 | + | require.Equal(t, tt.wantCode, typed.Code) | |
| 292 | + | } | |
| 293 | + | require.ErrorIs(t, err, tt.wantErr) | |
| 294 | + | }) | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | // TestLocalMuxRouteExcludesLocalBinding proves Config.LocalMuxRoute serves the | |
| 299 | + | // remote-side mux helper's route only. The broker-owned local binding's own | |
| 300 | + | // carriage is deliberately excluded: no helper ever bridges it, so a | |
| 301 | + | // configuration provisioning only the local object has no helper route, and one | |
| 302 | + | // provisioning both returns the registration's route, never the local binding's. | |
| 303 | + | func TestLocalMuxRouteExcludesLocalBinding(t *testing.T) { | |
| 304 | + | t.Run("local-only configuration provisions no helper route", func(t *testing.T) { | |
| 305 | + | document := map[string]any{ | |
| 306 | + | "marker": Marker, | |
| 307 | + | "registrations": []any{}, | |
| 308 | + | "local": localTestDocument(), | |
| 309 | + | } | |
| 310 | + | config, err := loadDocument(t, document, nil) | |
| 311 | + | require.NoError(t, err) | |
| 312 | + | _, ok := config.LocalBinding() | |
| 313 | + | require.True(t, ok, "the fixture provisions a broker-owned local binding") | |
| 314 | + | _, err = config.LocalMuxRoute() | |
| 315 | + | require.Error(t, err) | |
| 316 | + | require.Contains(t, err.Error(), "no Unix daemon-mux route is provisioned") | |
| 317 | + | }) | |
| 318 | + | ||
| 319 | + | t.Run("registration route wins over the local binding route", func(t *testing.T) { | |
| 320 | + | config, err := loadDocument(t, documentWithLocal(t, localTestDocument()), nil) | |
| 321 | + | require.NoError(t, err) | |
| 322 | + | binding, ok := config.LocalBinding() | |
| 323 | + | require.True(t, ok) | |
| 324 | + | ||
| 325 | + | route, err := config.LocalMuxRoute() | |
| 326 | + | require.NoError(t, err) | |
| 327 | + | require.True(t, route.IsLocal()) | |
| 328 | + | require.Equal(t, "/tmp/vev-brokerconfig-test/mux.sock", route.Path(), "the helper bridges the registration route") | |
| 329 | + | require.NotEqual(t, binding.Route.Path(), route.Path(), "the local binding route is not the helper route") | |
| 330 | + | require.NotEqual(t, binding.Route.Address(), route.Address(), "the two routes carry distinct pool addresses") | |
| 331 | + | }) | |
| 332 | + | } | |
| @@ -0,0 +1,332 @@ | |||||
| 1 | + | package brokerconfig | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/vev/internal/ports" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | const ( | |||
| 15 | + | localTestIdentity = "offline-local-daemon" | |||
| 16 | + | localTestSocket = "/tmp/vev-brokerconfig-test/local-mux.sock" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // localDocument returns the raw local object provisioned by the fixture. The | |||
| 20 | + | // caller may override or delete fields through the returned map. | |||
| 21 | + | func localTestDocument() map[string]any { | |||
| 22 | + | policy := testPolicy() | |||
| 23 | + | return map[string]any{ | |||
| 24 | + | "identity": localTestIdentity, | |||
| 25 | + | "route": localTestSocket, | |||
| 26 | + | "policy": map[string]any{ | |||
| 27 | + | "protocolVersion": policy.ProtocolVersion, | |||
| 28 | + | "catalogSchemaVersion": policy.CatalogSchemaVersion, | |||
| 29 | + | "environmentPolicy": "client-owned", | |||
| 30 | + | "transport": policy.Transport, | |||
| 31 | + | "trust": policy.Trust, | |||
| 32 | + | "launch": policy.Launch, | |||
| 33 | + | "isolation": policy.Isolation, | |||
| 34 | + | }, | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // documentWithLocal returns a valid registration document carrying one local | |||
| 39 | + | // binding, with any field overrides applied. | |||
| 40 | + | func documentWithLocal(t *testing.T, local map[string]any) map[string]any { | |||
| 41 | + | t.Helper() | |||
| 42 | + | document := registrationDocument(nil) | |||
| 43 | + | document["local"] = local | |||
| 44 | + | return document | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // loadDocument writes one document into a fresh offline root and loads it. | |||
| 48 | + | func loadDocument(t *testing.T, document any, reserved []string) (*Config, error) { | |||
| 49 | + | t.Helper() | |||
| 50 | + | root := t.TempDir() | |||
| 51 | + | writeConfig(t, root, document, 0o600) | |||
| 52 | + | layout, err := ResolveLayout(root, reserved) | |||
| 53 | + | require.NoError(t, err) | |||
| 54 | + | return Load(layout) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // localRequest builds one local open-stream request under the fixture policy. | |||
| 58 | + | func localRequest() ports.BrokerOpenStreamRequest { | |||
| 59 | + | return ports.BrokerOpenStreamRequest{ | |||
| 60 | + | Purpose: ports.BrokerStreamControl, | |||
| 61 | + | Local: true, | |||
| 62 | + | Policy: testPolicy(), | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // TestLoadLocalBinding pins the provisioned local binding: the identity, | |||
| 67 | + | // policy, sanitized display origin, and local Unix route are configuration | |||
| 68 | + | // authority, and the local route is resolvable by its opaque pool address. | |||
| 69 | + | func TestLoadLocalBinding(t *testing.T) { | |||
| 70 | + | config, err := loadDocument(t, documentWithLocal(t, localTestDocument()), nil) | |||
| 71 | + | require.NoError(t, err) | |||
| 72 | + | ||||
| 73 | + | binding, ok := config.LocalBinding() | |||
| 74 | + | require.True(t, ok) | |||
| 75 | + | require.Equal(t, ports.BrokerDaemonIdentity(localTestIdentity), binding.Identity) | |||
| 76 | + | require.Equal(t, testPolicy(), binding.Policy) | |||
| 77 | + | require.Equal(t, LocalDisplayOrigin, binding.DisplayOrigin) | |||
| 78 | + | require.True(t, binding.Route.IsLocal()) | |||
| 79 | + | require.Equal(t, localTestSocket, binding.Route.Path()) | |||
| 80 | + | ||||
| 81 | + | resolved, err := config.Resolver().Resolve(context.Background(), localRequest()) | |||
| 82 | + | require.NoError(t, err) | |||
| 83 | + | require.Equal(t, binding.Identity, resolved.Identity) | |||
| 84 | + | require.Equal(t, binding.Policy, resolved.Policy) | |||
| 85 | + | require.Equal(t, binding.Route.Address(), resolved.Address) | |||
| 86 | + | ||||
| 87 | + | route, ok := config.RouteByAddress(resolved.Address) | |||
| 88 | + | require.True(t, ok) | |||
| 89 | + | require.Equal(t, localTestSocket, route.Path()) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // TestLoadLocalBindingDefaultsDisplayOrigin pins the broker-owned default and | |||
| 93 | + | // the sanitizer: an absent origin becomes "local", and a hostile origin is | |||
| 94 | + | // sanitized rather than published raw. | |||
| 95 | + | func TestLoadLocalBindingDefaultsDisplayOrigin(t *testing.T) { | |||
| 96 | + | tests := []struct { | |||
| 97 | + | name string | |||
| 98 | + | origin any | |||
| 99 | + | want string | |||
| 100 | + | }{ | |||
| 101 | + | {name: "absent defaults to the broker-owned origin", origin: nil, want: LocalDisplayOrigin}, | |||
| 102 | + | {name: "explicit origin is kept", origin: "this-host", want: "this-host"}, | |||
| 103 | + | {name: "bidi and control runes are stripped", origin: "lo\u202ecal", want: "local"}, | |||
| 104 | + | {name: "wholly undisplayable defaults", origin: "\u202e\u2028", want: LocalDisplayOrigin}, | |||
| 105 | + | } | |||
| 106 | + | for _, tt := range tests { | |||
| 107 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 108 | + | local := localTestDocument() | |||
| 109 | + | if tt.origin == nil { | |||
| 110 | + | delete(local, "displayOrigin") | |||
| 111 | + | } else { | |||
| 112 | + | local["displayOrigin"] = tt.origin | |||
| 113 | + | } | |||
| 114 | + | config, err := loadDocument(t, documentWithLocal(t, local), nil) | |||
| 115 | + | require.NoError(t, err) | |||
| 116 | + | binding, ok := config.LocalBinding() | |||
| 117 | + | require.True(t, ok) | |||
| 118 | + | require.Equal(t, tt.want, binding.DisplayOrigin) | |||
| 119 | + | }) | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // TestLoadRejectsInvalidLocalBinding pins every local-binding refusal at the | |||
| 124 | + | // load boundary. A binding the broker could not fence is never provisioned. | |||
| 125 | + | func TestLoadRejectsInvalidLocalBinding(t *testing.T) { | |||
| 126 | + | tests := []struct { | |||
| 127 | + | name string | |||
| 128 | + | mutate func(t *testing.T, local map[string]any) | |||
| 129 | + | wantErr string | |||
| 130 | + | }{ | |||
| 131 | + | { | |||
| 132 | + | name: "missing identity", | |||
| 133 | + | mutate: func(_ *testing.T, local map[string]any) { delete(local, "identity") }, | |||
| 134 | + | wantErr: "identity", | |||
| 135 | + | }, | |||
| 136 | + | { | |||
| 137 | + | name: "missing policy", | |||
| 138 | + | mutate: func(_ *testing.T, local map[string]any) { delete(local, "policy") }, | |||
| 139 | + | wantErr: "policy", | |||
| 140 | + | }, | |||
| 141 | + | { | |||
| 142 | + | name: "missing route", | |||
| 143 | + | mutate: func(_ *testing.T, local map[string]any) { delete(local, "route") }, | |||
| 144 | + | wantErr: "route", | |||
| 145 | + | }, | |||
| 146 | + | { | |||
| 147 | + | name: "ssh route is refused", | |||
| 148 | + | mutate: func(_ *testing.T, local map[string]any) { | |||
| 149 | + | local["route"] = map[string]any{"kind": "ssh-stdio", "target": "user@host", "argv": []string{"vev"}} | |||
| 150 | + | }, | |||
| 151 | + | wantErr: "Unix daemonmux", | |||
| 152 | + | }, | |||
| 153 | + | { | |||
| 154 | + | name: "relative route is refused", | |||
| 155 | + | mutate: func(_ *testing.T, local map[string]any) { local["route"] = "relative/mux.sock" }, | |||
| 156 | + | wantErr: "not absolute", | |||
| 157 | + | }, | |||
| 158 | + | { | |||
| 159 | + | name: "unknown field is refused", | |||
| 160 | + | mutate: func(_ *testing.T, local map[string]any) { local["extra"] = 1 }, | |||
| 161 | + | wantErr: "unknown field", | |||
| 162 | + | }, | |||
| 163 | + | } | |||
| 164 | + | for _, tt := range tests { | |||
| 165 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 166 | + | local := localTestDocument() | |||
| 167 | + | tt.mutate(t, local) | |||
| 168 | + | _, err := loadDocument(t, documentWithLocal(t, local), nil) | |||
| 169 | + | require.Error(t, err) | |||
| 170 | + | require.Contains(t, err.Error(), tt.wantErr) | |||
| 171 | + | }) | |||
| 172 | + | } | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | // TestLoadLocalRouteFencesProduction proves the local route is fenced against | |||
| 176 | + | // the reserved production runtime and state directories exactly like a remote | |||
| 177 | + | // Unix route: an isolated sandbox can never reach a production endpoint | |||
| 178 | + | // through its own local binding. | |||
| 179 | + | func TestLoadLocalRouteFencesProduction(t *testing.T) { | |||
| 180 | + | reservedDir := t.TempDir() | |||
| 181 | + | local := localTestDocument() | |||
| 182 | + | local["route"] = filepath.Join(reservedDir, "mux.sock") | |||
| 183 | + | _, err := loadDocument(t, documentWithLocal(t, local), []string{reservedDir}) | |||
| 184 | + | require.Error(t, err) | |||
| 185 | + | require.Contains(t, err.Error(), "overlaps production") | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | // TestLoadLocalRouteAliasesRegistrationRoute proves the local binding may | |||
| 189 | + | // reuse the exact Unix mux route a registration already provisions: the pool | |||
| 190 | + | // address is identical only when the route is identical. | |||
| 191 | + | func TestLoadLocalRouteAliasesRegistrationRoute(t *testing.T) { | |||
| 192 | + | local := localTestDocument() | |||
| 193 | + | local["route"] = "/tmp/vev-brokerconfig-test/mux.sock" // the fixture registration route | |||
| 194 | + | config, err := loadDocument(t, documentWithLocal(t, local), nil) | |||
| 195 | + | require.NoError(t, err) | |||
| 196 | + | binding, ok := config.LocalBinding() | |||
| 197 | + | require.True(t, ok) | |||
| 198 | + | require.Equal(t, "/tmp/vev-brokerconfig-test/mux.sock", binding.Route.Path()) | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | // TestLoadRefusesLocalIdentityPoolConflict proves the pool's (identity, | |||
| 202 | + | // policy) keying is honored across the local and remote provisionings: a local | |||
| 203 | + | // binding that shares a registration's exact identity and policy but names a | |||
| 204 | + | // different route is refused rather than silently dialed for one and ignored | |||
| 205 | + | // for the other. | |||
| 206 | + | func TestLoadRefusesLocalIdentityPoolConflict(t *testing.T) { | |||
| 207 | + | local := localTestDocument() | |||
| 208 | + | local["identity"] = testRegistrationIdentity() | |||
| 209 | + | local["route"] = "/tmp/vev-brokerconfig-test/other-mux.sock" | |||
| 210 | + | _, err := loadDocument(t, documentWithLocal(t, local), nil) | |||
| 211 | + | require.Error(t, err) | |||
| 212 | + | require.Contains(t, err.Error(), "already provisioned on a different route") | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | // testRegistrationIdentity reads the identity the fixture registration | |||
| 216 | + | // provisions, so the collision test is anchored to the real fixture. | |||
| 217 | + | func testRegistrationIdentity() string { return "offline-daemon" } | |||
| 218 | + | ||||
| 219 | + | // TestResolveLocalSuccessAndRefusals pins the local resolution contract: | |||
| 220 | + | // success returns the configured authority and address, a configuration with | |||
| 221 | + | // no local binding refuses as unavailable, a conflicting policy is refused as | |||
| 222 | + | // such, and a cancelled context is reported as cancellation. Every refusal is | |||
| 223 | + | // typed as a ports.BrokerError so the pool classifies it without text matching. | |||
| 224 | + | func TestResolveLocalSuccessAndRefusals(t *testing.T) { | |||
| 225 | + | config, err := loadDocument(t, documentWithLocal(t, localTestDocument()), nil) | |||
| 226 | + | require.NoError(t, err) | |||
| 227 | + | binding, ok := config.LocalBinding() | |||
| 228 | + | require.True(t, ok) | |||
| 229 | + | resolver := config.Resolver() | |||
| 230 | + | ||||
| 231 | + | noLocalConfig, err := loadDocument(t, registrationDocument(nil), nil) | |||
| 232 | + | require.NoError(t, err) | |||
| 233 | + | ||||
| 234 | + | tests := []struct { | |||
| 235 | + | name string | |||
| 236 | + | resolver *Resolver | |||
| 237 | + | request ports.BrokerOpenStreamRequest | |||
| 238 | + | wantCode ports.BrokerErrorCode | |||
| 239 | + | wantErr error | |||
| 240 | + | }{ | |||
| 241 | + | { | |||
| 242 | + | name: "success returns configured authority", | |||
| 243 | + | resolver: resolver, | |||
| 244 | + | request: localRequest(), | |||
| 245 | + | }, | |||
| 246 | + | { | |||
| 247 | + | name: "no local binding is unavailable", | |||
| 248 | + | resolver: noLocalConfig.Resolver(), | |||
| 249 | + | request: localRequest(), | |||
| 250 | + | wantCode: ports.BrokerErrorUnavailable, | |||
| 251 | + | wantErr: errNoLocalRoute, | |||
| 252 | + | }, | |||
| 253 | + | { | |||
| 254 | + | name: "conflicting policy is refused", | |||
| 255 | + | resolver: resolver, | |||
| 256 | + | request: func() ports.BrokerOpenStreamRequest { | |||
| 257 | + | request := localRequest() | |||
| 258 | + | request.Policy.Transport = "other-transport" | |||
| 259 | + | return request | |||
| 260 | + | }(), | |||
| 261 | + | wantCode: ports.BrokerErrorConflictingPolicy, | |||
| 262 | + | wantErr: errLocalPolicyConflict, | |||
| 263 | + | }, | |||
| 264 | + | { | |||
| 265 | + | name: "cancelled context is cancellation", | |||
| 266 | + | resolver: resolver, | |||
| 267 | + | request: localRequest(), | |||
| 268 | + | wantErr: context.Canceled, | |||
| 269 | + | }, | |||
| 270 | + | } | |||
| 271 | + | for _, tt := range tests { | |||
| 272 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 273 | + | ctx := context.Background() | |||
| 274 | + | if errors.Is(tt.wantErr, context.Canceled) { | |||
| 275 | + | cancelled, cancel := context.WithCancel(ctx) | |||
| 276 | + | cancel() | |||
| 277 | + | ctx = cancelled | |||
| 278 | + | } | |||
| 279 | + | resolved, err := tt.resolver.Resolve(ctx, tt.request) | |||
| 280 | + | if tt.wantErr == nil { | |||
| 281 | + | require.NoError(t, err) | |||
| 282 | + | require.Equal(t, binding.Identity, resolved.Identity) | |||
| 283 | + | require.Equal(t, binding.Policy, resolved.Policy) | |||
| 284 | + | require.Equal(t, binding.Route.Address(), resolved.Address) | |||
| 285 | + | return | |||
| 286 | + | } | |||
| 287 | + | require.Error(t, err) | |||
| 288 | + | if tt.wantCode != 0 { | |||
| 289 | + | var typed ports.BrokerError | |||
| 290 | + | require.ErrorAs(t, err, &typed) | |||
| 291 | + | require.Equal(t, tt.wantCode, typed.Code) | |||
| 292 | + | } | |||
| 293 | + | require.ErrorIs(t, err, tt.wantErr) | |||
| 294 | + | }) | |||
| 295 | + | } | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | // TestLocalMuxRouteExcludesLocalBinding proves Config.LocalMuxRoute serves the | |||
| 299 | + | // remote-side mux helper's route only. The broker-owned local binding's own | |||
| 300 | + | // carriage is deliberately excluded: no helper ever bridges it, so a | |||
| 301 | + | // configuration provisioning only the local object has no helper route, and one | |||
| 302 | + | // provisioning both returns the registration's route, never the local binding's. | |||
| 303 | + | func TestLocalMuxRouteExcludesLocalBinding(t *testing.T) { | |||
| 304 | + | t.Run("local-only configuration provisions no helper route", func(t *testing.T) { | |||
| 305 | + | document := map[string]any{ | |||
| 306 | + | "marker": Marker, | |||
| 307 | + | "registrations": []any{}, | |||
| 308 | + | "local": localTestDocument(), | |||
| 309 | + | } | |||
| 310 | + | config, err := loadDocument(t, document, nil) | |||
| 311 | + | require.NoError(t, err) | |||
| 312 | + | _, ok := config.LocalBinding() | |||
| 313 | + | require.True(t, ok, "the fixture provisions a broker-owned local binding") | |||
| 314 | + | _, err = config.LocalMuxRoute() | |||
| 315 | + | require.Error(t, err) | |||
| 316 | + | require.Contains(t, err.Error(), "no Unix daemon-mux route is provisioned") | |||
| 317 | + | }) | |||
| 318 | + | ||||
| 319 | + | t.Run("registration route wins over the local binding route", func(t *testing.T) { | |||
| 320 | + | config, err := loadDocument(t, documentWithLocal(t, localTestDocument()), nil) | |||
| 321 | + | require.NoError(t, err) | |||
| 322 | + | binding, ok := config.LocalBinding() | |||
| 323 | + | require.True(t, ok) | |||
| 324 | + | ||||
| 325 | + | route, err := config.LocalMuxRoute() | |||
| 326 | + | require.NoError(t, err) | |||
| 327 | + | require.True(t, route.IsLocal()) | |||
| 328 | + | require.Equal(t, "/tmp/vev-brokerconfig-test/mux.sock", route.Path(), "the helper bridges the registration route") | |||
| 329 | + | require.NotEqual(t, binding.Route.Path(), route.Path(), "the local binding route is not the helper route") | |||
| 330 | + | require.NotEqual(t, binding.Route.Address(), route.Address(), "the two routes carry distinct pool addresses") | |||
| 331 | + | }) | |||
| 332 | + | } | |||
M internal/adapters/brokerconfig/resolver.go +41 -8
| @@ -12,27 +12,40 @@ import ( | |||
| 12 | 12 | // Resolve call fences the request against the provisioned registration and | |
| 13 | 13 | // returns the configured identity, policy, and opaque route address. A request | |
| 14 | 14 | // can therefore never select an address, identity, or policy of its own. | |
| 15 | + | // | |
| 16 | + | // A remote request is fenced against its provisioned registration (endpoint, | |
| 17 | + | // incarnation, and generation) and policy. A local request carries no endpoint | |
| 18 | + | // and no registration, so it is fenced against the broker-owned local binding: | |
| 19 | + | // the configured local identity, policy, and Unix daemonmux route. Both paths | |
| 20 | + | // return authority chosen by the configuration alone, and neither can widen, | |
| 21 | + | // narrow, or replace the provisioned identity or policy. | |
| 15 | 22 | type Resolver struct { | |
| 16 | 23 | byEndpoint map[string]Registration | |
| 24 | + | local *LocalBinding | |
| 17 | 25 | } | |
| 18 | 26 | ||
| 19 | 27 | var _ ports.BrokerEndpointResolver = (*Resolver)(nil) | |
| 20 | 28 | ||
| 21 | 29 | // Resolve fences one open-stream request against the immutable configuration. | |
| 22 | 30 | // | |
| 23 | - | // The request must name a configured endpoint, carry that endpoint's exact | |
| 24 | - | // registration (endpoint, incarnation, and generation), and request a policy | |
| 25 | - | // compatible with the provisioned policy. A local request is refused because | |
| 26 | - | // the offline sandbox provisions remote routes only. Unknown, stale, | |
| 27 | - | // or conflicting requests are refused with a typed broker error and no dial is | |
| 28 | - | // attempted; a cancelled context is reported as such so the pool classifies it | |
| 29 | - | // as cancellation rather than unavailability. | |
| 31 | + | // A remote request must name a configured endpoint, carry that endpoint's | |
| 32 | + | // exact registration (endpoint, incarnation, and generation), and request a | |
| 33 | + | // policy compatible with the provisioned policy. A local request must instead | |
| 34 | + | // carry an empty endpoint and zero registration (ports enforces that) and is | |
| 35 | + | // fenced against the provisioned local binding: without one it is refused, and | |
| 36 | + | // with one its requested policy must be exactly compatible with the | |
| 37 | + | // provisioned local policy. Either way the resolved identity, policy, and | |
| 38 | + | // opaque route address come from the configuration, never from the request. | |
| 39 | + | // | |
| 40 | + | // Unknown, stale, conflicting, or unprovisioned requests are refused with a | |
| 41 | + | // typed broker error and no dial is attempted; a cancelled context is reported | |
| 42 | + | // as such so the pool classifies it as cancellation rather than unavailability. | |
| 30 | 43 | func (r *Resolver) Resolve(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) { | |
| 31 | 44 | if err := ctx.Err(); err != nil { | |
| 32 | 45 | return ports.BrokerResolvedEndpoint{}, err | |
| 33 | 46 | } | |
| 34 | 47 | if request.Local { | |
| 35 | - | return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Cause: errNoLocalRoute} | |
| 48 | + | return r.resolveLocal(request) | |
| 36 | 49 | } | |
| 37 | 50 | provisioned, ok := r.byEndpoint[request.Endpoint] | |
| 38 | 51 | if !ok { | |
| @@ -50,3 +63,23 @@ func (r *Resolver) Resolve(ctx context.Context, request ports.BrokerOpenStreamRe | |||
| 50 | 63 | } | |
| 51 | 64 | return resolved, nil | |
| 52 | 65 | } | |
| 66 | + | ||
| 67 | + | // resolveLocal fences one local request against the broker-owned local | |
| 68 | + | // binding. A configuration that provisions no local route refuses the request | |
| 69 | + | // as unavailable; a request whose policy is not exactly compatible with the | |
| 70 | + | // provisioned local policy is refused as a conflicting policy. The resolved | |
| 71 | + | // identity, policy, and address are the binding's own, so the request supplies | |
| 72 | + | // none of them. | |
| 73 | + | func (r *Resolver) resolveLocal(request ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) { | |
| 74 | + | if r.local == nil { | |
| 75 | + | return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Cause: errNoLocalRoute} | |
| 76 | + | } | |
| 77 | + | if !r.local.Policy.Compatible(request.Policy) { | |
| 78 | + | return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errLocalPolicyConflict} | |
| 79 | + | } | |
| 80 | + | resolved := ports.BrokerResolvedEndpoint{Identity: r.local.Identity, Policy: r.local.Policy, Address: r.local.Route.Address()} | |
| 81 | + | if err := resolved.Validate(); err != nil { | |
| 82 | + | return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: err} | |
| 83 | + | } | |
| 84 | + | return resolved, nil | |
| 85 | + | } | |
| @@ -12,27 +12,40 @@ import ( | |||||
| 12 | // Resolve call fences the request against the provisioned registration and | 12 | // Resolve call fences the request against the provisioned registration and | ||
| 13 | // returns the configured identity, policy, and opaque route address. A request | 13 | // returns the configured identity, policy, and opaque route address. A request | ||
| 14 | // can therefore never select an address, identity, or policy of its own. | 14 | // can therefore never select an address, identity, or policy of its own. | ||
| 15 | + | // | |||
| 16 | + | // A remote request is fenced against its provisioned registration (endpoint, | |||
| 17 | + | // incarnation, and generation) and policy. A local request carries no endpoint | |||
| 18 | + | // and no registration, so it is fenced against the broker-owned local binding: | |||
| 19 | + | // the configured local identity, policy, and Unix daemonmux route. Both paths | |||
| 20 | + | // return authority chosen by the configuration alone, and neither can widen, | |||
| 21 | + | // narrow, or replace the provisioned identity or policy. | |||
| 15 | type Resolver struct { | 22 | type Resolver struct { | ||
| 16 | byEndpoint map[string]Registration | 23 | byEndpoint map[string]Registration | ||
| 24 | + | local *LocalBinding | |||
| 17 | } | 25 | } | ||
| 18 | 26 | ||||
| 19 | var _ ports.BrokerEndpointResolver = (*Resolver)(nil) | 27 | var _ ports.BrokerEndpointResolver = (*Resolver)(nil) | ||
| 20 | 28 | ||||
| 21 | // Resolve fences one open-stream request against the immutable configuration. | 29 | // Resolve fences one open-stream request against the immutable configuration. | ||
| 22 | // | 30 | // | ||
| 23 | - | // The request must name a configured endpoint, carry that endpoint's exact | 31 | + | // A remote request must name a configured endpoint, carry that endpoint's |
| 24 | - | // registration (endpoint, incarnation, and generation), and request a policy | 32 | + | // exact registration (endpoint, incarnation, and generation), and request a |
| 25 | - | // compatible with the provisioned policy. A local request is refused because | 33 | + | // policy compatible with the provisioned policy. A local request must instead |
| 26 | - | // the offline sandbox provisions remote routes only. Unknown, stale, | 34 | + | // carry an empty endpoint and zero registration (ports enforces that) and is |
| 27 | - | // or conflicting requests are refused with a typed broker error and no dial is | 35 | + | // fenced against the provisioned local binding: without one it is refused, and |
| 28 | - | // attempted; a cancelled context is reported as such so the pool classifies it | 36 | + | // with one its requested policy must be exactly compatible with the |
| 29 | - | // as cancellation rather than unavailability. | 37 | + | // provisioned local policy. Either way the resolved identity, policy, and |
| 38 | + | // opaque route address come from the configuration, never from the request. | |||
| 39 | + | // | |||
| 40 | + | // Unknown, stale, conflicting, or unprovisioned requests are refused with a | |||
| 41 | + | // typed broker error and no dial is attempted; a cancelled context is reported | |||
| 42 | + | // as such so the pool classifies it as cancellation rather than unavailability. | |||
| 30 | func (r *Resolver) Resolve(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) { | 43 | func (r *Resolver) Resolve(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) { | ||
| 31 | if err := ctx.Err(); err != nil { | 44 | if err := ctx.Err(); err != nil { | ||
| 32 | return ports.BrokerResolvedEndpoint{}, err | 45 | return ports.BrokerResolvedEndpoint{}, err | ||
| 33 | } | 46 | } | ||
| 34 | if request.Local { | 47 | if request.Local { | ||
| 35 | - | return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Cause: errNoLocalRoute} | 48 | + | return r.resolveLocal(request) |
| 36 | } | 49 | } | ||
| 37 | provisioned, ok := r.byEndpoint[request.Endpoint] | 50 | provisioned, ok := r.byEndpoint[request.Endpoint] | ||
| 38 | if !ok { | 51 | if !ok { | ||
| @@ -50,3 +63,23 @@ func (r *Resolver) Resolve(ctx context.Context, request ports.BrokerOpenStreamRe | |||||
| 50 | } | 63 | } | ||
| 51 | return resolved, nil | 64 | return resolved, nil | ||
| 52 | } | 65 | } | ||
| 66 | + | ||||
| 67 | + | // resolveLocal fences one local request against the broker-owned local | |||
| 68 | + | // binding. A configuration that provisions no local route refuses the request | |||
| 69 | + | // as unavailable; a request whose policy is not exactly compatible with the | |||
| 70 | + | // provisioned local policy is refused as a conflicting policy. The resolved | |||
| 71 | + | // identity, policy, and address are the binding's own, so the request supplies | |||
| 72 | + | // none of them. | |||
| 73 | + | func (r *Resolver) resolveLocal(request ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) { | |||
| 74 | + | if r.local == nil { | |||
| 75 | + | return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Cause: errNoLocalRoute} | |||
| 76 | + | } | |||
| 77 | + | if !r.local.Policy.Compatible(request.Policy) { | |||
| 78 | + | return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errLocalPolicyConflict} | |||
| 79 | + | } | |||
| 80 | + | resolved := ports.BrokerResolvedEndpoint{Identity: r.local.Identity, Policy: r.local.Policy, Address: r.local.Route.Address()} | |||
| 81 | + | if err := resolved.Validate(); err != nil { | |||
| 82 | + | return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: err} | |||
| 83 | + | } | |||
| 84 | + | return resolved, nil | |||
| 85 | + | } | |||
M internal/adapters/brokerconfig/route.go +6 -3
| @@ -93,8 +93,10 @@ type Route struct { | |||
| 93 | 93 | // is a digest of the route contents, so it leaks no route detail and can | |
| 94 | 94 | // never collide with a distinct route. | |
| 95 | 95 | address string | |
| 96 | - | // local is true for a Unix route that this process can dial directly, which | |
| 97 | - | // is the route a remote-side mux helper bridges to. | |
| 96 | + | // local is true for a Unix route that this process can dial directly. Such a | |
| 97 | + | // route is the carriage a remote-side mux helper bridges to when a | |
| 98 | + | // registration provisions it; a local binding's own carriage is dialled by | |
| 99 | + | // the broker itself and is never a helper route. | |
| 98 | 100 | local bool | |
| 99 | 101 | } | |
| 100 | 102 | ||
| @@ -121,7 +123,8 @@ func (r Route) ConnectTimeout() time.Duration { return r.trust.connectTimeout } | |||
| 121 | 123 | func (r Route) Address() string { return r.address } | |
| 122 | 124 | ||
| 123 | 125 | // IsLocal reports whether the route dials a Unix socket this process can reach | |
| 124 | - | // directly, which is the route a remote-side mux helper bridges to. | |
| 126 | + | // directly. A local binding's carriage and a registration's Unix carriage are | |
| 127 | + | // both local; only the latter is a route a remote-side mux helper bridges to. | |
| 125 | 128 | func (r Route) IsLocal() bool { return r.local } | |
| 126 | 129 | ||
| 127 | 130 | // Validate re-checks one already-parsed route. Parsed routes are validated | |
| @@ -93,8 +93,10 @@ type Route struct { | |||||
| 93 | // is a digest of the route contents, so it leaks no route detail and can | 93 | // is a digest of the route contents, so it leaks no route detail and can | ||
| 94 | // never collide with a distinct route. | 94 | // never collide with a distinct route. | ||
| 95 | address string | 95 | address string | ||
| 96 | - | // local is true for a Unix route that this process can dial directly, which | 96 | + | // local is true for a Unix route that this process can dial directly. Such a |
| 97 | - | // is the route a remote-side mux helper bridges to. | 97 | + | // route is the carriage a remote-side mux helper bridges to when a |
| 98 | + | // registration provisions it; a local binding's own carriage is dialled by | |||
| 99 | + | // the broker itself and is never a helper route. | |||
| 98 | local bool | 100 | local bool | ||
| 99 | } | 101 | } | ||
| 100 | 102 | ||||
| @@ -121,7 +123,8 @@ func (r Route) ConnectTimeout() time.Duration { return r.trust.connectTimeout } | |||||
| 121 | func (r Route) Address() string { return r.address } | 123 | func (r Route) Address() string { return r.address } | ||
| 122 | 124 | ||||
| 123 | // IsLocal reports whether the route dials a Unix socket this process can reach | 125 | // IsLocal reports whether the route dials a Unix socket this process can reach | ||
| 124 | - | // directly, which is the route a remote-side mux helper bridges to. | 126 | + | // directly. A local binding's carriage and a registration's Unix carriage are |
| 127 | + | // both local; only the latter is a route a remote-side mux helper bridges to. | |||
| 125 | func (r Route) IsLocal() bool { return r.local } | 128 | func (r Route) IsLocal() bool { return r.local } | ||
| 126 | 129 | ||||
| 127 | // Validate re-checks one already-parsed route. Parsed routes are validated | 130 | // Validate re-checks one already-parsed route. Parsed routes are validated | ||
A internal/app/broker_offline_local.go +153 -0
| @@ -0,0 +1,153 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "log/slog" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/adapters/brokerconfig" | |
| 10 | + | "github.com/bnema/vev/internal/adapters/daemonmux" | |
| 11 | + | "github.com/bnema/vev/internal/domain" | |
| 12 | + | "github.com/bnema/vev/internal/ports" | |
| 13 | + | "github.com/bnema/vev/internal/usecase/broker" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // Broker-owned local observation composition (Plan 001 P5.3a). | |
| 17 | + | // | |
| 18 | + | // The offline broker observes its own machine daemon through the same daemonmux | |
| 19 | + | // carriage a local logical stream is dialed over. The probe completes only the | |
| 20 | + | // physical preamble: it dials the provisioned local Unix route, negotiates | |
| 21 | + | // identity, incarnation, and policy, and closes the carriage. It deliberately | |
| 22 | + | // opens no logical stream, so observing the local daemon never attaches to a | |
| 23 | + | // session, and it only ever dials an existing local Unix socket, so it never | |
| 24 | + | // starts a stopped daemon. The composition is isolated from production state: | |
| 25 | + | // it derives the probe from the sandbox configuration and never touches the | |
| 26 | + | // production runtime or state directories. | |
| 27 | + | // | |
| 28 | + | // This is a preparation slice: nothing here is activated by production | |
| 29 | + | // composition, and the observation-disabled offline authority composition is | |
| 30 | + | // unchanged. | |
| 31 | + | ||
| 32 | + | // localCarrierDialer establishes one raw framed daemonmux carriage for the | |
| 33 | + | // opaque route address the broker-owned local binding published. It is the same | |
| 34 | + | // seam the pooled mux connector uses, so the probe can never select a transport | |
| 35 | + | // or an address of its own. | |
| 36 | + | type localCarrierDialer func(ctx context.Context, address string) (daemonmux.RawFramedTransport, error) | |
| 37 | + | ||
| 38 | + | // localRouteProbe is the broker-owned ports-independent local observation | |
| 39 | + | // probe. It holds the resolved local endpoint authority (identity, policy, and | |
| 40 | + | // opaque address) and dials it exactly once per observation, then closes the | |
| 41 | + | // carriage: no logical stream is ever opened and no daemon process is ever | |
| 42 | + | // started. | |
| 43 | + | type localRouteProbe struct { | |
| 44 | + | endpoint ports.BrokerResolvedEndpoint | |
| 45 | + | dial localCarrierDialer | |
| 46 | + | ceilings daemonmux.MuxCeilings | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | var _ broker.LocalProbe = (*localRouteProbe)(nil) | |
| 50 | + | ||
| 51 | + | // newLocalRouteProbe fences one broker-owned local binding into a probe. The | |
| 52 | + | // binding's identity, policy, and route address must validate, the ceilings | |
| 53 | + | // must be a valid advertisement, and a nil dial is refused: a probe that cannot | |
| 54 | + | // establish a carriage must not exist. | |
| 55 | + | func newLocalRouteProbe(binding brokerconfig.LocalBinding, dial localCarrierDialer, ceilings daemonmux.MuxCeilings) (*localRouteProbe, error) { | |
| 56 | + | if dial == nil { | |
| 57 | + | return nil, errors.New("vev: broker local probe requires a dialer") | |
| 58 | + | } | |
| 59 | + | // Probing must never spawn a process, so only a Unix route this process | |
| 60 | + | // dials directly is acceptable. brokerconfig refuses any other kind at load | |
| 61 | + | // time; this re-checks the exported binding a caller may have built itself, | |
| 62 | + | // because the dial seam routes an ssh address to a child process. | |
| 63 | + | if !binding.Route.IsLocal() { | |
| 64 | + | return nil, errors.New("vev: broker local probe requires a local route") | |
| 65 | + | } | |
| 66 | + | if err := ceilings.Validate(); err != nil { | |
| 67 | + | return nil, fmt.Errorf("vev: broker local probe: %w", err) | |
| 68 | + | } | |
| 69 | + | endpoint := ports.BrokerResolvedEndpoint{Identity: binding.Identity, Policy: binding.Policy, Address: binding.Route.Address()} | |
| 70 | + | if err := endpoint.Validate(); err != nil { | |
| 71 | + | return nil, fmt.Errorf("vev: broker local probe: %w", err) | |
| 72 | + | } | |
| 73 | + | return &localRouteProbe{endpoint: endpoint, dial: dial, ceilings: ceilings}, nil | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // ProbeLocal observes the local daemon by completing the daemonmux physical | |
| 77 | + | // preamble and closing the carriage. A successful preamble reports the only | |
| 78 | + | // observed state the carriage exposes: the authenticated identity, the daemon | |
| 79 | + | // incarnation, the protocol version the daemon's accepted policy negotiated, | |
| 80 | + | // and reachability. Capabilities and the session catalogue are not carried by | |
| 81 | + | // the physical preamble, so they stay unknown (zero) rather than being | |
| 82 | + | // invented, and every failure reports an explicit availability with zero | |
| 83 | + | // observed identity: a probe never manufactures identity, version, or | |
| 84 | + | // inventory it did not observe. | |
| 85 | + | func (p *localRouteProbe) ProbeLocal(ctx context.Context) (ports.BrokerDaemonObservation, error) { | |
| 86 | + | if p == nil { | |
| 87 | + | return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, errors.New("vev: broker local probe is nil") | |
| 88 | + | } | |
| 89 | + | raw, err := p.dial(ctx, p.endpoint.Address) | |
| 90 | + | if err != nil { | |
| 91 | + | if raw != nil { | |
| 92 | + | _ = raw.Close() | |
| 93 | + | } | |
| 94 | + | return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err | |
| 95 | + | } | |
| 96 | + | if raw == nil { | |
| 97 | + | return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, errors.New("vev: broker local probe: nil carriage") | |
| 98 | + | } | |
| 99 | + | bridge, err := daemonmux.NewPreambleCarrier(raw) | |
| 100 | + | if err != nil { | |
| 101 | + | _ = raw.Close() | |
| 102 | + | return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err | |
| 103 | + | } | |
| 104 | + | // RunClientHandshake closes the carriage on every failure; on success the | |
| 105 | + | // probe closes it here, because it has nothing further to send. The | |
| 106 | + | // handshake never opens a logical stream. | |
| 107 | + | result, err := daemonmux.RunClientHandshake(ctx, bridge, p.endpoint, p.ceilings) | |
| 108 | + | _ = bridge.Close() | |
| 109 | + | if err != nil { | |
| 110 | + | return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err | |
| 111 | + | } | |
| 112 | + | return ports.BrokerDaemonObservation{ | |
| 113 | + | Identity: result.Identity, | |
| 114 | + | Incarnation: result.Incarnation, | |
| 115 | + | ProtocolVersion: result.Policy.ProtocolVersion, | |
| 116 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 117 | + | }, nil | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // brokerLocalProbe builds the broker-owned local probe over one validated | |
| 121 | + | // offline configuration. The dialer looks the opaque address up in the same | |
| 122 | + | // immutable configuration the pooled connector uses, so a probe can never reach | |
| 123 | + | // an address the configuration did not provision. | |
| 124 | + | func brokerLocalProbe(config *brokerconfig.Config, log *slog.Logger) (*localRouteProbe, error) { | |
| 125 | + | binding, ok := config.LocalBinding() | |
| 126 | + | if !ok { | |
| 127 | + | return nil, errors.New("vev: broker local probe requires a provisioned local binding") | |
| 128 | + | } | |
| 129 | + | dial := func(ctx context.Context, address string) (daemonmux.RawFramedTransport, error) { | |
| 130 | + | route, ok := config.RouteByAddress(address) | |
| 131 | + | if !ok { | |
| 132 | + | return nil, errors.New("vev: broker local probe: unknown route address") | |
| 133 | + | } | |
| 134 | + | return dialBrokerRoute(ctx, route, log) | |
| 135 | + | } | |
| 136 | + | return newLocalRouteProbe(binding, dial, daemonmux.DefaultMuxCeilings()) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // brokerLocalObservation composes the registry's local producer from one | |
| 140 | + | // validated offline configuration: the configured display origin and policy | |
| 141 | + | // become the local entry's authority, and the probe supplies observed state | |
| 142 | + | // only. A configuration without a local binding provisions no local producer. | |
| 143 | + | func brokerLocalObservation(config *brokerconfig.Config, log *slog.Logger) (*broker.LocalObservation, error) { | |
| 144 | + | binding, ok := config.LocalBinding() | |
| 145 | + | if !ok { | |
| 146 | + | return nil, errors.New("vev: broker local observation requires a provisioned local binding") | |
| 147 | + | } | |
| 148 | + | probe, err := brokerLocalProbe(config, log) | |
| 149 | + | if err != nil { | |
| 150 | + | return nil, err | |
| 151 | + | } | |
| 152 | + | return &broker.LocalObservation{DisplayOrigin: binding.DisplayOrigin, Policy: binding.Policy, Probe: probe}, nil | |
| 153 | + | } | |
| @@ -0,0 +1,153 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "log/slog" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/adapters/brokerconfig" | |||
| 10 | + | "github.com/bnema/vev/internal/adapters/daemonmux" | |||
| 11 | + | "github.com/bnema/vev/internal/domain" | |||
| 12 | + | "github.com/bnema/vev/internal/ports" | |||
| 13 | + | "github.com/bnema/vev/internal/usecase/broker" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // Broker-owned local observation composition (Plan 001 P5.3a). | |||
| 17 | + | // | |||
| 18 | + | // The offline broker observes its own machine daemon through the same daemonmux | |||
| 19 | + | // carriage a local logical stream is dialed over. The probe completes only the | |||
| 20 | + | // physical preamble: it dials the provisioned local Unix route, negotiates | |||
| 21 | + | // identity, incarnation, and policy, and closes the carriage. It deliberately | |||
| 22 | + | // opens no logical stream, so observing the local daemon never attaches to a | |||
| 23 | + | // session, and it only ever dials an existing local Unix socket, so it never | |||
| 24 | + | // starts a stopped daemon. The composition is isolated from production state: | |||
| 25 | + | // it derives the probe from the sandbox configuration and never touches the | |||
| 26 | + | // production runtime or state directories. | |||
| 27 | + | // | |||
| 28 | + | // This is a preparation slice: nothing here is activated by production | |||
| 29 | + | // composition, and the observation-disabled offline authority composition is | |||
| 30 | + | // unchanged. | |||
| 31 | + | ||||
| 32 | + | // localCarrierDialer establishes one raw framed daemonmux carriage for the | |||
| 33 | + | // opaque route address the broker-owned local binding published. It is the same | |||
| 34 | + | // seam the pooled mux connector uses, so the probe can never select a transport | |||
| 35 | + | // or an address of its own. | |||
| 36 | + | type localCarrierDialer func(ctx context.Context, address string) (daemonmux.RawFramedTransport, error) | |||
| 37 | + | ||||
| 38 | + | // localRouteProbe is the broker-owned ports-independent local observation | |||
| 39 | + | // probe. It holds the resolved local endpoint authority (identity, policy, and | |||
| 40 | + | // opaque address) and dials it exactly once per observation, then closes the | |||
| 41 | + | // carriage: no logical stream is ever opened and no daemon process is ever | |||
| 42 | + | // started. | |||
| 43 | + | type localRouteProbe struct { | |||
| 44 | + | endpoint ports.BrokerResolvedEndpoint | |||
| 45 | + | dial localCarrierDialer | |||
| 46 | + | ceilings daemonmux.MuxCeilings | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | var _ broker.LocalProbe = (*localRouteProbe)(nil) | |||
| 50 | + | ||||
| 51 | + | // newLocalRouteProbe fences one broker-owned local binding into a probe. The | |||
| 52 | + | // binding's identity, policy, and route address must validate, the ceilings | |||
| 53 | + | // must be a valid advertisement, and a nil dial is refused: a probe that cannot | |||
| 54 | + | // establish a carriage must not exist. | |||
| 55 | + | func newLocalRouteProbe(binding brokerconfig.LocalBinding, dial localCarrierDialer, ceilings daemonmux.MuxCeilings) (*localRouteProbe, error) { | |||
| 56 | + | if dial == nil { | |||
| 57 | + | return nil, errors.New("vev: broker local probe requires a dialer") | |||
| 58 | + | } | |||
| 59 | + | // Probing must never spawn a process, so only a Unix route this process | |||
| 60 | + | // dials directly is acceptable. brokerconfig refuses any other kind at load | |||
| 61 | + | // time; this re-checks the exported binding a caller may have built itself, | |||
| 62 | + | // because the dial seam routes an ssh address to a child process. | |||
| 63 | + | if !binding.Route.IsLocal() { | |||
| 64 | + | return nil, errors.New("vev: broker local probe requires a local route") | |||
| 65 | + | } | |||
| 66 | + | if err := ceilings.Validate(); err != nil { | |||
| 67 | + | return nil, fmt.Errorf("vev: broker local probe: %w", err) | |||
| 68 | + | } | |||
| 69 | + | endpoint := ports.BrokerResolvedEndpoint{Identity: binding.Identity, Policy: binding.Policy, Address: binding.Route.Address()} | |||
| 70 | + | if err := endpoint.Validate(); err != nil { | |||
| 71 | + | return nil, fmt.Errorf("vev: broker local probe: %w", err) | |||
| 72 | + | } | |||
| 73 | + | return &localRouteProbe{endpoint: endpoint, dial: dial, ceilings: ceilings}, nil | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // ProbeLocal observes the local daemon by completing the daemonmux physical | |||
| 77 | + | // preamble and closing the carriage. A successful preamble reports the only | |||
| 78 | + | // observed state the carriage exposes: the authenticated identity, the daemon | |||
| 79 | + | // incarnation, the protocol version the daemon's accepted policy negotiated, | |||
| 80 | + | // and reachability. Capabilities and the session catalogue are not carried by | |||
| 81 | + | // the physical preamble, so they stay unknown (zero) rather than being | |||
| 82 | + | // invented, and every failure reports an explicit availability with zero | |||
| 83 | + | // observed identity: a probe never manufactures identity, version, or | |||
| 84 | + | // inventory it did not observe. | |||
| 85 | + | func (p *localRouteProbe) ProbeLocal(ctx context.Context) (ports.BrokerDaemonObservation, error) { | |||
| 86 | + | if p == nil { | |||
| 87 | + | return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, errors.New("vev: broker local probe is nil") | |||
| 88 | + | } | |||
| 89 | + | raw, err := p.dial(ctx, p.endpoint.Address) | |||
| 90 | + | if err != nil { | |||
| 91 | + | if raw != nil { | |||
| 92 | + | _ = raw.Close() | |||
| 93 | + | } | |||
| 94 | + | return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err | |||
| 95 | + | } | |||
| 96 | + | if raw == nil { | |||
| 97 | + | return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, errors.New("vev: broker local probe: nil carriage") | |||
| 98 | + | } | |||
| 99 | + | bridge, err := daemonmux.NewPreambleCarrier(raw) | |||
| 100 | + | if err != nil { | |||
| 101 | + | _ = raw.Close() | |||
| 102 | + | return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err | |||
| 103 | + | } | |||
| 104 | + | // RunClientHandshake closes the carriage on every failure; on success the | |||
| 105 | + | // probe closes it here, because it has nothing further to send. The | |||
| 106 | + | // handshake never opens a logical stream. | |||
| 107 | + | result, err := daemonmux.RunClientHandshake(ctx, bridge, p.endpoint, p.ceilings) | |||
| 108 | + | _ = bridge.Close() | |||
| 109 | + | if err != nil { | |||
| 110 | + | return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err | |||
| 111 | + | } | |||
| 112 | + | return ports.BrokerDaemonObservation{ | |||
| 113 | + | Identity: result.Identity, | |||
| 114 | + | Incarnation: result.Incarnation, | |||
| 115 | + | ProtocolVersion: result.Policy.ProtocolVersion, | |||
| 116 | + | Availability: domain.RemoteAvailabilityReachable, | |||
| 117 | + | }, nil | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | // brokerLocalProbe builds the broker-owned local probe over one validated | |||
| 121 | + | // offline configuration. The dialer looks the opaque address up in the same | |||
| 122 | + | // immutable configuration the pooled connector uses, so a probe can never reach | |||
| 123 | + | // an address the configuration did not provision. | |||
| 124 | + | func brokerLocalProbe(config *brokerconfig.Config, log *slog.Logger) (*localRouteProbe, error) { | |||
| 125 | + | binding, ok := config.LocalBinding() | |||
| 126 | + | if !ok { | |||
| 127 | + | return nil, errors.New("vev: broker local probe requires a provisioned local binding") | |||
| 128 | + | } | |||
| 129 | + | dial := func(ctx context.Context, address string) (daemonmux.RawFramedTransport, error) { | |||
| 130 | + | route, ok := config.RouteByAddress(address) | |||
| 131 | + | if !ok { | |||
| 132 | + | return nil, errors.New("vev: broker local probe: unknown route address") | |||
| 133 | + | } | |||
| 134 | + | return dialBrokerRoute(ctx, route, log) | |||
| 135 | + | } | |||
| 136 | + | return newLocalRouteProbe(binding, dial, daemonmux.DefaultMuxCeilings()) | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // brokerLocalObservation composes the registry's local producer from one | |||
| 140 | + | // validated offline configuration: the configured display origin and policy | |||
| 141 | + | // become the local entry's authority, and the probe supplies observed state | |||
| 142 | + | // only. A configuration without a local binding provisions no local producer. | |||
| 143 | + | func brokerLocalObservation(config *brokerconfig.Config, log *slog.Logger) (*broker.LocalObservation, error) { | |||
| 144 | + | binding, ok := config.LocalBinding() | |||
| 145 | + | if !ok { | |||
| 146 | + | return nil, errors.New("vev: broker local observation requires a provisioned local binding") | |||
| 147 | + | } | |||
| 148 | + | probe, err := brokerLocalProbe(config, log) | |||
| 149 | + | if err != nil { | |||
| 150 | + | return nil, err | |||
| 151 | + | } | |||
| 152 | + | return &broker.LocalObservation{DisplayOrigin: binding.DisplayOrigin, Policy: binding.Policy, Probe: probe}, nil | |||
| 153 | + | } | |||
A internal/app/broker_offline_local_test.go +314 -0
| @@ -0,0 +1,314 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "io" | |
| 7 | + | "log/slog" | |
| 8 | + | "net" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 11 | + | "sync/atomic" | |
| 12 | + | "testing" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | ||
| 17 | + | "github.com/bnema/vev/internal/adapters/brokerconfig" | |
| 18 | + | "github.com/bnema/vev/internal/adapters/brokerstore" | |
| 19 | + | "github.com/bnema/vev/internal/adapters/clock" | |
| 20 | + | "github.com/bnema/vev/internal/adapters/daemonmux" | |
| 21 | + | "github.com/bnema/vev/internal/adapters/ipc" | |
| 22 | + | "github.com/bnema/vev/internal/domain" | |
| 23 | + | "github.com/bnema/vev/internal/ports" | |
| 24 | + | "github.com/bnema/vev/internal/usecase/broker" | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | const brokerLocalTestIdentity = "offline-local-daemon" | |
| 28 | + | ||
| 29 | + | // testLocalSandboxConfig writes and loads one marker-valid sandbox whose only | |
| 30 | + | // provisioned carriage is the broker-owned local binding. | |
| 31 | + | func testLocalSandboxConfig(t *testing.T) (*brokerconfig.Config, brokerconfig.LocalBinding) { | |
| 32 | + | t.Helper() | |
| 33 | + | root := filepath.Join(shortTempDir(t, "vevl"), "sandbox") | |
| 34 | + | policy := brokerTestPolicy() | |
| 35 | + | require.NoError(t, os.MkdirAll(root, 0o700)) | |
| 36 | + | writeSandboxConfig(t, root, map[string]any{ | |
| 37 | + | "marker": brokerconfig.Marker, | |
| 38 | + | "registrations": []any{}, | |
| 39 | + | "local": map[string]any{ | |
| 40 | + | "identity": brokerLocalTestIdentity, | |
| 41 | + | "displayOrigin": "local", | |
| 42 | + | "route": filepath.Join(root, "local-mux.sock"), | |
| 43 | + | "policy": map[string]any{ | |
| 44 | + | "protocolVersion": policy.ProtocolVersion, | |
| 45 | + | "catalogSchemaVersion": policy.CatalogSchemaVersion, | |
| 46 | + | "environmentPolicy": "client-owned", | |
| 47 | + | "transport": policy.Transport, | |
| 48 | + | "trust": policy.Trust, | |
| 49 | + | "launch": policy.Launch, | |
| 50 | + | "isolation": policy.Isolation, | |
| 51 | + | }, | |
| 52 | + | }, | |
| 53 | + | }) | |
| 54 | + | layout, err := brokerconfig.ResolveLayout(root, nil) | |
| 55 | + | require.NoError(t, err) | |
| 56 | + | config, err := brokerconfig.Load(layout) | |
| 57 | + | require.NoError(t, err) | |
| 58 | + | binding, ok := config.LocalBinding() | |
| 59 | + | require.True(t, ok) | |
| 60 | + | return config, binding | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // rawFramedTransport adapts one in-memory pipe end to the raw framed carriage | |
| 64 | + | // the daemonmux bridge consumes. No real socket is opened. | |
| 65 | + | func rawFramedTransport(raw net.Conn) daemonmux.RawFramedTransport { | |
| 66 | + | return ipc.NewTransport(raw).(daemonmux.RawFramedTransport) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // testServerBinding builds the daemon-side binding matching one local binding. | |
| 70 | + | func testServerBinding(t *testing.T, binding brokerconfig.LocalBinding) daemonmux.ServerBinding { | |
| 71 | + | t.Helper() | |
| 72 | + | serverBinding, err := daemonmux.NewServerBinding(binding.Identity, ports.BrokerDaemonIncarnation{7, 1}, binding.Policy) | |
| 73 | + | require.NoError(t, err) | |
| 74 | + | return serverBinding | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // TestBrokerLocalProbeObservesWithoutAttaching proves the local probe completes | |
| 78 | + | // only the daemonmux physical preamble: it reports the daemon's authenticated | |
| 79 | + | // identity, incarnation, and negotiated protocol version, and then closes the | |
| 80 | + | // carriage without ever opening a logical stream (no attach). Capabilities and | |
| 81 | + | // the session catalogue stay unknown because the preamble carries neither. | |
| 82 | + | func TestBrokerLocalProbeObservesWithoutAttaching(t *testing.T) { | |
| 83 | + | _, binding := testLocalSandboxConfig(t) | |
| 84 | + | serverBinding := testServerBinding(t, binding) | |
| 85 | + | ||
| 86 | + | clientConn, serverConn := net.Pipe() | |
| 87 | + | t.Cleanup(func() { | |
| 88 | + | _ = clientConn.Close() | |
| 89 | + | _ = serverConn.Close() | |
| 90 | + | }) | |
| 91 | + | ||
| 92 | + | var dialed atomic.Value | |
| 93 | + | dial := func(_ context.Context, address string) (daemonmux.RawFramedTransport, error) { | |
| 94 | + | dialed.Store(address) | |
| 95 | + | return rawFramedTransport(clientConn), nil | |
| 96 | + | } | |
| 97 | + | probe, err := newLocalRouteProbe(binding, dial, daemonmux.DefaultMuxCeilings()) | |
| 98 | + | require.NoError(t, err) | |
| 99 | + | ||
| 100 | + | // The daemon side completes one preamble and then blocks reading the next | |
| 101 | + | // frame. The probe must never send one: the read ends only when the probe | |
| 102 | + | // closes the carriage, which is the proof that no attachment stream opened. | |
| 103 | + | serverDone := make(chan error, 1) | |
| 104 | + | go func() { | |
| 105 | + | bridge, err := daemonmux.NewPreambleCarrier(rawFramedTransport(serverConn)) | |
| 106 | + | if err != nil { | |
| 107 | + | serverDone <- err | |
| 108 | + | return | |
| 109 | + | } | |
| 110 | + | if _, err := daemonmux.RunServerHandshake(context.Background(), bridge, serverBinding, daemonmux.DefaultMuxCeilings()); err != nil { | |
| 111 | + | serverDone <- err | |
| 112 | + | return | |
| 113 | + | } | |
| 114 | + | readCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |
| 115 | + | defer cancel() | |
| 116 | + | _, recvErr := bridge.Receive(readCtx) | |
| 117 | + | serverDone <- recvErr | |
| 118 | + | }() | |
| 119 | + | ||
| 120 | + | observation, err := probe.ProbeLocal(context.Background()) | |
| 121 | + | require.NoError(t, err) | |
| 122 | + | require.Equal(t, binding.Identity, observation.Identity) | |
| 123 | + | require.Equal(t, ports.BrokerDaemonIncarnation{7, 1}, observation.Incarnation) | |
| 124 | + | require.Equal(t, binding.Policy.ProtocolVersion, observation.ProtocolVersion) | |
| 125 | + | require.Equal(t, domain.RemoteAvailabilityReachable, observation.Availability) | |
| 126 | + | require.Zero(t, observation.Capabilities, "the preamble carries no capabilities; they must stay unknown") | |
| 127 | + | require.Empty(t, observation.Sessions) | |
| 128 | + | require.False(t, observation.InventoryKnown) | |
| 129 | + | require.False(t, observation.Local, "the probe supplies observed state only, never local authority") | |
| 130 | + | require.Empty(t, observation.Endpoint) | |
| 131 | + | require.Equal(t, binding.Route.Address(), dialed.Load()) | |
| 132 | + | ||
| 133 | + | serverErr := <-serverDone | |
| 134 | + | require.Error(t, serverErr, "the probe sent no frame after the preamble") | |
| 135 | + | require.True(t, errors.Is(serverErr, io.EOF) || errors.Is(serverErr, io.ErrClosedPipe) || errors.Is(serverErr, context.Canceled), | |
| 136 | + | "expected a closed carriage, got %v", serverErr) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // TestBrokerLocalProbeFailsClosed proves an unavailable carriage yields zero | |
| 140 | + | // observed identity and an explicit availability, never an invented one, and | |
| 141 | + | // that the probe only dials the provisioned local address: it has no spawn path | |
| 142 | + | // and never starts a stopped daemon. | |
| 143 | + | func TestBrokerLocalProbeFailsClosed(t *testing.T) { | |
| 144 | + | _, binding := testLocalSandboxConfig(t) | |
| 145 | + | ||
| 146 | + | var addresses []string | |
| 147 | + | dial := func(_ context.Context, address string) (daemonmux.RawFramedTransport, error) { | |
| 148 | + | addresses = append(addresses, address) | |
| 149 | + | return nil, errors.New("no carriage") | |
| 150 | + | } | |
| 151 | + | probe, err := newLocalRouteProbe(binding, dial, daemonmux.DefaultMuxCeilings()) | |
| 152 | + | require.NoError(t, err) | |
| 153 | + | ||
| 154 | + | observation, err := probe.ProbeLocal(context.Background()) | |
| 155 | + | require.Error(t, err) | |
| 156 | + | require.Equal(t, domain.RemoteAvailabilityUnreachable, observation.Availability) | |
| 157 | + | require.Zero(t, observation.Identity) | |
| 158 | + | require.True(t, observation.Incarnation.IsZero()) | |
| 159 | + | require.Zero(t, observation.ProtocolVersion) | |
| 160 | + | require.Nil(t, observation.Sessions) | |
| 161 | + | require.Equal(t, []string{binding.Route.Address()}, addresses) | |
| 162 | + | ||
| 163 | + | t.Run("a nil carriage is refused", func(t *testing.T) { | |
| 164 | + | probe, err := newLocalRouteProbe(binding, func(context.Context, string) (daemonmux.RawFramedTransport, error) { | |
| 165 | + | return nil, nil | |
| 166 | + | }, daemonmux.DefaultMuxCeilings()) | |
| 167 | + | require.NoError(t, err) | |
| 168 | + | observation, err := probe.ProbeLocal(context.Background()) | |
| 169 | + | require.Error(t, err) | |
| 170 | + | require.Zero(t, observation.Identity) | |
| 171 | + | }) | |
| 172 | + | ||
| 173 | + | t.Run("construction refuses a missing dialer", func(t *testing.T) { | |
| 174 | + | _, err := newLocalRouteProbe(binding, nil, daemonmux.DefaultMuxCeilings()) | |
| 175 | + | require.Error(t, err) | |
| 176 | + | }) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // TestBrokerLocalObservationComposition proves the composition derives the | |
| 180 | + | // local producer's authority from the validated configuration and refuses a | |
| 181 | + | // configuration that provisions no local binding. | |
| 182 | + | func TestBrokerLocalObservationComposition(t *testing.T) { | |
| 183 | + | config, binding := testLocalSandboxConfig(t) | |
| 184 | + | observation, err := brokerLocalObservation(config, nil) | |
| 185 | + | require.NoError(t, err) | |
| 186 | + | require.Equal(t, binding.DisplayOrigin, observation.DisplayOrigin) | |
| 187 | + | require.Equal(t, binding.Policy, observation.Policy) | |
| 188 | + | require.NotNil(t, observation.Probe) | |
| 189 | + | ||
| 190 | + | t.Run("no local binding provisions no producer", func(t *testing.T) { | |
| 191 | + | root := filepath.Join(shortTempDir(t, "vevl"), "sandbox") | |
| 192 | + | require.NoError(t, os.MkdirAll(root, 0o700)) | |
| 193 | + | writeSandboxConfig(t, root, sandboxRegistrationDocument("/tmp/vev-local-test/mux.sock")) | |
| 194 | + | layout, err := brokerconfig.ResolveLayout(root, nil) | |
| 195 | + | require.NoError(t, err) | |
| 196 | + | config, err := brokerconfig.Load(layout) | |
| 197 | + | require.NoError(t, err) | |
| 198 | + | _, err = brokerLocalObservation(config, nil) | |
| 199 | + | require.Error(t, err) | |
| 200 | + | }) | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // TestBrokerLocalObservationRegistryPublishesLocalFirst proves the composition | |
| 204 | + | // end to end: the registry observes the local daemon through the broker-owned | |
| 205 | + | // probe and publishes it at index zero, and the durable store persists no local | |
| 206 | + | // entry (the local observation is never durable). | |
| 207 | + | func TestBrokerLocalObservationRegistryPublishesLocalFirst(t *testing.T) { | |
| 208 | + | _, binding := testLocalSandboxConfig(t) | |
| 209 | + | serverBinding := testServerBinding(t, binding) | |
| 210 | + | ||
| 211 | + | var dials atomic.Int32 | |
| 212 | + | dial := func(ctx context.Context, _ string) (daemonmux.RawFramedTransport, error) { | |
| 213 | + | dials.Add(1) | |
| 214 | + | clientConn, serverConn := net.Pipe() | |
| 215 | + | go func() { | |
| 216 | + | defer serverConn.Close() | |
| 217 | + | bridge, err := daemonmux.NewPreambleCarrier(rawFramedTransport(serverConn)) | |
| 218 | + | if err != nil { | |
| 219 | + | return | |
| 220 | + | } | |
| 221 | + | if _, err := daemonmux.RunServerHandshake(ctx, bridge, serverBinding, daemonmux.DefaultMuxCeilings()); err != nil { | |
| 222 | + | return | |
| 223 | + | } | |
| 224 | + | // Hold the carriage until the probe closes it or the attempt is | |
| 225 | + | // retired, so the daemon side owns no logical stream either. | |
| 226 | + | <-ctx.Done() | |
| 227 | + | _ = bridge.Close() | |
| 228 | + | }() | |
| 229 | + | return rawFramedTransport(clientConn), nil | |
| 230 | + | } | |
| 231 | + | probe, err := newLocalRouteProbe(binding, dial, daemonmux.DefaultMuxCeilings()) | |
| 232 | + | require.NoError(t, err) | |
| 233 | + | ||
| 234 | + | stateDir := filepath.Join(shortTempDir(t, "vevs"), "state") | |
| 235 | + | require.NoError(t, os.MkdirAll(stateDir, 0o700)) | |
| 236 | + | store, err := brokerstore.OpenOffline(brokerstore.Options{Dir: stateDir}) | |
| 237 | + | require.NoError(t, err) | |
| 238 | + | t.Cleanup(func() { require.NoError(t, store.Close()) }) | |
| 239 | + | ||
| 240 | + | registry, err := broker.NewRegistryWithConfig(7, store, nil, clock.New(), slog.New(slog.NewTextHandler(io.Discard, nil)), broker.RegistryConfig{ | |
| 241 | + | Local: &broker.LocalObservation{DisplayOrigin: binding.DisplayOrigin, Policy: binding.Policy, Probe: probe}, | |
| 242 | + | }) | |
| 243 | + | require.NoError(t, err) | |
| 244 | + | ||
| 245 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 246 | + | done := make(chan struct{}) | |
| 247 | + | go func() { defer close(done); registry.Run(ctx) }() | |
| 248 | + | t.Cleanup(func() { cancel(); <-done }) | |
| 249 | + | ||
| 250 | + | require.Eventually(t, func() bool { | |
| 251 | + | snapshot := registry.Snapshot() | |
| 252 | + | return len(snapshot.Daemons) == 1 && snapshot.Daemons[0].Local && | |
| 253 | + | !snapshot.Daemons[0].Checking && snapshot.Daemons[0].Availability == domain.RemoteAvailabilityReachable | |
| 254 | + | }, 3*time.Second, 2*time.Millisecond) | |
| 255 | + | ||
| 256 | + | snapshot := registry.Snapshot() | |
| 257 | + | require.Len(t, snapshot.Daemons, 1) | |
| 258 | + | require.True(t, snapshot.Daemons[0].Local) | |
| 259 | + | require.Equal(t, binding.Identity, snapshot.Daemons[0].Identity) | |
| 260 | + | require.Positive(t, dials.Load()) | |
| 261 | + | ||
| 262 | + | // The durable store persists remote observations only: the local entry never | |
| 263 | + | // reaches disk. | |
| 264 | + | require.Eventually(t, func() bool { | |
| 265 | + | stored, err := store.Load() | |
| 266 | + | return err == nil && stored.Revision > 0 | |
| 267 | + | }, 3*time.Second, 2*time.Millisecond) | |
| 268 | + | stored, err := store.Load() | |
| 269 | + | require.NoError(t, err) | |
| 270 | + | for _, daemon := range stored.Daemons { | |
| 271 | + | require.False(t, daemon.Local, "a local observation must never be durable") | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // TestBrokerLocalProbeRefusesNonLocalRoute proves the probe refuses a | |
| 276 | + | // hand-built binding whose route is an ssh helper route: probing must never | |
| 277 | + | // start a child process, so only a Unix route this process dials directly is | |
| 278 | + | // acceptable. brokerconfig exposes no exported route constructor and its route | |
| 279 | + | // fields are unexported, so the ssh route is obtained the only way this package | |
| 280 | + | // can: through a provisioned ssh registration (loadTestRoute, shared with the | |
| 281 | + | // mux tests) and used directly as the local binding's route. | |
| 282 | + | func TestBrokerLocalProbeRefusesNonLocalRoute(t *testing.T) { | |
| 283 | + | isolateSandboxEnv(t) | |
| 284 | + | sshRoute := loadTestRoute(t, map[string]any{ | |
| 285 | + | "kind": "ssh-stdio", | |
| 286 | + | "target": "user@host:2222", | |
| 287 | + | "argv": []any{"vev", brokerMuxStdioCommand, "--offline-root", "/srv/remote"}, | |
| 288 | + | }) | |
| 289 | + | require.Equal(t, brokerconfig.RouteSSHStdio, sshRoute.Kind()) | |
| 290 | + | require.False(t, sshRoute.IsLocal(), "an ssh helper route is never dialed directly") | |
| 291 | + | ||
| 292 | + | binding := brokerconfig.LocalBinding{ | |
| 293 | + | Identity: brokerLocalTestIdentity, | |
| 294 | + | DisplayOrigin: "local", | |
| 295 | + | Policy: brokerTestPolicy(), | |
| 296 | + | Route: sshRoute, | |
| 297 | + | } | |
| 298 | + | _, err := newLocalRouteProbe(binding, func(context.Context, string) (daemonmux.RawFramedTransport, error) { | |
| 299 | + | t.Fatal("an ssh route must never be dialed by the local probe") | |
| 300 | + | return nil, nil | |
| 301 | + | }, daemonmux.DefaultMuxCeilings()) | |
| 302 | + | require.Error(t, err) | |
| 303 | + | require.Contains(t, err.Error(), "local route") | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | // TestBrokerLocalProbeNilReceiverFailsClosed proves a nil probe reports an | |
| 307 | + | // explicit error and an observation whose availability is never zero. | |
| 308 | + | func TestBrokerLocalProbeNilReceiverFailsClosed(t *testing.T) { | |
| 309 | + | var probe *localRouteProbe | |
| 310 | + | observation, err := probe.ProbeLocal(context.Background()) | |
| 311 | + | require.Error(t, err) | |
| 312 | + | require.Equal(t, domain.RemoteAvailabilityUnreachable, observation.Availability) | |
| 313 | + | require.NotZero(t, observation.Availability, "availability is never zero") | |
| 314 | + | } | |
| @@ -0,0 +1,314 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "io" | |||
| 7 | + | "log/slog" | |||
| 8 | + | "net" | |||
| 9 | + | "os" | |||
| 10 | + | "path/filepath" | |||
| 11 | + | "sync/atomic" | |||
| 12 | + | "testing" | |||
| 13 | + | "time" | |||
| 14 | + | ||||
| 15 | + | "github.com/stretchr/testify/require" | |||
| 16 | + | ||||
| 17 | + | "github.com/bnema/vev/internal/adapters/brokerconfig" | |||
| 18 | + | "github.com/bnema/vev/internal/adapters/brokerstore" | |||
| 19 | + | "github.com/bnema/vev/internal/adapters/clock" | |||
| 20 | + | "github.com/bnema/vev/internal/adapters/daemonmux" | |||
| 21 | + | "github.com/bnema/vev/internal/adapters/ipc" | |||
| 22 | + | "github.com/bnema/vev/internal/domain" | |||
| 23 | + | "github.com/bnema/vev/internal/ports" | |||
| 24 | + | "github.com/bnema/vev/internal/usecase/broker" | |||
| 25 | + | ) | |||
| 26 | + | ||||
| 27 | + | const brokerLocalTestIdentity = "offline-local-daemon" | |||
| 28 | + | ||||
| 29 | + | // testLocalSandboxConfig writes and loads one marker-valid sandbox whose only | |||
| 30 | + | // provisioned carriage is the broker-owned local binding. | |||
| 31 | + | func testLocalSandboxConfig(t *testing.T) (*brokerconfig.Config, brokerconfig.LocalBinding) { | |||
| 32 | + | t.Helper() | |||
| 33 | + | root := filepath.Join(shortTempDir(t, "vevl"), "sandbox") | |||
| 34 | + | policy := brokerTestPolicy() | |||
| 35 | + | require.NoError(t, os.MkdirAll(root, 0o700)) | |||
| 36 | + | writeSandboxConfig(t, root, map[string]any{ | |||
| 37 | + | "marker": brokerconfig.Marker, | |||
| 38 | + | "registrations": []any{}, | |||
| 39 | + | "local": map[string]any{ | |||
| 40 | + | "identity": brokerLocalTestIdentity, | |||
| 41 | + | "displayOrigin": "local", | |||
| 42 | + | "route": filepath.Join(root, "local-mux.sock"), | |||
| 43 | + | "policy": map[string]any{ | |||
| 44 | + | "protocolVersion": policy.ProtocolVersion, | |||
| 45 | + | "catalogSchemaVersion": policy.CatalogSchemaVersion, | |||
| 46 | + | "environmentPolicy": "client-owned", | |||
| 47 | + | "transport": policy.Transport, | |||
| 48 | + | "trust": policy.Trust, | |||
| 49 | + | "launch": policy.Launch, | |||
| 50 | + | "isolation": policy.Isolation, | |||
| 51 | + | }, | |||
| 52 | + | }, | |||
| 53 | + | }) | |||
| 54 | + | layout, err := brokerconfig.ResolveLayout(root, nil) | |||
| 55 | + | require.NoError(t, err) | |||
| 56 | + | config, err := brokerconfig.Load(layout) | |||
| 57 | + | require.NoError(t, err) | |||
| 58 | + | binding, ok := config.LocalBinding() | |||
| 59 | + | require.True(t, ok) | |||
| 60 | + | return config, binding | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // rawFramedTransport adapts one in-memory pipe end to the raw framed carriage | |||
| 64 | + | // the daemonmux bridge consumes. No real socket is opened. | |||
| 65 | + | func rawFramedTransport(raw net.Conn) daemonmux.RawFramedTransport { | |||
| 66 | + | return ipc.NewTransport(raw).(daemonmux.RawFramedTransport) | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // testServerBinding builds the daemon-side binding matching one local binding. | |||
| 70 | + | func testServerBinding(t *testing.T, binding brokerconfig.LocalBinding) daemonmux.ServerBinding { | |||
| 71 | + | t.Helper() | |||
| 72 | + | serverBinding, err := daemonmux.NewServerBinding(binding.Identity, ports.BrokerDaemonIncarnation{7, 1}, binding.Policy) | |||
| 73 | + | require.NoError(t, err) | |||
| 74 | + | return serverBinding | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // TestBrokerLocalProbeObservesWithoutAttaching proves the local probe completes | |||
| 78 | + | // only the daemonmux physical preamble: it reports the daemon's authenticated | |||
| 79 | + | // identity, incarnation, and negotiated protocol version, and then closes the | |||
| 80 | + | // carriage without ever opening a logical stream (no attach). Capabilities and | |||
| 81 | + | // the session catalogue stay unknown because the preamble carries neither. | |||
| 82 | + | func TestBrokerLocalProbeObservesWithoutAttaching(t *testing.T) { | |||
| 83 | + | _, binding := testLocalSandboxConfig(t) | |||
| 84 | + | serverBinding := testServerBinding(t, binding) | |||
| 85 | + | ||||
| 86 | + | clientConn, serverConn := net.Pipe() | |||
| 87 | + | t.Cleanup(func() { | |||
| 88 | + | _ = clientConn.Close() | |||
| 89 | + | _ = serverConn.Close() | |||
| 90 | + | }) | |||
| 91 | + | ||||
| 92 | + | var dialed atomic.Value | |||
| 93 | + | dial := func(_ context.Context, address string) (daemonmux.RawFramedTransport, error) { | |||
| 94 | + | dialed.Store(address) | |||
| 95 | + | return rawFramedTransport(clientConn), nil | |||
| 96 | + | } | |||
| 97 | + | probe, err := newLocalRouteProbe(binding, dial, daemonmux.DefaultMuxCeilings()) | |||
| 98 | + | require.NoError(t, err) | |||
| 99 | + | ||||
| 100 | + | // The daemon side completes one preamble and then blocks reading the next | |||
| 101 | + | // frame. The probe must never send one: the read ends only when the probe | |||
| 102 | + | // closes the carriage, which is the proof that no attachment stream opened. | |||
| 103 | + | serverDone := make(chan error, 1) | |||
| 104 | + | go func() { | |||
| 105 | + | bridge, err := daemonmux.NewPreambleCarrier(rawFramedTransport(serverConn)) | |||
| 106 | + | if err != nil { | |||
| 107 | + | serverDone <- err | |||
| 108 | + | return | |||
| 109 | + | } | |||
| 110 | + | if _, err := daemonmux.RunServerHandshake(context.Background(), bridge, serverBinding, daemonmux.DefaultMuxCeilings()); err != nil { | |||
| 111 | + | serverDone <- err | |||
| 112 | + | return | |||
| 113 | + | } | |||
| 114 | + | readCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |||
| 115 | + | defer cancel() | |||
| 116 | + | _, recvErr := bridge.Receive(readCtx) | |||
| 117 | + | serverDone <- recvErr | |||
| 118 | + | }() | |||
| 119 | + | ||||
| 120 | + | observation, err := probe.ProbeLocal(context.Background()) | |||
| 121 | + | require.NoError(t, err) | |||
| 122 | + | require.Equal(t, binding.Identity, observation.Identity) | |||
| 123 | + | require.Equal(t, ports.BrokerDaemonIncarnation{7, 1}, observation.Incarnation) | |||
| 124 | + | require.Equal(t, binding.Policy.ProtocolVersion, observation.ProtocolVersion) | |||
| 125 | + | require.Equal(t, domain.RemoteAvailabilityReachable, observation.Availability) | |||
| 126 | + | require.Zero(t, observation.Capabilities, "the preamble carries no capabilities; they must stay unknown") | |||
| 127 | + | require.Empty(t, observation.Sessions) | |||
| 128 | + | require.False(t, observation.InventoryKnown) | |||
| 129 | + | require.False(t, observation.Local, "the probe supplies observed state only, never local authority") | |||
| 130 | + | require.Empty(t, observation.Endpoint) | |||
| 131 | + | require.Equal(t, binding.Route.Address(), dialed.Load()) | |||
| 132 | + | ||||
| 133 | + | serverErr := <-serverDone | |||
| 134 | + | require.Error(t, serverErr, "the probe sent no frame after the preamble") | |||
| 135 | + | require.True(t, errors.Is(serverErr, io.EOF) || errors.Is(serverErr, io.ErrClosedPipe) || errors.Is(serverErr, context.Canceled), | |||
| 136 | + | "expected a closed carriage, got %v", serverErr) | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // TestBrokerLocalProbeFailsClosed proves an unavailable carriage yields zero | |||
| 140 | + | // observed identity and an explicit availability, never an invented one, and | |||
| 141 | + | // that the probe only dials the provisioned local address: it has no spawn path | |||
| 142 | + | // and never starts a stopped daemon. | |||
| 143 | + | func TestBrokerLocalProbeFailsClosed(t *testing.T) { | |||
| 144 | + | _, binding := testLocalSandboxConfig(t) | |||
| 145 | + | ||||
| 146 | + | var addresses []string | |||
| 147 | + | dial := func(_ context.Context, address string) (daemonmux.RawFramedTransport, error) { | |||
| 148 | + | addresses = append(addresses, address) | |||
| 149 | + | return nil, errors.New("no carriage") | |||
| 150 | + | } | |||
| 151 | + | probe, err := newLocalRouteProbe(binding, dial, daemonmux.DefaultMuxCeilings()) | |||
| 152 | + | require.NoError(t, err) | |||
| 153 | + | ||||
| 154 | + | observation, err := probe.ProbeLocal(context.Background()) | |||
| 155 | + | require.Error(t, err) | |||
| 156 | + | require.Equal(t, domain.RemoteAvailabilityUnreachable, observation.Availability) | |||
| 157 | + | require.Zero(t, observation.Identity) | |||
| 158 | + | require.True(t, observation.Incarnation.IsZero()) | |||
| 159 | + | require.Zero(t, observation.ProtocolVersion) | |||
| 160 | + | require.Nil(t, observation.Sessions) | |||
| 161 | + | require.Equal(t, []string{binding.Route.Address()}, addresses) | |||
| 162 | + | ||||
| 163 | + | t.Run("a nil carriage is refused", func(t *testing.T) { | |||
| 164 | + | probe, err := newLocalRouteProbe(binding, func(context.Context, string) (daemonmux.RawFramedTransport, error) { | |||
| 165 | + | return nil, nil | |||
| 166 | + | }, daemonmux.DefaultMuxCeilings()) | |||
| 167 | + | require.NoError(t, err) | |||
| 168 | + | observation, err := probe.ProbeLocal(context.Background()) | |||
| 169 | + | require.Error(t, err) | |||
| 170 | + | require.Zero(t, observation.Identity) | |||
| 171 | + | }) | |||
| 172 | + | ||||
| 173 | + | t.Run("construction refuses a missing dialer", func(t *testing.T) { | |||
| 174 | + | _, err := newLocalRouteProbe(binding, nil, daemonmux.DefaultMuxCeilings()) | |||
| 175 | + | require.Error(t, err) | |||
| 176 | + | }) | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | // TestBrokerLocalObservationComposition proves the composition derives the | |||
| 180 | + | // local producer's authority from the validated configuration and refuses a | |||
| 181 | + | // configuration that provisions no local binding. | |||
| 182 | + | func TestBrokerLocalObservationComposition(t *testing.T) { | |||
| 183 | + | config, binding := testLocalSandboxConfig(t) | |||
| 184 | + | observation, err := brokerLocalObservation(config, nil) | |||
| 185 | + | require.NoError(t, err) | |||
| 186 | + | require.Equal(t, binding.DisplayOrigin, observation.DisplayOrigin) | |||
| 187 | + | require.Equal(t, binding.Policy, observation.Policy) | |||
| 188 | + | require.NotNil(t, observation.Probe) | |||
| 189 | + | ||||
| 190 | + | t.Run("no local binding provisions no producer", func(t *testing.T) { | |||
| 191 | + | root := filepath.Join(shortTempDir(t, "vevl"), "sandbox") | |||
| 192 | + | require.NoError(t, os.MkdirAll(root, 0o700)) | |||
| 193 | + | writeSandboxConfig(t, root, sandboxRegistrationDocument("/tmp/vev-local-test/mux.sock")) | |||
| 194 | + | layout, err := brokerconfig.ResolveLayout(root, nil) | |||
| 195 | + | require.NoError(t, err) | |||
| 196 | + | config, err := brokerconfig.Load(layout) | |||
| 197 | + | require.NoError(t, err) | |||
| 198 | + | _, err = brokerLocalObservation(config, nil) | |||
| 199 | + | require.Error(t, err) | |||
| 200 | + | }) | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | // TestBrokerLocalObservationRegistryPublishesLocalFirst proves the composition | |||
| 204 | + | // end to end: the registry observes the local daemon through the broker-owned | |||
| 205 | + | // probe and publishes it at index zero, and the durable store persists no local | |||
| 206 | + | // entry (the local observation is never durable). | |||
| 207 | + | func TestBrokerLocalObservationRegistryPublishesLocalFirst(t *testing.T) { | |||
| 208 | + | _, binding := testLocalSandboxConfig(t) | |||
| 209 | + | serverBinding := testServerBinding(t, binding) | |||
| 210 | + | ||||
| 211 | + | var dials atomic.Int32 | |||
| 212 | + | dial := func(ctx context.Context, _ string) (daemonmux.RawFramedTransport, error) { | |||
| 213 | + | dials.Add(1) | |||
| 214 | + | clientConn, serverConn := net.Pipe() | |||
| 215 | + | go func() { | |||
| 216 | + | defer serverConn.Close() | |||
| 217 | + | bridge, err := daemonmux.NewPreambleCarrier(rawFramedTransport(serverConn)) | |||
| 218 | + | if err != nil { | |||
| 219 | + | return | |||
| 220 | + | } | |||
| 221 | + | if _, err := daemonmux.RunServerHandshake(ctx, bridge, serverBinding, daemonmux.DefaultMuxCeilings()); err != nil { | |||
| 222 | + | return | |||
| 223 | + | } | |||
| 224 | + | // Hold the carriage until the probe closes it or the attempt is | |||
| 225 | + | // retired, so the daemon side owns no logical stream either. | |||
| 226 | + | <-ctx.Done() | |||
| 227 | + | _ = bridge.Close() | |||
| 228 | + | }() | |||
| 229 | + | return rawFramedTransport(clientConn), nil | |||
| 230 | + | } | |||
| 231 | + | probe, err := newLocalRouteProbe(binding, dial, daemonmux.DefaultMuxCeilings()) | |||
| 232 | + | require.NoError(t, err) | |||
| 233 | + | ||||
| 234 | + | stateDir := filepath.Join(shortTempDir(t, "vevs"), "state") | |||
| 235 | + | require.NoError(t, os.MkdirAll(stateDir, 0o700)) | |||
| 236 | + | store, err := brokerstore.OpenOffline(brokerstore.Options{Dir: stateDir}) | |||
| 237 | + | require.NoError(t, err) | |||
| 238 | + | t.Cleanup(func() { require.NoError(t, store.Close()) }) | |||
| 239 | + | ||||
| 240 | + | registry, err := broker.NewRegistryWithConfig(7, store, nil, clock.New(), slog.New(slog.NewTextHandler(io.Discard, nil)), broker.RegistryConfig{ | |||
| 241 | + | Local: &broker.LocalObservation{DisplayOrigin: binding.DisplayOrigin, Policy: binding.Policy, Probe: probe}, | |||
| 242 | + | }) | |||
| 243 | + | require.NoError(t, err) | |||
| 244 | + | ||||
| 245 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 246 | + | done := make(chan struct{}) | |||
| 247 | + | go func() { defer close(done); registry.Run(ctx) }() | |||
| 248 | + | t.Cleanup(func() { cancel(); <-done }) | |||
| 249 | + | ||||
| 250 | + | require.Eventually(t, func() bool { | |||
| 251 | + | snapshot := registry.Snapshot() | |||
| 252 | + | return len(snapshot.Daemons) == 1 && snapshot.Daemons[0].Local && | |||
| 253 | + | !snapshot.Daemons[0].Checking && snapshot.Daemons[0].Availability == domain.RemoteAvailabilityReachable | |||
| 254 | + | }, 3*time.Second, 2*time.Millisecond) | |||
| 255 | + | ||||
| 256 | + | snapshot := registry.Snapshot() | |||
| 257 | + | require.Len(t, snapshot.Daemons, 1) | |||
| 258 | + | require.True(t, snapshot.Daemons[0].Local) | |||
| 259 | + | require.Equal(t, binding.Identity, snapshot.Daemons[0].Identity) | |||
| 260 | + | require.Positive(t, dials.Load()) | |||
| 261 | + | ||||
| 262 | + | // The durable store persists remote observations only: the local entry never | |||
| 263 | + | // reaches disk. | |||
| 264 | + | require.Eventually(t, func() bool { | |||
| 265 | + | stored, err := store.Load() | |||
| 266 | + | return err == nil && stored.Revision > 0 | |||
| 267 | + | }, 3*time.Second, 2*time.Millisecond) | |||
| 268 | + | stored, err := store.Load() | |||
| 269 | + | require.NoError(t, err) | |||
| 270 | + | for _, daemon := range stored.Daemons { | |||
| 271 | + | require.False(t, daemon.Local, "a local observation must never be durable") | |||
| 272 | + | } | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | // TestBrokerLocalProbeRefusesNonLocalRoute proves the probe refuses a | |||
| 276 | + | // hand-built binding whose route is an ssh helper route: probing must never | |||
| 277 | + | // start a child process, so only a Unix route this process dials directly is | |||
| 278 | + | // acceptable. brokerconfig exposes no exported route constructor and its route | |||
| 279 | + | // fields are unexported, so the ssh route is obtained the only way this package | |||
| 280 | + | // can: through a provisioned ssh registration (loadTestRoute, shared with the | |||
| 281 | + | // mux tests) and used directly as the local binding's route. | |||
| 282 | + | func TestBrokerLocalProbeRefusesNonLocalRoute(t *testing.T) { | |||
| 283 | + | isolateSandboxEnv(t) | |||
| 284 | + | sshRoute := loadTestRoute(t, map[string]any{ | |||
| 285 | + | "kind": "ssh-stdio", | |||
| 286 | + | "target": "user@host:2222", | |||
| 287 | + | "argv": []any{"vev", brokerMuxStdioCommand, "--offline-root", "/srv/remote"}, | |||
| 288 | + | }) | |||
| 289 | + | require.Equal(t, brokerconfig.RouteSSHStdio, sshRoute.Kind()) | |||
| 290 | + | require.False(t, sshRoute.IsLocal(), "an ssh helper route is never dialed directly") | |||
| 291 | + | ||||
| 292 | + | binding := brokerconfig.LocalBinding{ | |||
| 293 | + | Identity: brokerLocalTestIdentity, | |||
| 294 | + | DisplayOrigin: "local", | |||
| 295 | + | Policy: brokerTestPolicy(), | |||
| 296 | + | Route: sshRoute, | |||
| 297 | + | } | |||
| 298 | + | _, err := newLocalRouteProbe(binding, func(context.Context, string) (daemonmux.RawFramedTransport, error) { | |||
| 299 | + | t.Fatal("an ssh route must never be dialed by the local probe") | |||
| 300 | + | return nil, nil | |||
| 301 | + | }, daemonmux.DefaultMuxCeilings()) | |||
| 302 | + | require.Error(t, err) | |||
| 303 | + | require.Contains(t, err.Error(), "local route") | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | // TestBrokerLocalProbeNilReceiverFailsClosed proves a nil probe reports an | |||
| 307 | + | // explicit error and an observation whose availability is never zero. | |||
| 308 | + | func TestBrokerLocalProbeNilReceiverFailsClosed(t *testing.T) { | |||
| 309 | + | var probe *localRouteProbe | |||
| 310 | + | observation, err := probe.ProbeLocal(context.Background()) | |||
| 311 | + | require.Error(t, err) | |||
| 312 | + | require.Equal(t, domain.RemoteAvailabilityUnreachable, observation.Availability) | |||
| 313 | + | require.NotZero(t, observation.Availability, "availability is never zero") | |||
| 314 | + | } | |||
M internal/usecase/broker/pool.go +9 -0
| @@ -18,6 +18,15 @@ type PoolLimits struct { | |||
| 18 | 18 | Idle time.Duration | |
| 19 | 19 | } | |
| 20 | 20 | ||
| 21 | + | // poolKey is the exact authenticated (identity, policy) pair the broker pools | |
| 22 | + | // one physical transport by. The resolved endpoint's opaque address is | |
| 23 | + | // deliberately excluded: it selects the transport, not the pooling identity. | |
| 24 | + | // | |
| 25 | + | // A local stream request carries no endpoint and no registration, so its key is | |
| 26 | + | // the broker-owned local identity and policy the resolver returns. Every local | |
| 27 | + | // stream therefore shares one pooled physical connection to the local daemon, | |
| 28 | + | // exactly as every stream to one remote identity does; the local daemon is not | |
| 29 | + | // a configured host, so no registration participates in its key. | |
| 21 | 30 | type poolKey struct { | |
| 22 | 31 | identity ports.BrokerDaemonIdentity | |
| 23 | 32 | policy ports.BrokerPolicy | |
| @@ -18,6 +18,15 @@ type PoolLimits struct { | |||||
| 18 | Idle time.Duration | 18 | Idle time.Duration | ||
| 19 | } | 19 | } | ||
| 20 | 20 | ||||
| 21 | + | // poolKey is the exact authenticated (identity, policy) pair the broker pools | |||
| 22 | + | // one physical transport by. The resolved endpoint's opaque address is | |||
| 23 | + | // deliberately excluded: it selects the transport, not the pooling identity. | |||
| 24 | + | // | |||
| 25 | + | // A local stream request carries no endpoint and no registration, so its key is | |||
| 26 | + | // the broker-owned local identity and policy the resolver returns. Every local | |||
| 27 | + | // stream therefore shares one pooled physical connection to the local daemon, | |||
| 28 | + | // exactly as every stream to one remote identity does; the local daemon is not | |||
| 29 | + | // a configured host, so no registration participates in its key. | |||
| 21 | type poolKey struct { | 30 | type poolKey struct { | ||
| 22 | identity ports.BrokerDaemonIdentity | 31 | identity ports.BrokerDaemonIdentity | ||
| 23 | policy ports.BrokerPolicy | 32 | policy ports.BrokerPolicy | ||
M internal/usecase/broker/pool_test.go +27 -0
| @@ -87,6 +87,33 @@ func await(t *testing.T, ch <-chan struct{}) { | |||
| 87 | 87 | } | |
| 88 | 88 | } | |
| 89 | 89 | ||
| 90 | + | // TestPoolRefusedResolutionNeverDials proves a refused resolution is terminal: | |
| 91 | + | // the pool returns the typed broker error and never asks the connector to open | |
| 92 | + | // a physical transport, so an unprovisioned or conflicting request can never | |
| 93 | + | // reach an address. The same ordering fences a refused local request. | |
| 94 | + | func TestPoolRefusedResolutionNeverDials(t *testing.T) { | |
| 95 | + | var dials atomic.Int32 | |
| 96 | + | resolver := poolResolver(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) { | |
| 97 | + | return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errors.New("refused")} | |
| 98 | + | }) | |
| 99 | + | connector := poolConnector(func(context.Context, ports.BrokerResolvedEndpoint) (ports.BrokerPhysicalConnection, error) { | |
| 100 | + | dials.Add(1) | |
| 101 | + | return nil, nil | |
| 102 | + | }) | |
| 103 | + | pool, err := NewPool(1, resolver, connector, newManualClock(time.Unix(0, 0)), PoolLimits{Physical: 1, Clients: 8, Streams: 8, StreamsPerClient: 8, Idle: time.Minute}) | |
| 104 | + | require.NoError(t, err) | |
| 105 | + | t.Cleanup(func() { require.NoError(t, pool.Close()) }) | |
| 106 | + | ||
| 107 | + | id, err := pool.RegisterClient() | |
| 108 | + | require.NoError(t, err) | |
| 109 | + | _, err = pool.OpenStream(context.Background(), poolRequest(id, 1)) | |
| 110 | + | require.Error(t, err) | |
| 111 | + | var typed ports.BrokerError | |
| 112 | + | require.ErrorAs(t, err, &typed) | |
| 113 | + | require.Equal(t, ports.BrokerErrorConflictingPolicy, typed.Code) | |
| 114 | + | require.Zero(t, dials.Load(), "a refused request must never dial") | |
| 115 | + | } | |
| 116 | + | ||
| 90 | 117 | func TestPoolHundredStreamsLossAndIsolation(t *testing.T) { | |
| 91 | 118 | var calls atomic.Int32 | |
| 92 | 119 | var physical *fakePhysical | |
| @@ -87,6 +87,33 @@ func await(t *testing.T, ch <-chan struct{}) { | |||||
| 87 | } | 87 | } | ||
| 88 | } | 88 | } | ||
| 89 | 89 | ||||
| 90 | + | // TestPoolRefusedResolutionNeverDials proves a refused resolution is terminal: | |||
| 91 | + | // the pool returns the typed broker error and never asks the connector to open | |||
| 92 | + | // a physical transport, so an unprovisioned or conflicting request can never | |||
| 93 | + | // reach an address. The same ordering fences a refused local request. | |||
| 94 | + | func TestPoolRefusedResolutionNeverDials(t *testing.T) { | |||
| 95 | + | var dials atomic.Int32 | |||
| 96 | + | resolver := poolResolver(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) { | |||
| 97 | + | return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errors.New("refused")} | |||
| 98 | + | }) | |||
| 99 | + | connector := poolConnector(func(context.Context, ports.BrokerResolvedEndpoint) (ports.BrokerPhysicalConnection, error) { | |||
| 100 | + | dials.Add(1) | |||
| 101 | + | return nil, nil | |||
| 102 | + | }) | |||
| 103 | + | pool, err := NewPool(1, resolver, connector, newManualClock(time.Unix(0, 0)), PoolLimits{Physical: 1, Clients: 8, Streams: 8, StreamsPerClient: 8, Idle: time.Minute}) | |||
| 104 | + | require.NoError(t, err) | |||
| 105 | + | t.Cleanup(func() { require.NoError(t, pool.Close()) }) | |||
| 106 | + | ||||
| 107 | + | id, err := pool.RegisterClient() | |||
| 108 | + | require.NoError(t, err) | |||
| 109 | + | _, err = pool.OpenStream(context.Background(), poolRequest(id, 1)) | |||
| 110 | + | require.Error(t, err) | |||
| 111 | + | var typed ports.BrokerError | |||
| 112 | + | require.ErrorAs(t, err, &typed) | |||
| 113 | + | require.Equal(t, ports.BrokerErrorConflictingPolicy, typed.Code) | |||
| 114 | + | require.Zero(t, dials.Load(), "a refused request must never dial") | |||
| 115 | + | } | |||
| 116 | + | ||||
| 90 | func TestPoolHundredStreamsLossAndIsolation(t *testing.T) { | 117 | func TestPoolHundredStreamsLossAndIsolation(t *testing.T) { | ||
| 91 | var calls atomic.Int32 | 118 | var calls atomic.Int32 | ||
| 92 | var physical *fakePhysical | 119 | var physical *fakePhysical | ||
M internal/usecase/broker/registry.go +85 -7
| @@ -48,6 +48,11 @@ type RegistryConfig struct { | |||
| 48 | 48 | // expected to be immutable for the run; a caller that needs observation | |
| 49 | 49 | // keeps the zero-value config. | |
| 50 | 50 | ObservationDisabled bool | |
| 51 | + | // Local, when non-nil, enables observation of the broker's own machine | |
| 52 | + | // daemon. The registry then publishes the local entry at index zero, ahead | |
| 53 | + | // of every remote, and never persists it. Local is refused together with | |
| 54 | + | // ObservationDisabled: a read-only registry owns no local producer. | |
| 55 | + | Local *LocalObservation | |
| 51 | 56 | } | |
| 52 | 57 | ||
| 53 | 58 | // Registry owns configured hosts and their immutable observation projection. | |
| @@ -102,6 +107,17 @@ type Registry struct { | |||
| 102 | 107 | wake chan struct{} | |
| 103 | 108 | running atomic.Int32 | |
| 104 | 109 | ||
| 110 | + | // local selects observation of the broker's own machine daemon. It is nil | |
| 111 | + | // for a remote-only registry. localHost is the configured local authority | |
| 112 | + | // stamped onto every publication (Local, DisplayOrigin, Policy, Rank) plus | |
| 113 | + | // the observed state a probe supplies; localAttempt is the one admitted | |
| 114 | + | // local probe. The local entry is never durable and is never invented: until | |
| 115 | + | // a probe answers, the configured authority is published with unknown | |
| 116 | + | // availability and zero observed identity. | |
| 117 | + | local *LocalObservation | |
| 118 | + | localHost ports.BrokerDaemonObservation | |
| 119 | + | localAttempt *probeAttempt | |
| 120 | + | ||
| 105 | 121 | freshFor time.Duration | |
| 106 | 122 | retryBase time.Duration | |
| 107 | 123 | retryMax time.Duration | |
| @@ -123,13 +139,19 @@ func NewRegistry(epoch ports.BrokerEpoch, store ports.BrokerHostStore, probe por | |||
| 123 | 139 | ||
| 124 | 140 | // NewRegistryWithConfig restores a validated durable snapshot under a fresh | |
| 125 | 141 | // broker epoch and selects the run mode from cfg. Observation-disabled mode | |
| 126 | - | // tolerates a nil probe because such a registry never probes; every other | |
| 127 | - | // dependency is required exactly as NewRegistry requires it. | |
| 142 | + | // tolerates a nil probe because such a registry never probes, and a registry | |
| 143 | + | // configured with a local observation producer (Local) tolerates a nil remote | |
| 144 | + | // probe as long as it owns no remote membership. Every other dependency is | |
| 145 | + | // required exactly as NewRegistry requires it. | |
| 128 | 146 | func NewRegistryWithConfig(epoch ports.BrokerEpoch, store ports.BrokerHostStore, probe ports.BrokerHostProbe, clock ports.Clock, log *slog.Logger, cfg RegistryConfig) (*Registry, error) { | |
| 129 | 147 | if epoch == 0 || nilDependency(store) || nilDependency(clock) { | |
| 130 | 148 | return nil, errors.New("broker: invalid registry dependencies") | |
| 131 | 149 | } | |
| 132 | - | if !cfg.ObservationDisabled && nilDependency(probe) { | |
| 150 | + | // A remote probe is required to observe remotes, but a registry that | |
| 151 | + | // observes only its own machine daemon (Local) may omit it. Such a registry | |
| 152 | + | // never owns a remote host: projectMembership and the mutation paths refuse | |
| 153 | + | // remote membership rather than silently never probing it. | |
| 154 | + | if !cfg.ObservationDisabled && nilDependency(probe) && cfg.Local == nil { | |
| 133 | 155 | return nil, errors.New("broker: invalid registry dependencies") | |
| 134 | 156 | } | |
| 135 | 157 | if log == nil { | |
| @@ -144,6 +166,23 @@ func NewRegistryWithConfig(epoch ports.BrokerEpoch, store ports.BrokerHostStore, | |||
| 144 | 166 | freshFor: defaultFreshFor, retryBase: defaultRetryBase, retryMax: defaultRetryLimit, | |
| 145 | 167 | jitter: jittered, | |
| 146 | 168 | } | |
| 169 | + | if cfg.ObservationDisabled && cfg.Local != nil { | |
| 170 | + | return nil, errors.New("broker: observation-disabled registry cannot observe the local daemon") | |
| 171 | + | } | |
| 172 | + | if cfg.Local != nil { | |
| 173 | + | if err := cfg.Local.validate(); err != nil { | |
| 174 | + | return nil, err | |
| 175 | + | } | |
| 176 | + | // Copy the configured authority: the registry reads it from the probe | |
| 177 | + | // and publish paths, and a caller that kept the pointer could otherwise | |
| 178 | + | // mutate authority after construction. | |
| 179 | + | local := *cfg.Local | |
| 180 | + | r.local = &local | |
| 181 | + | r.localHost = ports.BrokerDaemonObservation{Local: true, DisplayOrigin: cfg.Local.DisplayOrigin, Policy: cfg.Local.Policy, Availability: domain.RemoteAvailabilityUnknown} | |
| 182 | + | if err := r.localHost.Validate(); err != nil { | |
| 183 | + | return nil, fmt.Errorf("broker: invalid local observation authority: %w", err) | |
| 184 | + | } | |
| 185 | + | } | |
| 147 | 186 | r.store = newSnapshotWriter(store, log) | |
| 148 | 187 | snapshot, err := store.Load() | |
| 149 | 188 | if err != nil { | |
| @@ -193,6 +232,9 @@ func (r *Registry) projectMembership(hosts ports.BrokerHosts) error { | |||
| 193 | 232 | if err := hosts.Validate(); err != nil { | |
| 194 | 233 | return err | |
| 195 | 234 | } | |
| 235 | + | if len(hosts.Hosts) > 0 && !r.observationDisabled && nilDependency(r.probe) { | |
| 236 | + | return errors.New("broker: remote hosts require a host probe") | |
| 237 | + | } | |
| 196 | 238 | next := make(map[string]ports.BrokerDaemonObservation, len(hosts.Hosts)) | |
| 197 | 239 | order := make([]string, 0, len(hosts.Hosts)) | |
| 198 | 240 | for rank, record := range hosts.Hosts { | |
| @@ -413,6 +455,9 @@ func (r *Registry) ReplaceHosts(records []ports.BrokerHostRecord) error { | |||
| 413 | 455 | if r.running.Load() == runStopped { | |
| 414 | 456 | return fmt.Errorf("broker: membership replacement: %w", ports.ErrBrokerRegistryClosed) | |
| 415 | 457 | } | |
| 458 | + | if len(records) > 0 && !r.observationDisabled && nilDependency(r.probe) { | |
| 459 | + | return errors.New("broker: remote hosts require a host probe") | |
| 460 | + | } | |
| 416 | 461 | if r.revisionExhaustedLocked() { | |
| 417 | 462 | return fmt.Errorf("broker: membership replacement: %w", ports.ErrBrokerRevisionExhausted) | |
| 418 | 463 | } | |
| @@ -436,6 +481,9 @@ func (r *Registry) setHosts(records []ports.BrokerHostRecord) error { | |||
| 436 | 481 | if len(records) > ports.BrokerMaxHosts { | |
| 437 | 482 | return fmt.Errorf("broker: %d host registrations exceed the limit of %d", len(records), ports.BrokerMaxHosts) | |
| 438 | 483 | } | |
| 484 | + | if len(records) > 0 && !r.observationDisabled && nilDependency(r.probe) { | |
| 485 | + | return errors.New("broker: remote hosts require a host probe") | |
| 486 | + | } | |
| 439 | 487 | next := make(map[string]ports.BrokerDaemonObservation, len(records)) | |
| 440 | 488 | order := make([]string, 0, len(records)) | |
| 441 | 489 | for rank, record := range records { | |
| @@ -608,7 +656,15 @@ func (r *Registry) Run(ctx context.Context) { | |||
| 608 | 656 | func (r *Registry) settle() { | |
| 609 | 657 | r.mu.Lock() | |
| 610 | 658 | r.cancelAllInflightLocked() | |
| 659 | + | if r.localAttempt != nil { | |
| 660 | + | r.localAttempt.cancel() | |
| 661 | + | r.localAttempt = nil | |
| 662 | + | } | |
| 611 | 663 | cleared := false | |
| 664 | + | if r.local != nil && r.localHost.Checking { | |
| 665 | + | r.localHost.Checking = false | |
| 666 | + | cleared = true | |
| 667 | + | } | |
| 612 | 668 | for endpoint, host := range r.hosts { | |
| 613 | 669 | if !host.Checking { | |
| 614 | 670 | continue | |
| @@ -645,6 +701,9 @@ type probeResult struct { | |||
| 645 | 701 | snapshot ports.BrokerDaemonObservation | |
| 646 | 702 | err error | |
| 647 | 703 | at time.Time | |
| 704 | + | // local marks the result of one local probe attempt, which carries no | |
| 705 | + | // endpoint or registration and is applied to the configured local entry. | |
| 706 | + | local bool | |
| 648 | 707 | } | |
| 649 | 708 | ||
| 650 | 709 | func (r *Registry) dispatch(ctx context.Context, now time.Time, results chan<- probeResult) { | |
| @@ -682,6 +741,9 @@ func (r *Registry) dispatch(ctx context.Context, now time.Time, results chan<- p | |||
| 682 | 741 | // Only an observation that actually started changes the projection; an | |
| 683 | 742 | // idle dispatch must not bump the revision or wake subscribers. The | |
| 684 | 743 | // in-flight projection is never persisted: Checking is transient. | |
| 744 | + | if r.dispatchLocalLocked(ctx, now, results) { | |
| 745 | + | started = true | |
| 746 | + | } | |
| 685 | 747 | if started { | |
| 686 | 748 | r.publishLocked(false) | |
| 687 | 749 | } | |
| @@ -691,6 +753,10 @@ func (r *Registry) dispatch(ctx context.Context, now time.Time, results chan<- p | |||
| 691 | 753 | func (r *Registry) apply(result probeResult) { | |
| 692 | 754 | r.mu.Lock() | |
| 693 | 755 | defer r.mu.Unlock() | |
| 756 | + | if result.local { | |
| 757 | + | r.applyLocal(result) | |
| 758 | + | return | |
| 759 | + | } | |
| 694 | 760 | current, ok := r.hosts[result.endpoint] | |
| 695 | 761 | attempt := r.inflight[result.endpoint] | |
| 696 | 762 | if !ok || !current.Registration.Equal(result.registration) || attempt == nil || attempt.token != result.attempt { | |
| @@ -834,6 +900,14 @@ func (r *Registry) nextDelay(now time.Time) time.Duration { | |||
| 834 | 900 | r.mu.Lock() | |
| 835 | 901 | defer r.mu.Unlock() | |
| 836 | 902 | var earliest time.Time | |
| 903 | + | if r.local != nil && r.localAttempt == nil { | |
| 904 | + | if r.localHost.NextDue.IsZero() { | |
| 905 | + | return 0 | |
| 906 | + | } | |
| 907 | + | if earliest.IsZero() || r.localHost.NextDue.Before(earliest) { | |
| 908 | + | earliest = r.localHost.NextDue | |
| 909 | + | } | |
| 910 | + | } | |
| 837 | 911 | for endpoint, host := range r.hosts { | |
| 838 | 912 | // An observation already in flight is completed by its result, never | |
| 839 | 913 | // by the schedule; waiting on it here would spin the run loop. | |
| @@ -958,10 +1032,14 @@ func (r *Registry) publishLocked(persist bool) { | |||
| 958 | 1032 | if r.revisionExhaustedLocked() { | |
| 959 | 1033 | return | |
| 960 | 1034 | } | |
| 961 | - | // Remote daemons are published in registration order. The local daemon, | |
| 962 | - | // when a producer exists (Plan 001 P5.3a), is prepended ahead of these; | |
| 963 | - | // the registry produces no local observation yet and never invents one. | |
| 964 | - | daemons := make([]ports.BrokerDaemonObservation, 0, len(r.hosts)) | |
| 1035 | + | // Remote daemons are published in registration order. The broker's own | |
| 1036 | + | // machine daemon, when a producer is configured (Plan 001 P5.3a), is the | |
| 1037 | + | // prepended entry at index zero; without a producer the registry produces no | |
| 1038 | + | // local observation and never invents one. | |
| 1039 | + | daemons := make([]ports.BrokerDaemonObservation, 0, len(r.hosts)+1) | |
| 1040 | + | if r.local != nil { | |
| 1041 | + | daemons = append(daemons, r.localHost.Clone()) | |
| 1042 | + | } | |
| 965 | 1043 | for _, endpoint := range r.order { | |
| 966 | 1044 | host, ok := r.hosts[endpoint] | |
| 967 | 1045 | if !ok { | |
| @@ -48,6 +48,11 @@ type RegistryConfig struct { | |||||
| 48 | // expected to be immutable for the run; a caller that needs observation | 48 | // expected to be immutable for the run; a caller that needs observation | ||
| 49 | // keeps the zero-value config. | 49 | // keeps the zero-value config. | ||
| 50 | ObservationDisabled bool | 50 | ObservationDisabled bool | ||
| 51 | + | // Local, when non-nil, enables observation of the broker's own machine | |||
| 52 | + | // daemon. The registry then publishes the local entry at index zero, ahead | |||
| 53 | + | // of every remote, and never persists it. Local is refused together with | |||
| 54 | + | // ObservationDisabled: a read-only registry owns no local producer. | |||
| 55 | + | Local *LocalObservation | |||
| 51 | } | 56 | } | ||
| 52 | 57 | ||||
| 53 | // Registry owns configured hosts and their immutable observation projection. | 58 | // Registry owns configured hosts and their immutable observation projection. | ||
| @@ -102,6 +107,17 @@ type Registry struct { | |||||
| 102 | wake chan struct{} | 107 | wake chan struct{} | ||
| 103 | running atomic.Int32 | 108 | running atomic.Int32 | ||
| 104 | 109 | ||||
| 110 | + | // local selects observation of the broker's own machine daemon. It is nil | |||
| 111 | + | // for a remote-only registry. localHost is the configured local authority | |||
| 112 | + | // stamped onto every publication (Local, DisplayOrigin, Policy, Rank) plus | |||
| 113 | + | // the observed state a probe supplies; localAttempt is the one admitted | |||
| 114 | + | // local probe. The local entry is never durable and is never invented: until | |||
| 115 | + | // a probe answers, the configured authority is published with unknown | |||
| 116 | + | // availability and zero observed identity. | |||
| 117 | + | local *LocalObservation | |||
| 118 | + | localHost ports.BrokerDaemonObservation | |||
| 119 | + | localAttempt *probeAttempt | |||
| 120 | + | ||||
| 105 | freshFor time.Duration | 121 | freshFor time.Duration | ||
| 106 | retryBase time.Duration | 122 | retryBase time.Duration | ||
| 107 | retryMax time.Duration | 123 | retryMax time.Duration | ||
| @@ -123,13 +139,19 @@ func NewRegistry(epoch ports.BrokerEpoch, store ports.BrokerHostStore, probe por | |||||
| 123 | 139 | ||||
| 124 | // NewRegistryWithConfig restores a validated durable snapshot under a fresh | 140 | // NewRegistryWithConfig restores a validated durable snapshot under a fresh | ||
| 125 | // broker epoch and selects the run mode from cfg. Observation-disabled mode | 141 | // broker epoch and selects the run mode from cfg. Observation-disabled mode | ||
| 126 | - | // tolerates a nil probe because such a registry never probes; every other | 142 | + | // tolerates a nil probe because such a registry never probes, and a registry |
| 127 | - | // dependency is required exactly as NewRegistry requires it. | 143 | + | // configured with a local observation producer (Local) tolerates a nil remote |
| 144 | + | // probe as long as it owns no remote membership. Every other dependency is | |||
| 145 | + | // required exactly as NewRegistry requires it. | |||
| 128 | func NewRegistryWithConfig(epoch ports.BrokerEpoch, store ports.BrokerHostStore, probe ports.BrokerHostProbe, clock ports.Clock, log *slog.Logger, cfg RegistryConfig) (*Registry, error) { | 146 | func NewRegistryWithConfig(epoch ports.BrokerEpoch, store ports.BrokerHostStore, probe ports.BrokerHostProbe, clock ports.Clock, log *slog.Logger, cfg RegistryConfig) (*Registry, error) { | ||
| 129 | if epoch == 0 || nilDependency(store) || nilDependency(clock) { | 147 | if epoch == 0 || nilDependency(store) || nilDependency(clock) { | ||
| 130 | return nil, errors.New("broker: invalid registry dependencies") | 148 | return nil, errors.New("broker: invalid registry dependencies") | ||
| 131 | } | 149 | } | ||
| 132 | - | if !cfg.ObservationDisabled && nilDependency(probe) { | 150 | + | // A remote probe is required to observe remotes, but a registry that |
| 151 | + | // observes only its own machine daemon (Local) may omit it. Such a registry | |||
| 152 | + | // never owns a remote host: projectMembership and the mutation paths refuse | |||
| 153 | + | // remote membership rather than silently never probing it. | |||
| 154 | + | if !cfg.ObservationDisabled && nilDependency(probe) && cfg.Local == nil { | |||
| 133 | return nil, errors.New("broker: invalid registry dependencies") | 155 | return nil, errors.New("broker: invalid registry dependencies") | ||
| 134 | } | 156 | } | ||
| 135 | if log == nil { | 157 | if log == nil { | ||
| @@ -144,6 +166,23 @@ func NewRegistryWithConfig(epoch ports.BrokerEpoch, store ports.BrokerHostStore, | |||||
| 144 | freshFor: defaultFreshFor, retryBase: defaultRetryBase, retryMax: defaultRetryLimit, | 166 | freshFor: defaultFreshFor, retryBase: defaultRetryBase, retryMax: defaultRetryLimit, | ||
| 145 | jitter: jittered, | 167 | jitter: jittered, | ||
| 146 | } | 168 | } | ||
| 169 | + | if cfg.ObservationDisabled && cfg.Local != nil { | |||
| 170 | + | return nil, errors.New("broker: observation-disabled registry cannot observe the local daemon") | |||
| 171 | + | } | |||
| 172 | + | if cfg.Local != nil { | |||
| 173 | + | if err := cfg.Local.validate(); err != nil { | |||
| 174 | + | return nil, err | |||
| 175 | + | } | |||
| 176 | + | // Copy the configured authority: the registry reads it from the probe | |||
| 177 | + | // and publish paths, and a caller that kept the pointer could otherwise | |||
| 178 | + | // mutate authority after construction. | |||
| 179 | + | local := *cfg.Local | |||
| 180 | + | r.local = &local | |||
| 181 | + | r.localHost = ports.BrokerDaemonObservation{Local: true, DisplayOrigin: cfg.Local.DisplayOrigin, Policy: cfg.Local.Policy, Availability: domain.RemoteAvailabilityUnknown} | |||
| 182 | + | if err := r.localHost.Validate(); err != nil { | |||
| 183 | + | return nil, fmt.Errorf("broker: invalid local observation authority: %w", err) | |||
| 184 | + | } | |||
| 185 | + | } | |||
| 147 | r.store = newSnapshotWriter(store, log) | 186 | r.store = newSnapshotWriter(store, log) | ||
| 148 | snapshot, err := store.Load() | 187 | snapshot, err := store.Load() | ||
| 149 | if err != nil { | 188 | if err != nil { | ||
| @@ -193,6 +232,9 @@ func (r *Registry) projectMembership(hosts ports.BrokerHosts) error { | |||||
| 193 | if err := hosts.Validate(); err != nil { | 232 | if err := hosts.Validate(); err != nil { | ||
| 194 | return err | 233 | return err | ||
| 195 | } | 234 | } | ||
| 235 | + | if len(hosts.Hosts) > 0 && !r.observationDisabled && nilDependency(r.probe) { | |||
| 236 | + | return errors.New("broker: remote hosts require a host probe") | |||
| 237 | + | } | |||
| 196 | next := make(map[string]ports.BrokerDaemonObservation, len(hosts.Hosts)) | 238 | next := make(map[string]ports.BrokerDaemonObservation, len(hosts.Hosts)) | ||
| 197 | order := make([]string, 0, len(hosts.Hosts)) | 239 | order := make([]string, 0, len(hosts.Hosts)) | ||
| 198 | for rank, record := range hosts.Hosts { | 240 | for rank, record := range hosts.Hosts { | ||
| @@ -413,6 +455,9 @@ func (r *Registry) ReplaceHosts(records []ports.BrokerHostRecord) error { | |||||
| 413 | if r.running.Load() == runStopped { | 455 | if r.running.Load() == runStopped { | ||
| 414 | return fmt.Errorf("broker: membership replacement: %w", ports.ErrBrokerRegistryClosed) | 456 | return fmt.Errorf("broker: membership replacement: %w", ports.ErrBrokerRegistryClosed) | ||
| 415 | } | 457 | } | ||
| 458 | + | if len(records) > 0 && !r.observationDisabled && nilDependency(r.probe) { | |||
| 459 | + | return errors.New("broker: remote hosts require a host probe") | |||
| 460 | + | } | |||
| 416 | if r.revisionExhaustedLocked() { | 461 | if r.revisionExhaustedLocked() { | ||
| 417 | return fmt.Errorf("broker: membership replacement: %w", ports.ErrBrokerRevisionExhausted) | 462 | return fmt.Errorf("broker: membership replacement: %w", ports.ErrBrokerRevisionExhausted) | ||
| 418 | } | 463 | } | ||
| @@ -436,6 +481,9 @@ func (r *Registry) setHosts(records []ports.BrokerHostRecord) error { | |||||
| 436 | if len(records) > ports.BrokerMaxHosts { | 481 | if len(records) > ports.BrokerMaxHosts { | ||
| 437 | return fmt.Errorf("broker: %d host registrations exceed the limit of %d", len(records), ports.BrokerMaxHosts) | 482 | return fmt.Errorf("broker: %d host registrations exceed the limit of %d", len(records), ports.BrokerMaxHosts) | ||
| 438 | } | 483 | } | ||
| 484 | + | if len(records) > 0 && !r.observationDisabled && nilDependency(r.probe) { | |||
| 485 | + | return errors.New("broker: remote hosts require a host probe") | |||
| 486 | + | } | |||
| 439 | next := make(map[string]ports.BrokerDaemonObservation, len(records)) | 487 | next := make(map[string]ports.BrokerDaemonObservation, len(records)) | ||
| 440 | order := make([]string, 0, len(records)) | 488 | order := make([]string, 0, len(records)) | ||
| 441 | for rank, record := range records { | 489 | for rank, record := range records { | ||
| @@ -608,7 +656,15 @@ func (r *Registry) Run(ctx context.Context) { | |||||
| 608 | func (r *Registry) settle() { | 656 | func (r *Registry) settle() { | ||
| 609 | r.mu.Lock() | 657 | r.mu.Lock() | ||
| 610 | r.cancelAllInflightLocked() | 658 | r.cancelAllInflightLocked() | ||
| 659 | + | if r.localAttempt != nil { | |||
| 660 | + | r.localAttempt.cancel() | |||
| 661 | + | r.localAttempt = nil | |||
| 662 | + | } | |||
| 611 | cleared := false | 663 | cleared := false | ||
| 664 | + | if r.local != nil && r.localHost.Checking { | |||
| 665 | + | r.localHost.Checking = false | |||
| 666 | + | cleared = true | |||
| 667 | + | } | |||
| 612 | for endpoint, host := range r.hosts { | 668 | for endpoint, host := range r.hosts { | ||
| 613 | if !host.Checking { | 669 | if !host.Checking { | ||
| 614 | continue | 670 | continue | ||
| @@ -645,6 +701,9 @@ type probeResult struct { | |||||
| 645 | snapshot ports.BrokerDaemonObservation | 701 | snapshot ports.BrokerDaemonObservation | ||
| 646 | err error | 702 | err error | ||
| 647 | at time.Time | 703 | at time.Time | ||
| 704 | + | // local marks the result of one local probe attempt, which carries no | |||
| 705 | + | // endpoint or registration and is applied to the configured local entry. | |||
| 706 | + | local bool | |||
| 648 | } | 707 | } | ||
| 649 | 708 | ||||
| 650 | func (r *Registry) dispatch(ctx context.Context, now time.Time, results chan<- probeResult) { | 709 | func (r *Registry) dispatch(ctx context.Context, now time.Time, results chan<- probeResult) { | ||
| @@ -682,6 +741,9 @@ func (r *Registry) dispatch(ctx context.Context, now time.Time, results chan<- p | |||||
| 682 | // Only an observation that actually started changes the projection; an | 741 | // Only an observation that actually started changes the projection; an | ||
| 683 | // idle dispatch must not bump the revision or wake subscribers. The | 742 | // idle dispatch must not bump the revision or wake subscribers. The | ||
| 684 | // in-flight projection is never persisted: Checking is transient. | 743 | // in-flight projection is never persisted: Checking is transient. | ||
| 744 | + | if r.dispatchLocalLocked(ctx, now, results) { | |||
| 745 | + | started = true | |||
| 746 | + | } | |||
| 685 | if started { | 747 | if started { | ||
| 686 | r.publishLocked(false) | 748 | r.publishLocked(false) | ||
| 687 | } | 749 | } | ||
| @@ -691,6 +753,10 @@ func (r *Registry) dispatch(ctx context.Context, now time.Time, results chan<- p | |||||
| 691 | func (r *Registry) apply(result probeResult) { | 753 | func (r *Registry) apply(result probeResult) { | ||
| 692 | r.mu.Lock() | 754 | r.mu.Lock() | ||
| 693 | defer r.mu.Unlock() | 755 | defer r.mu.Unlock() | ||
| 756 | + | if result.local { | |||
| 757 | + | r.applyLocal(result) | |||
| 758 | + | return | |||
| 759 | + | } | |||
| 694 | current, ok := r.hosts[result.endpoint] | 760 | current, ok := r.hosts[result.endpoint] | ||
| 695 | attempt := r.inflight[result.endpoint] | 761 | attempt := r.inflight[result.endpoint] | ||
| 696 | if !ok || !current.Registration.Equal(result.registration) || attempt == nil || attempt.token != result.attempt { | 762 | if !ok || !current.Registration.Equal(result.registration) || attempt == nil || attempt.token != result.attempt { | ||
| @@ -834,6 +900,14 @@ func (r *Registry) nextDelay(now time.Time) time.Duration { | |||||
| 834 | r.mu.Lock() | 900 | r.mu.Lock() | ||
| 835 | defer r.mu.Unlock() | 901 | defer r.mu.Unlock() | ||
| 836 | var earliest time.Time | 902 | var earliest time.Time | ||
| 903 | + | if r.local != nil && r.localAttempt == nil { | |||
| 904 | + | if r.localHost.NextDue.IsZero() { | |||
| 905 | + | return 0 | |||
| 906 | + | } | |||
| 907 | + | if earliest.IsZero() || r.localHost.NextDue.Before(earliest) { | |||
| 908 | + | earliest = r.localHost.NextDue | |||
| 909 | + | } | |||
| 910 | + | } | |||
| 837 | for endpoint, host := range r.hosts { | 911 | for endpoint, host := range r.hosts { | ||
| 838 | // An observation already in flight is completed by its result, never | 912 | // An observation already in flight is completed by its result, never | ||
| 839 | // by the schedule; waiting on it here would spin the run loop. | 913 | // by the schedule; waiting on it here would spin the run loop. | ||
| @@ -958,10 +1032,14 @@ func (r *Registry) publishLocked(persist bool) { | |||||
| 958 | if r.revisionExhaustedLocked() { | 1032 | if r.revisionExhaustedLocked() { | ||
| 959 | return | 1033 | return | ||
| 960 | } | 1034 | } | ||
| 961 | - | // Remote daemons are published in registration order. The local daemon, | 1035 | + | // Remote daemons are published in registration order. The broker's own |
| 962 | - | // when a producer exists (Plan 001 P5.3a), is prepended ahead of these; | 1036 | + | // machine daemon, when a producer is configured (Plan 001 P5.3a), is the |
| 963 | - | // the registry produces no local observation yet and never invents one. | 1037 | + | // prepended entry at index zero; without a producer the registry produces no |
| 964 | - | daemons := make([]ports.BrokerDaemonObservation, 0, len(r.hosts)) | 1038 | + | // local observation and never invents one. |
| 1039 | + | daemons := make([]ports.BrokerDaemonObservation, 0, len(r.hosts)+1) | |||
| 1040 | + | if r.local != nil { | |||
| 1041 | + | daemons = append(daemons, r.localHost.Clone()) | |||
| 1042 | + | } | |||
| 965 | for _, endpoint := range r.order { | 1043 | for _, endpoint := range r.order { | ||
| 966 | host, ok := r.hosts[endpoint] | 1044 | host, ok := r.hosts[endpoint] | ||
| 967 | if !ok { | 1045 | if !ok { | ||
A internal/usecase/broker/registry_local.go +199 -0
| @@ -0,0 +1,199 @@ | |||
| 1 | + | package broker | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | "github.com/bnema/vev/internal/protocol" | |
| 12 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // Broker-owned local observation (Plan 001 P5.3a). | |
| 16 | + | // | |
| 17 | + | // The broker's own machine daemon is not a configured host: it has no | |
| 18 | + | // endpoint, no registration, and no durable membership. It is observed through | |
| 19 | + | // the same probe lifecycle as a remote host, but its configured authority | |
| 20 | + | // (display origin and policy) is supplied by the composition and its entry is | |
| 21 | + | // always published first, at index zero, and never persisted. Observing the | |
| 22 | + | // local daemon only reads: the probe dials the broker-owned local route and | |
| 23 | + | // completes the daemonmux physical preamble, so it never opens a logical | |
| 24 | + | // session stream (never attaches) and never starts a stopped daemon. | |
| 25 | + | ||
| 26 | + | // localProbeKey seeds the deterministic jitter for the local probe schedule. | |
| 27 | + | // It is a fixed token, so local retry and refresh jitter is reproducible and | |
| 28 | + | // independent of the configured remotes. A remote endpoint spelled "local" | |
| 29 | + | // shares the seed, which affects only the jitter spread and never identity. | |
| 30 | + | const localProbeKey = "local" | |
| 31 | + | ||
| 32 | + | // LocalProbe observes the broker's own machine daemon without attaching to it | |
| 33 | + | // or starting a stopped daemon. It reports only observed state: the observed | |
| 34 | + | // daemon identity, incarnation, protocol version, capabilities, availability, | |
| 35 | + | // failure, freshness, and session inventory. Configured authority (the local | |
| 36 | + | // flag, display origin, policy, and rank) is stamped by the registry and never | |
| 37 | + | // trusted from the probe; unknown identity, version, or inventory is zero and | |
| 38 | + | // is never invented. The returned observation must satisfy | |
| 39 | + | // ports.BrokerDaemonObservation.Validate once the registry stamps authority. | |
| 40 | + | // | |
| 41 | + | // ProbeLocal must honor cancellation: the registry cancels the attempt context | |
| 42 | + | // when the run settles, and a probe that ignores cancellation delays shutdown. | |
| 43 | + | type LocalProbe interface { | |
| 44 | + | ProbeLocal(ctx context.Context) (ports.BrokerDaemonObservation, error) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // LocalObservation configures observation of the broker's own machine daemon. | |
| 48 | + | // The display origin and policy are configured authority: the registry stamps | |
| 49 | + | // them onto every local publication and never reads them from a probe. The | |
| 50 | + | // probe is the sole observed-state source. | |
| 51 | + | type LocalObservation struct { | |
| 52 | + | // DisplayOrigin is the presentation hint published for the local entry. It | |
| 53 | + | // is a display value only and never routing authority. | |
| 54 | + | DisplayOrigin string | |
| 55 | + | // Policy is the exact connection policy of the local binding. | |
| 56 | + | Policy ports.BrokerPolicy | |
| 57 | + | // Probe observes the local daemon. It is required. | |
| 58 | + | Probe LocalProbe | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // validate refuses a local observation the registry could not publish or fence. | |
| 62 | + | func (l *LocalObservation) validate() error { | |
| 63 | + | if l == nil { | |
| 64 | + | return nil | |
| 65 | + | } | |
| 66 | + | if nilDependency(l.Probe) { | |
| 67 | + | return errors.New("broker: local observation has no probe") | |
| 68 | + | } | |
| 69 | + | if err := l.Policy.Validate(); err != nil { | |
| 70 | + | return fmt.Errorf("broker: local observation authority: %w", err) | |
| 71 | + | } | |
| 72 | + | if l.DisplayOrigin == "" || ports.SanitizeBrokerDisplayText(l.DisplayOrigin) != l.DisplayOrigin { | |
| 73 | + | return errors.New("broker: local observation has an invalid display origin") | |
| 74 | + | } | |
| 75 | + | return nil | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // dispatchLocalLocked admits at most one local probe attempt when its schedule | |
| 79 | + | // says it is due. Callers must hold r.mu. It reports whether an attempt | |
| 80 | + | // started, so dispatch publishes the transient Checking state exactly once. | |
| 81 | + | func (r *Registry) dispatchLocalLocked(ctx context.Context, now time.Time, results chan<- probeResult) bool { | |
| 82 | + | if r.local == nil || r.localAttempt != nil { | |
| 83 | + | return false | |
| 84 | + | } | |
| 85 | + | if !r.localHost.NextDue.IsZero() && now.Before(r.localHost.NextDue) { | |
| 86 | + | return false | |
| 87 | + | } | |
| 88 | + | r.attempts++ | |
| 89 | + | attempt := r.attempts | |
| 90 | + | probeCtx, cancel := context.WithCancel(ctx) | |
| 91 | + | r.localAttempt = &probeAttempt{token: attempt, cancel: cancel} | |
| 92 | + | r.localHost.Checking = true | |
| 93 | + | r.localHost.LastAttempt = now | |
| 94 | + | go func(attempt uint64, probeCtx context.Context) { | |
| 95 | + | snapshot, err := r.local.Probe.ProbeLocal(probeCtx) | |
| 96 | + | result := probeResult{local: true, attempt: attempt, snapshot: snapshot, err: err, at: r.clock.Now()} | |
| 97 | + | select { | |
| 98 | + | case results <- result: | |
| 99 | + | case <-probeCtx.Done(): | |
| 100 | + | // The attempt was retired (run settled), so its completion is | |
| 101 | + | // already fenced: dropping it keeps a settled registry from | |
| 102 | + | // queueing a result the run loop would only discard. | |
| 103 | + | } | |
| 104 | + | }(attempt, probeCtx) | |
| 105 | + | return true | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // applyLocal adopts one completed local probe. Callers must hold r.mu, exactly | |
| 109 | + | // as Registry.apply requires. A stale attempt touches nothing. A confirmed | |
| 110 | + | // reachable result publishes the observed state with configured authority | |
| 111 | + | // stamped on top; every other outcome publishes a typed failure and keeps the | |
| 112 | + | // last-known identity and inventory, so an unreachable local daemon never | |
| 113 | + | // reports an invented identity and its availability is never zero. | |
| 114 | + | func (r *Registry) applyLocal(result probeResult) { | |
| 115 | + | if r.local == nil { | |
| 116 | + | return | |
| 117 | + | } | |
| 118 | + | attempt := r.localAttempt | |
| 119 | + | if attempt == nil || attempt.token != result.attempt { | |
| 120 | + | return | |
| 121 | + | } | |
| 122 | + | r.localAttempt = nil | |
| 123 | + | // The probe has returned, so releasing its derived context here only frees | |
| 124 | + | // the attempt's resources; the record is gone before any new attempt can | |
| 125 | + | // start. | |
| 126 | + | attempt.cancel() | |
| 127 | + | ||
| 128 | + | current := r.localHost | |
| 129 | + | current.Checking = false | |
| 130 | + | current.LastAttempt = result.at | |
| 131 | + | ||
| 132 | + | kind, availability, failed := observationOutcome(result) | |
| 133 | + | if !failed { | |
| 134 | + | observed := stampLocalAuthority(result.snapshot.Clone(), current) | |
| 135 | + | observed.Checking = false | |
| 136 | + | observed.LastAttempt = result.at | |
| 137 | + | observed.LastSuccess = result.at | |
| 138 | + | observed.NextDue = result.at.Add(r.jitter(r.freshFor, localProbeKey, result.attempt)) | |
| 139 | + | observed.ConsecutiveFailures = 0 | |
| 140 | + | observed.LastFailure = domain.RemoteFailure{} | |
| 141 | + | observed.FailureEpisode = current.FailureEpisode | |
| 142 | + | // The published local observation must be fully valid once authority is | |
| 143 | + | // stamped. A probe that invents partial identity or an invalid session | |
| 144 | + | // catalogue is an invalid response, never a published observation. | |
| 145 | + | if err := observed.Validate(); err != nil { | |
| 146 | + | r.log.Debug("broker: local observation is invalid", "err", err) | |
| 147 | + | kind, availability, failed = domain.RemoteFailureInvalidResponse, domain.RemoteAvailabilityInvalidResponse, true | |
| 148 | + | } else if err := validateLocalObservation(observed); err != nil { | |
| 149 | + | r.log.Debug("broker: local observation is not publishable", "err", err) | |
| 150 | + | kind, availability, failed = domain.RemoteFailureInvalidResponse, domain.RemoteAvailabilityInvalidResponse, true | |
| 151 | + | } else { | |
| 152 | + | r.localHost = observed | |
| 153 | + | r.publishLocked(true) | |
| 154 | + | return | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | // Every non-reachable outcome is a typed failure on the capped retry | |
| 158 | + | // cadence: the observed identity, last-known inventory, and failure episode | |
| 159 | + | // counter stay authoritative. | |
| 160 | + | current.Availability = availability | |
| 161 | + | if current.ConsecutiveFailures == 0 { | |
| 162 | + | current.FailureEpisode++ | |
| 163 | + | } | |
| 164 | + | current.ConsecutiveFailures++ | |
| 165 | + | current.LastFailure = domain.RemoteFailure{Kind: kind, Err: result.err} | |
| 166 | + | current.NextDue = result.at.Add(r.jitter(r.retryDelay(current.ConsecutiveFailures), localProbeKey, result.attempt)) | |
| 167 | + | r.localHost = current | |
| 168 | + | r.publishLocked(true) | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | // stampLocalAuthority copies the configured local authority onto a fresh probe | |
| 172 | + | // result. Probe results supply observed state only, so their local flag, | |
| 173 | + | // endpoint, registration, display origin, policy, and rank fields are discarded | |
| 174 | + | // wholesale: a hostile probe can never promote itself to local authority or | |
| 175 | + | // borrow a remote endpoint. | |
| 176 | + | func stampLocalAuthority(observation, authority ports.BrokerDaemonObservation) ports.BrokerDaemonObservation { | |
| 177 | + | observation.Local = true | |
| 178 | + | observation.Endpoint = "" | |
| 179 | + | observation.Registration = domain.RemoteRegistration{} | |
| 180 | + | observation.DisplayOrigin = authority.DisplayOrigin | |
| 181 | + | observation.Policy = authority.Policy | |
| 182 | + | observation.Rank = authority.Rank | |
| 183 | + | return observation | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // validateLocalObservation re-checks the catalogue shape of a local session | |
| 187 | + | // inventory. The durable projection rule cannot apply to a local observation | |
| 188 | + | // (the local daemon is never durable and has no registration incarnation), so | |
| 189 | + | // the catalogue itself is validated directly against the current schema. | |
| 190 | + | func validateLocalObservation(observation ports.BrokerDaemonObservation) error { | |
| 191 | + | if len(observation.Sessions) == 0 { | |
| 192 | + | return nil | |
| 193 | + | } | |
| 194 | + | return catalogue.ValidateRemoteCatalog(catalogue.RemoteCatalog{ | |
| 195 | + | ProtocolVersion: protocol.Version, | |
| 196 | + | SchemaVersion: catalogue.RemoteCatalogSchemaVersion, | |
| 197 | + | Sessions: observation.Sessions, | |
| 198 | + | }) | |
| 199 | + | } | |
| @@ -0,0 +1,199 @@ | |||||
| 1 | + | package broker | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/domain" | |||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | + | "github.com/bnema/vev/internal/protocol" | |||
| 12 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // Broker-owned local observation (Plan 001 P5.3a). | |||
| 16 | + | // | |||
| 17 | + | // The broker's own machine daemon is not a configured host: it has no | |||
| 18 | + | // endpoint, no registration, and no durable membership. It is observed through | |||
| 19 | + | // the same probe lifecycle as a remote host, but its configured authority | |||
| 20 | + | // (display origin and policy) is supplied by the composition and its entry is | |||
| 21 | + | // always published first, at index zero, and never persisted. Observing the | |||
| 22 | + | // local daemon only reads: the probe dials the broker-owned local route and | |||
| 23 | + | // completes the daemonmux physical preamble, so it never opens a logical | |||
| 24 | + | // session stream (never attaches) and never starts a stopped daemon. | |||
| 25 | + | ||||
| 26 | + | // localProbeKey seeds the deterministic jitter for the local probe schedule. | |||
| 27 | + | // It is a fixed token, so local retry and refresh jitter is reproducible and | |||
| 28 | + | // independent of the configured remotes. A remote endpoint spelled "local" | |||
| 29 | + | // shares the seed, which affects only the jitter spread and never identity. | |||
| 30 | + | const localProbeKey = "local" | |||
| 31 | + | ||||
| 32 | + | // LocalProbe observes the broker's own machine daemon without attaching to it | |||
| 33 | + | // or starting a stopped daemon. It reports only observed state: the observed | |||
| 34 | + | // daemon identity, incarnation, protocol version, capabilities, availability, | |||
| 35 | + | // failure, freshness, and session inventory. Configured authority (the local | |||
| 36 | + | // flag, display origin, policy, and rank) is stamped by the registry and never | |||
| 37 | + | // trusted from the probe; unknown identity, version, or inventory is zero and | |||
| 38 | + | // is never invented. The returned observation must satisfy | |||
| 39 | + | // ports.BrokerDaemonObservation.Validate once the registry stamps authority. | |||
| 40 | + | // | |||
| 41 | + | // ProbeLocal must honor cancellation: the registry cancels the attempt context | |||
| 42 | + | // when the run settles, and a probe that ignores cancellation delays shutdown. | |||
| 43 | + | type LocalProbe interface { | |||
| 44 | + | ProbeLocal(ctx context.Context) (ports.BrokerDaemonObservation, error) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // LocalObservation configures observation of the broker's own machine daemon. | |||
| 48 | + | // The display origin and policy are configured authority: the registry stamps | |||
| 49 | + | // them onto every local publication and never reads them from a probe. The | |||
| 50 | + | // probe is the sole observed-state source. | |||
| 51 | + | type LocalObservation struct { | |||
| 52 | + | // DisplayOrigin is the presentation hint published for the local entry. It | |||
| 53 | + | // is a display value only and never routing authority. | |||
| 54 | + | DisplayOrigin string | |||
| 55 | + | // Policy is the exact connection policy of the local binding. | |||
| 56 | + | Policy ports.BrokerPolicy | |||
| 57 | + | // Probe observes the local daemon. It is required. | |||
| 58 | + | Probe LocalProbe | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // validate refuses a local observation the registry could not publish or fence. | |||
| 62 | + | func (l *LocalObservation) validate() error { | |||
| 63 | + | if l == nil { | |||
| 64 | + | return nil | |||
| 65 | + | } | |||
| 66 | + | if nilDependency(l.Probe) { | |||
| 67 | + | return errors.New("broker: local observation has no probe") | |||
| 68 | + | } | |||
| 69 | + | if err := l.Policy.Validate(); err != nil { | |||
| 70 | + | return fmt.Errorf("broker: local observation authority: %w", err) | |||
| 71 | + | } | |||
| 72 | + | if l.DisplayOrigin == "" || ports.SanitizeBrokerDisplayText(l.DisplayOrigin) != l.DisplayOrigin { | |||
| 73 | + | return errors.New("broker: local observation has an invalid display origin") | |||
| 74 | + | } | |||
| 75 | + | return nil | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // dispatchLocalLocked admits at most one local probe attempt when its schedule | |||
| 79 | + | // says it is due. Callers must hold r.mu. It reports whether an attempt | |||
| 80 | + | // started, so dispatch publishes the transient Checking state exactly once. | |||
| 81 | + | func (r *Registry) dispatchLocalLocked(ctx context.Context, now time.Time, results chan<- probeResult) bool { | |||
| 82 | + | if r.local == nil || r.localAttempt != nil { | |||
| 83 | + | return false | |||
| 84 | + | } | |||
| 85 | + | if !r.localHost.NextDue.IsZero() && now.Before(r.localHost.NextDue) { | |||
| 86 | + | return false | |||
| 87 | + | } | |||
| 88 | + | r.attempts++ | |||
| 89 | + | attempt := r.attempts | |||
| 90 | + | probeCtx, cancel := context.WithCancel(ctx) | |||
| 91 | + | r.localAttempt = &probeAttempt{token: attempt, cancel: cancel} | |||
| 92 | + | r.localHost.Checking = true | |||
| 93 | + | r.localHost.LastAttempt = now | |||
| 94 | + | go func(attempt uint64, probeCtx context.Context) { | |||
| 95 | + | snapshot, err := r.local.Probe.ProbeLocal(probeCtx) | |||
| 96 | + | result := probeResult{local: true, attempt: attempt, snapshot: snapshot, err: err, at: r.clock.Now()} | |||
| 97 | + | select { | |||
| 98 | + | case results <- result: | |||
| 99 | + | case <-probeCtx.Done(): | |||
| 100 | + | // The attempt was retired (run settled), so its completion is | |||
| 101 | + | // already fenced: dropping it keeps a settled registry from | |||
| 102 | + | // queueing a result the run loop would only discard. | |||
| 103 | + | } | |||
| 104 | + | }(attempt, probeCtx) | |||
| 105 | + | return true | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // applyLocal adopts one completed local probe. Callers must hold r.mu, exactly | |||
| 109 | + | // as Registry.apply requires. A stale attempt touches nothing. A confirmed | |||
| 110 | + | // reachable result publishes the observed state with configured authority | |||
| 111 | + | // stamped on top; every other outcome publishes a typed failure and keeps the | |||
| 112 | + | // last-known identity and inventory, so an unreachable local daemon never | |||
| 113 | + | // reports an invented identity and its availability is never zero. | |||
| 114 | + | func (r *Registry) applyLocal(result probeResult) { | |||
| 115 | + | if r.local == nil { | |||
| 116 | + | return | |||
| 117 | + | } | |||
| 118 | + | attempt := r.localAttempt | |||
| 119 | + | if attempt == nil || attempt.token != result.attempt { | |||
| 120 | + | return | |||
| 121 | + | } | |||
| 122 | + | r.localAttempt = nil | |||
| 123 | + | // The probe has returned, so releasing its derived context here only frees | |||
| 124 | + | // the attempt's resources; the record is gone before any new attempt can | |||
| 125 | + | // start. | |||
| 126 | + | attempt.cancel() | |||
| 127 | + | ||||
| 128 | + | current := r.localHost | |||
| 129 | + | current.Checking = false | |||
| 130 | + | current.LastAttempt = result.at | |||
| 131 | + | ||||
| 132 | + | kind, availability, failed := observationOutcome(result) | |||
| 133 | + | if !failed { | |||
| 134 | + | observed := stampLocalAuthority(result.snapshot.Clone(), current) | |||
| 135 | + | observed.Checking = false | |||
| 136 | + | observed.LastAttempt = result.at | |||
| 137 | + | observed.LastSuccess = result.at | |||
| 138 | + | observed.NextDue = result.at.Add(r.jitter(r.freshFor, localProbeKey, result.attempt)) | |||
| 139 | + | observed.ConsecutiveFailures = 0 | |||
| 140 | + | observed.LastFailure = domain.RemoteFailure{} | |||
| 141 | + | observed.FailureEpisode = current.FailureEpisode | |||
| 142 | + | // The published local observation must be fully valid once authority is | |||
| 143 | + | // stamped. A probe that invents partial identity or an invalid session | |||
| 144 | + | // catalogue is an invalid response, never a published observation. | |||
| 145 | + | if err := observed.Validate(); err != nil { | |||
| 146 | + | r.log.Debug("broker: local observation is invalid", "err", err) | |||
| 147 | + | kind, availability, failed = domain.RemoteFailureInvalidResponse, domain.RemoteAvailabilityInvalidResponse, true | |||
| 148 | + | } else if err := validateLocalObservation(observed); err != nil { | |||
| 149 | + | r.log.Debug("broker: local observation is not publishable", "err", err) | |||
| 150 | + | kind, availability, failed = domain.RemoteFailureInvalidResponse, domain.RemoteAvailabilityInvalidResponse, true | |||
| 151 | + | } else { | |||
| 152 | + | r.localHost = observed | |||
| 153 | + | r.publishLocked(true) | |||
| 154 | + | return | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | // Every non-reachable outcome is a typed failure on the capped retry | |||
| 158 | + | // cadence: the observed identity, last-known inventory, and failure episode | |||
| 159 | + | // counter stay authoritative. | |||
| 160 | + | current.Availability = availability | |||
| 161 | + | if current.ConsecutiveFailures == 0 { | |||
| 162 | + | current.FailureEpisode++ | |||
| 163 | + | } | |||
| 164 | + | current.ConsecutiveFailures++ | |||
| 165 | + | current.LastFailure = domain.RemoteFailure{Kind: kind, Err: result.err} | |||
| 166 | + | current.NextDue = result.at.Add(r.jitter(r.retryDelay(current.ConsecutiveFailures), localProbeKey, result.attempt)) | |||
| 167 | + | r.localHost = current | |||
| 168 | + | r.publishLocked(true) | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | // stampLocalAuthority copies the configured local authority onto a fresh probe | |||
| 172 | + | // result. Probe results supply observed state only, so their local flag, | |||
| 173 | + | // endpoint, registration, display origin, policy, and rank fields are discarded | |||
| 174 | + | // wholesale: a hostile probe can never promote itself to local authority or | |||
| 175 | + | // borrow a remote endpoint. | |||
| 176 | + | func stampLocalAuthority(observation, authority ports.BrokerDaemonObservation) ports.BrokerDaemonObservation { | |||
| 177 | + | observation.Local = true | |||
| 178 | + | observation.Endpoint = "" | |||
| 179 | + | observation.Registration = domain.RemoteRegistration{} | |||
| 180 | + | observation.DisplayOrigin = authority.DisplayOrigin | |||
| 181 | + | observation.Policy = authority.Policy | |||
| 182 | + | observation.Rank = authority.Rank | |||
| 183 | + | return observation | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | // validateLocalObservation re-checks the catalogue shape of a local session | |||
| 187 | + | // inventory. The durable projection rule cannot apply to a local observation | |||
| 188 | + | // (the local daemon is never durable and has no registration incarnation), so | |||
| 189 | + | // the catalogue itself is validated directly against the current schema. | |||
| 190 | + | func validateLocalObservation(observation ports.BrokerDaemonObservation) error { | |||
| 191 | + | if len(observation.Sessions) == 0 { | |||
| 192 | + | return nil | |||
| 193 | + | } | |||
| 194 | + | return catalogue.ValidateRemoteCatalog(catalogue.RemoteCatalog{ | |||
| 195 | + | ProtocolVersion: protocol.Version, | |||
| 196 | + | SchemaVersion: catalogue.RemoteCatalogSchemaVersion, | |||
| 197 | + | Sessions: observation.Sessions, | |||
| 198 | + | }) | |||
| 199 | + | } | |||
A internal/usecase/broker/registry_local_test.go +460 -0
| @@ -0,0 +1,460 @@ | |||
| 1 | + | package broker | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "sync/atomic" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/internal/domain" | |
| 13 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | + | "github.com/bnema/vev/internal/protocol" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // scriptedLocalProbe scripts one local observation per received call: the test | |
| 18 | + | // owns the completion, so the local probe never depends on wall time. | |
| 19 | + | type scriptedLocalProbe struct { | |
| 20 | + | calls chan localProbeCall | |
| 21 | + | canceled chan struct{} | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | type localProbeCall struct { | |
| 25 | + | result chan localProbeAnswer | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | type localProbeAnswer struct { | |
| 29 | + | snapshot ports.BrokerDaemonObservation | |
| 30 | + | err error | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func newScriptedLocalProbe(capacity int) *scriptedLocalProbe { | |
| 34 | + | return &scriptedLocalProbe{calls: make(chan localProbeCall, capacity), canceled: make(chan struct{}, 1)} | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func (p *scriptedLocalProbe) ProbeLocal(ctx context.Context) (ports.BrokerDaemonObservation, error) { | |
| 38 | + | call := localProbeCall{result: make(chan localProbeAnswer, 1)} | |
| 39 | + | select { | |
| 40 | + | case p.calls <- call: | |
| 41 | + | case <-ctx.Done(): | |
| 42 | + | p.noteCanceled() | |
| 43 | + | return ports.BrokerDaemonObservation{}, ctx.Err() | |
| 44 | + | } | |
| 45 | + | select { | |
| 46 | + | case answer := <-call.result: | |
| 47 | + | return answer.snapshot, answer.err | |
| 48 | + | case <-ctx.Done(): | |
| 49 | + | p.noteCanceled() | |
| 50 | + | return ports.BrokerDaemonObservation{}, ctx.Err() | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func (p *scriptedLocalProbe) noteCanceled() { | |
| 55 | + | select { | |
| 56 | + | case p.canceled <- struct{}{}: | |
| 57 | + | default: | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func receiveLocalCall(t *testing.T, probe *scriptedLocalProbe) localProbeCall { | |
| 62 | + | t.Helper() | |
| 63 | + | select { | |
| 64 | + | case call := <-probe.calls: | |
| 65 | + | return call | |
| 66 | + | case <-time.After(time.Second): | |
| 67 | + | t.Fatal("timed out waiting for local probe") | |
| 68 | + | return localProbeCall{} | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // newLocalTestRegistry builds a registry that observes the broker's own machine | |
| 73 | + | // daemon through localProbe, beside an optional remote probe. | |
| 74 | + | func newLocalTestRegistry(t *testing.T, store ports.BrokerHostStore, remoteProbe ports.BrokerHostProbe, localProbe LocalProbe, clock *manualClock) *Registry { | |
| 75 | + | t.Helper() | |
| 76 | + | registry, err := NewRegistryWithConfig(1, store, remoteProbe, clock, nil, RegistryConfig{ | |
| 77 | + | Local: &LocalObservation{DisplayOrigin: "local", Policy: poolPolicy(), Probe: localProbe}, | |
| 78 | + | }) | |
| 79 | + | require.NoError(t, err) | |
| 80 | + | registry.jitter = identityJitter | |
| 81 | + | return registry | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // waitLocal waits for the published local entry (always index zero) to satisfy | |
| 85 | + | // predicate and returns it. | |
| 86 | + | func waitLocal(t *testing.T, r *Registry, predicate func(ports.BrokerDaemonObservation) bool) ports.BrokerDaemonObservation { | |
| 87 | + | t.Helper() | |
| 88 | + | snapshot := waitSnapshot(t, r, func(snapshot ports.BrokerSnapshot) bool { | |
| 89 | + | return len(snapshot.Daemons) > 0 && snapshot.Daemons[0].Local && predicate(snapshot.Daemons[0]) | |
| 90 | + | }) | |
| 91 | + | return snapshot.Daemons[0] | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // TestRegistryPublishesObservedLocalDaemonFirst proves the local observation is | |
| 95 | + | // published at index zero ahead of every remote, carries the configured | |
| 96 | + | // authority plus the observed identity/incarnation/version, and is never | |
| 97 | + | // durable: the store persists the remote observations only. | |
| 98 | + | func TestRegistryPublishesObservedLocalDaemonFirst(t *testing.T) { | |
| 99 | + | clock := newManualClock(time.Unix(100, 0)) | |
| 100 | + | store := newTestStore() | |
| 101 | + | local := newScriptedLocalProbe(1) | |
| 102 | + | registry := newLocalTestRegistry(t, store, newTestProbe(1), local, clock) | |
| 103 | + | reg := registration(t, "user@host:22", 1) | |
| 104 | + | require.NoError(t, registry.setHosts(hostRecords(reg))) | |
| 105 | + | startRegistry(t, registry) | |
| 106 | + | ||
| 107 | + | call := receiveLocalCall(t, local) | |
| 108 | + | call.result <- localProbeAnswer{snapshot: ports.BrokerDaemonObservation{ | |
| 109 | + | Identity: "local-daemon", | |
| 110 | + | Incarnation: ports.BrokerDaemonIncarnation{9}, | |
| 111 | + | ProtocolVersion: protocol.Version, | |
| 112 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 113 | + | }} | |
| 114 | + | ||
| 115 | + | host := waitLocal(t, registry, func(o ports.BrokerDaemonObservation) bool { | |
| 116 | + | return !o.Checking && o.Availability == domain.RemoteAvailabilityReachable | |
| 117 | + | }) | |
| 118 | + | require.True(t, host.Local) | |
| 119 | + | require.Zero(t, host.Endpoint) | |
| 120 | + | require.Equal(t, domain.RemoteRegistration{}, host.Registration) | |
| 121 | + | require.Equal(t, "local", host.DisplayOrigin) | |
| 122 | + | require.Equal(t, poolPolicy(), host.Policy) | |
| 123 | + | require.Zero(t, host.Rank) | |
| 124 | + | require.Equal(t, ports.BrokerDaemonIdentity("local-daemon"), host.Identity) | |
| 125 | + | require.Equal(t, ports.BrokerDaemonIncarnation{9}, host.Incarnation) | |
| 126 | + | require.Equal(t, protocol.Version, host.ProtocolVersion) | |
| 127 | + | ||
| 128 | + | snapshot := registry.Snapshot() | |
| 129 | + | require.True(t, snapshot.Daemons[0].Local) | |
| 130 | + | require.Equal(t, reg.Endpoint, snapshot.Daemons[1].Endpoint) | |
| 131 | + | ||
| 132 | + | // Never durable: the store receives remote observations only, and the local | |
| 133 | + | // entry is dropped before persistence. | |
| 134 | + | stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool { | |
| 135 | + | return len(snapshot.Daemons) == 1 && !snapshot.Daemons[0].Local | |
| 136 | + | }) | |
| 137 | + | require.Equal(t, reg.Endpoint, stored.Daemons[0].Endpoint) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // TestRegistryLocalObservationNeverInventsState proves an unreachable local | |
| 141 | + | // daemon is published with the explicit unknown availability and zero observed | |
| 142 | + | // identity, and a hostile probe result cannot supply local authority, | |
| 143 | + | // endpoint, registration, policy, or display origin. | |
| 144 | + | func TestRegistryLocalObservationNeverInventsState(t *testing.T) { | |
| 145 | + | t.Run("failure keeps zero identity and typed availability", func(t *testing.T) { | |
| 146 | + | clock := newManualClock(time.Unix(100, 0)) | |
| 147 | + | local := newScriptedLocalProbe(1) | |
| 148 | + | registry := newLocalTestRegistry(t, newTestStore(), newTestProbe(1), local, clock) | |
| 149 | + | startRegistry(t, registry) | |
| 150 | + | ||
| 151 | + | call := receiveLocalCall(t, local) | |
| 152 | + | call.result <- localProbeAnswer{err: errors.New("local carriage refused")} | |
| 153 | + | ||
| 154 | + | host := waitLocal(t, registry, func(o ports.BrokerDaemonObservation) bool { | |
| 155 | + | return !o.Checking && o.Availability == domain.RemoteAvailabilityUnreachable | |
| 156 | + | }) | |
| 157 | + | require.True(t, host.Local) | |
| 158 | + | require.Zero(t, host.Endpoint) | |
| 159 | + | require.Equal(t, ports.BrokerDaemonIdentity(""), host.Identity) | |
| 160 | + | require.True(t, host.Incarnation.IsZero()) | |
| 161 | + | require.Zero(t, host.ProtocolVersion) | |
| 162 | + | require.Equal(t, domain.RemoteFailureTransport, host.LastFailure.Kind) | |
| 163 | + | require.Equal(t, 1, len(registry.Snapshot().Daemons)) | |
| 164 | + | }) | |
| 165 | + | ||
| 166 | + | t.Run("hostile probe cannot supply local authority", func(t *testing.T) { | |
| 167 | + | clock := newManualClock(time.Unix(100, 0)) | |
| 168 | + | local := newScriptedLocalProbe(1) | |
| 169 | + | registry := newLocalTestRegistry(t, newTestStore(), newTestProbe(1), local, clock) | |
| 170 | + | startRegistry(t, registry) | |
| 171 | + | ||
| 172 | + | hostilePolicy := poolPolicy() | |
| 173 | + | hostilePolicy.Trust = "attacker" | |
| 174 | + | call := receiveLocalCall(t, local) | |
| 175 | + | call.result <- localProbeAnswer{snapshot: ports.BrokerDaemonObservation{ | |
| 176 | + | Local: false, | |
| 177 | + | Endpoint: "attacker@evil", | |
| 178 | + | DisplayOrigin: "evil", | |
| 179 | + | Rank: 42, | |
| 180 | + | Registration: registration(t, "attacker@evil", 9), | |
| 181 | + | Policy: hostilePolicy, | |
| 182 | + | Identity: "local-daemon", | |
| 183 | + | Incarnation: ports.BrokerDaemonIncarnation{9}, | |
| 184 | + | ProtocolVersion: protocol.Version, | |
| 185 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 186 | + | }} | |
| 187 | + | ||
| 188 | + | host := waitLocal(t, registry, func(o ports.BrokerDaemonObservation) bool { | |
| 189 | + | return !o.Checking && o.Availability == domain.RemoteAvailabilityReachable | |
| 190 | + | }) | |
| 191 | + | require.True(t, host.Local) | |
| 192 | + | require.Zero(t, host.Endpoint) | |
| 193 | + | require.Equal(t, domain.RemoteRegistration{}, host.Registration) | |
| 194 | + | require.Equal(t, "local", host.DisplayOrigin) | |
| 195 | + | require.Equal(t, poolPolicy(), host.Policy) | |
| 196 | + | require.NotEqual(t, hostilePolicy, host.Policy) | |
| 197 | + | require.Zero(t, host.Rank) | |
| 198 | + | }) | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | // TestRegistryLocalObservationValidatesProbeResults proves an invalid observed | |
| 202 | + | // projection is classified as an invalid response, never published as an | |
| 203 | + | // observation the wire refuses: a partial identity (identity without a version) | |
| 204 | + | // is rejected without inventing the missing half. | |
| 205 | + | func TestRegistryLocalObservationValidatesProbeResults(t *testing.T) { | |
| 206 | + | clock := newManualClock(time.Unix(100, 0)) | |
| 207 | + | local := newScriptedLocalProbe(1) | |
| 208 | + | registry := newLocalTestRegistry(t, newTestStore(), newTestProbe(1), local, clock) | |
| 209 | + | startRegistry(t, registry) | |
| 210 | + | ||
| 211 | + | call := receiveLocalCall(t, local) | |
| 212 | + | call.result <- localProbeAnswer{snapshot: ports.BrokerDaemonObservation{ | |
| 213 | + | Identity: "local-daemon", // no protocol version: partial identity | |
| 214 | + | Incarnation: ports.BrokerDaemonIncarnation{9}, | |
| 215 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 216 | + | }} | |
| 217 | + | ||
| 218 | + | host := waitLocal(t, registry, func(o ports.BrokerDaemonObservation) bool { | |
| 219 | + | return !o.Checking && o.Availability == domain.RemoteAvailabilityInvalidResponse | |
| 220 | + | }) | |
| 221 | + | require.True(t, host.Local) | |
| 222 | + | require.Equal(t, domain.RemoteFailureInvalidResponse, host.LastFailure.Kind) | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | // TestRegistryLocalObservationConstruction pins the composition refusals: a | |
| 226 | + | // read-only registry owns no local producer, a local producer requires a probe, | |
| 227 | + | // and its policy and display origin are validated before anything is published. | |
| 228 | + | func TestRegistryLocalObservationConstruction(t *testing.T) { | |
| 229 | + | clock := newManualClock(time.Unix(100, 0)) | |
| 230 | + | valid := &LocalObservation{DisplayOrigin: "local", Policy: poolPolicy(), Probe: newScriptedLocalProbe(1)} | |
| 231 | + | ||
| 232 | + | invalidPolicy := poolPolicy() | |
| 233 | + | invalidPolicy.ProtocolVersion = 0 | |
| 234 | + | ||
| 235 | + | tests := []struct { | |
| 236 | + | name string | |
| 237 | + | cfg RegistryConfig | |
| 238 | + | remoteProbe ports.BrokerHostProbe | |
| 239 | + | }{ | |
| 240 | + | { | |
| 241 | + | name: "observation-disabled registry cannot observe locally", | |
| 242 | + | cfg: RegistryConfig{ObservationDisabled: true, Local: valid}, | |
| 243 | + | remoteProbe: newTestProbe(1), | |
| 244 | + | }, | |
| 245 | + | { | |
| 246 | + | name: "local producer requires a probe", | |
| 247 | + | cfg: RegistryConfig{Local: &LocalObservation{DisplayOrigin: "local", Policy: poolPolicy()}}, | |
| 248 | + | remoteProbe: newTestProbe(1), | |
| 249 | + | }, | |
| 250 | + | { | |
| 251 | + | name: "local authority policy is validated", | |
| 252 | + | cfg: RegistryConfig{Local: &LocalObservation{DisplayOrigin: "local", Policy: invalidPolicy, Probe: newScriptedLocalProbe(1)}}, | |
| 253 | + | remoteProbe: newTestProbe(1), | |
| 254 | + | }, | |
| 255 | + | { | |
| 256 | + | name: "local display origin is required", | |
| 257 | + | cfg: RegistryConfig{Local: &LocalObservation{DisplayOrigin: "", Policy: poolPolicy(), Probe: newScriptedLocalProbe(1)}}, | |
| 258 | + | remoteProbe: newTestProbe(1), | |
| 259 | + | }, | |
| 260 | + | { | |
| 261 | + | name: "local display origin must be normalized", | |
| 262 | + | cfg: RegistryConfig{Local: &LocalObservation{DisplayOrigin: "ev\u202eil", Policy: poolPolicy(), Probe: newScriptedLocalProbe(1)}}, | |
| 263 | + | remoteProbe: newTestProbe(1), | |
| 264 | + | }, | |
| 265 | + | { | |
| 266 | + | name: "no producer at all is still refused", | |
| 267 | + | cfg: RegistryConfig{}, | |
| 268 | + | remoteProbe: nil, | |
| 269 | + | }, | |
| 270 | + | } | |
| 271 | + | for _, tt := range tests { | |
| 272 | + | t.Run(tt.name, func(t *testing.T) { | |
| 273 | + | _, err := NewRegistryWithConfig(1, newTestStore(), tt.remoteProbe, clock, nil, tt.cfg) | |
| 274 | + | require.Error(t, err) | |
| 275 | + | }) | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | // TestRegistryLocalOnlyRequiresNoRemoteProbe proves a composition that observes | |
| 280 | + | // only the broker's own machine daemon needs no remote probe, and that such a | |
| 281 | + | // registry refuses remote membership rather than silently never probing it. | |
| 282 | + | func TestRegistryLocalOnlyRequiresNoRemoteProbe(t *testing.T) { | |
| 283 | + | clock := newManualClock(time.Unix(100, 0)) | |
| 284 | + | local := newScriptedLocalProbe(1) | |
| 285 | + | registry, err := NewRegistryWithConfig(1, newTestStore(), nil, clock, nil, RegistryConfig{ | |
| 286 | + | Local: &LocalObservation{DisplayOrigin: "local", Policy: poolPolicy(), Probe: local}, | |
| 287 | + | }) | |
| 288 | + | require.NoError(t, err) | |
| 289 | + | registry.jitter = identityJitter | |
| 290 | + | ||
| 291 | + | reg := registration(t, "user@host:22", 1) | |
| 292 | + | require.Error(t, registry.setHosts(hostRecords(reg))) | |
| 293 | + | require.Error(t, registry.ReplaceHosts(hostRecords(reg))) | |
| 294 | + | ||
| 295 | + | startRegistry(t, registry) | |
| 296 | + | call := receiveLocalCall(t, local) | |
| 297 | + | call.result <- localProbeAnswer{snapshot: ports.BrokerDaemonObservation{ | |
| 298 | + | Identity: "local-daemon", | |
| 299 | + | Incarnation: ports.BrokerDaemonIncarnation{9}, | |
| 300 | + | ProtocolVersion: protocol.Version, | |
| 301 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 302 | + | }} | |
| 303 | + | host := waitLocal(t, registry, func(o ports.BrokerDaemonObservation) bool { | |
| 304 | + | return !o.Checking && o.Availability == domain.RemoteAvailabilityReachable | |
| 305 | + | }) | |
| 306 | + | require.True(t, host.Local) | |
| 307 | + | require.Len(t, registry.Snapshot().Daemons, 1) | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | // localEntryCount counts the published local entries. A valid snapshot carries | |
| 311 | + | // at most one, so this is also the assertion that no second local entry is | |
| 312 | + | // ever invented. | |
| 313 | + | func localEntryCount(snapshot ports.BrokerSnapshot) int { | |
| 314 | + | count := 0 | |
| 315 | + | for _, daemon := range snapshot.Daemons { | |
| 316 | + | if daemon.Local { | |
| 317 | + | count++ | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | return count | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | // remoteEndpoints lists the published remote endpoints in publication order, | |
| 324 | + | // excluding the local entry. | |
| 325 | + | func remoteEndpoints(snapshot ports.BrokerSnapshot) []string { | |
| 326 | + | var endpoints []string | |
| 327 | + | for _, daemon := range snapshot.Daemons { | |
| 328 | + | if daemon.Local { | |
| 329 | + | continue | |
| 330 | + | } | |
| 331 | + | endpoints = append(endpoints, daemon.Endpoint) | |
| 332 | + | } | |
| 333 | + | return endpoints | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | // TestRegistryLocalEntryStaysFirstAcrossMembershipChanges proves the published | |
| 337 | + | // snapshot always carries exactly one local entry at index zero while the | |
| 338 | + | // remote membership churns through add, reorder, removal, re-add, and clear, | |
| 339 | + | // with the remotes in registration order behind it. The local entry stays first | |
| 340 | + | // because the registry owns it separately from durable membership: it is never | |
| 341 | + | // reordered by a membership change and never replaced by a remote. | |
| 342 | + | func TestRegistryLocalEntryStaysFirstAcrossMembershipChanges(t *testing.T) { | |
| 343 | + | first := registration(t, "user@first:22", 1) | |
| 344 | + | second := registration(t, "user@second:22", 2) | |
| 345 | + | third := registration(t, "user@third:22", 3) | |
| 346 | + | ||
| 347 | + | steps := []struct { | |
| 348 | + | name string | |
| 349 | + | apply func(t *testing.T, r *Registry) | |
| 350 | + | remotes []string | |
| 351 | + | }{ | |
| 352 | + | { | |
| 353 | + | name: "no remotes publishes the local entry alone", | |
| 354 | + | apply: func(*testing.T, *Registry) {}, | |
| 355 | + | }, | |
| 356 | + | { | |
| 357 | + | name: "adding remotes keeps the local entry first", | |
| 358 | + | apply: func(t *testing.T, r *Registry) { require.NoError(t, r.setHosts(hostRecords(first, second))) }, | |
| 359 | + | remotes: []string{first.Endpoint, second.Endpoint}, | |
| 360 | + | }, | |
| 361 | + | { | |
| 362 | + | name: "reordering remotes keeps the local entry first", | |
| 363 | + | apply: func(t *testing.T, r *Registry) { require.NoError(t, r.setHosts(hostRecords(second, first))) }, | |
| 364 | + | remotes: []string{second.Endpoint, first.Endpoint}, | |
| 365 | + | }, | |
| 366 | + | { | |
| 367 | + | name: "durable replacement keeps the local entry first", | |
| 368 | + | apply: func(t *testing.T, r *Registry) { require.NoError(t, r.ReplaceHosts(hostRecords(first))) }, | |
| 369 | + | remotes: []string{first.Endpoint}, | |
| 370 | + | }, | |
| 371 | + | { | |
| 372 | + | name: "removing then re-adding a remote keeps the local entry first", | |
| 373 | + | apply: func(t *testing.T, r *Registry) { | |
| 374 | + | require.NoError(t, r.ReplaceHosts(hostRecords())) | |
| 375 | + | require.NoError(t, r.ReplaceHosts(hostRecords(first, third))) | |
| 376 | + | }, | |
| 377 | + | remotes: []string{first.Endpoint, third.Endpoint}, | |
| 378 | + | }, | |
| 379 | + | { | |
| 380 | + | name: "clearing durable membership leaves the local entry alone", | |
| 381 | + | apply: func(t *testing.T, r *Registry) { require.NoError(t, r.ReplaceHosts(hostRecords())) }, | |
| 382 | + | remotes: nil, | |
| 383 | + | }, | |
| 384 | + | } | |
| 385 | + | registry := newLocalTestRegistry(t, newTestStore(), newTestProbe(1), newScriptedLocalProbe(1), newManualClock(time.Unix(100, 0))) | |
| 386 | + | for _, tt := range steps { | |
| 387 | + | t.Run(tt.name, func(t *testing.T) { | |
| 388 | + | tt.apply(t, registry) | |
| 389 | + | snapshot := registry.Snapshot() | |
| 390 | + | require.NotEmpty(t, snapshot.Daemons) | |
| 391 | + | require.True(t, snapshot.Daemons[0].Local, "the local entry is always at index zero") | |
| 392 | + | require.Equal(t, 1, localEntryCount(snapshot), "exactly one local entry is published") | |
| 393 | + | require.Equal(t, tt.remotes, remoteEndpoints(snapshot), "remotes keep registration order behind the local entry") | |
| 394 | + | }) | |
| 395 | + | } | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | // TestRegistryLocalOnlyRefusesRestoredRemoteMembership proves a local-only | |
| 399 | + | // registry (a configured local observation and no remote probe) refuses restored | |
| 400 | + | // durable remote membership at the projectMembership boundary, so it never owns | |
| 401 | + | // a remote host it could never probe. The setHosts/ReplaceHosts refusals are | |
| 402 | + | // covered beside this test. | |
| 403 | + | func TestRegistryLocalOnlyRefusesRestoredRemoteMembership(t *testing.T) { | |
| 404 | + | clock := newManualClock(time.Unix(100, 0)) | |
| 405 | + | local := func() *LocalObservation { | |
| 406 | + | return &LocalObservation{DisplayOrigin: "local", Policy: poolPolicy(), Probe: newScriptedLocalProbe(1)} | |
| 407 | + | } | |
| 408 | + | reg := registration(t, "user@host:22", 1) | |
| 409 | + | ||
| 410 | + | t.Run("construction refuses a store with restored remote membership", func(t *testing.T) { | |
| 411 | + | store := newTestStore() | |
| 412 | + | store.hosts = ports.BrokerHosts{Revision: 1, Hosts: hostRecords(reg)} | |
| 413 | + | _, err := NewRegistryWithConfig(1, store, nil, clock, nil, RegistryConfig{Local: local()}) | |
| 414 | + | require.Error(t, err) | |
| 415 | + | require.Contains(t, err.Error(), "remote hosts require a host probe") | |
| 416 | + | }) | |
| 417 | + | ||
| 418 | + | t.Run("projectMembership refuses restored remote membership directly", func(t *testing.T) { | |
| 419 | + | registry, err := NewRegistryWithConfig(1, newTestStore(), nil, clock, nil, RegistryConfig{Local: local()}) | |
| 420 | + | require.NoError(t, err) | |
| 421 | + | // Empty restored membership is accepted: the local-only registry owns no | |
| 422 | + | // remote host and needs no remote probe. | |
| 423 | + | require.NoError(t, registry.projectMembership(ports.BrokerHosts{Revision: 1})) | |
| 424 | + | err = registry.projectMembership(ports.BrokerHosts{Revision: 1, Hosts: hostRecords(reg)}) | |
| 425 | + | require.Error(t, err) | |
| 426 | + | require.Contains(t, err.Error(), "remote hosts require a host probe") | |
| 427 | + | require.Empty(t, remoteEndpoints(registry.Snapshot()), "the refused membership is never published") | |
| 428 | + | }) | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | // TestPoolLocalStreamsShareOnePhysicalEntry proves two local open-stream | |
| 432 | + | // requests under one broker-owned local binding resolve to the same pooled | |
| 433 | + | // (identity, policy) physical entry: the pool dials one daemonmux carriage, | |
| 434 | + | // both logical streams ride it, and closing one never closes its sibling. It | |
| 435 | + | // lives here because pool_test.go is outside this change's write scope; the | |
| 436 | + | // fake connector/resolver harness it reuses is defined there. | |
| 437 | + | func TestPoolLocalStreamsShareOnePhysicalEntry(t *testing.T) { | |
| 438 | + | var connects atomic.Int32 | |
| 439 | + | var physical *fakePhysical | |
| 440 | + | pool, _ := setupPool(t, func(_ context.Context, endpoint ports.BrokerResolvedEndpoint) (ports.BrokerPhysicalConnection, error) { | |
| 441 | + | connects.Add(1) | |
| 442 | + | physical = &fakePhysical{endpoint: endpoint, done: make(chan struct{})} | |
| 443 | + | return physical, nil | |
| 444 | + | }) | |
| 445 | + | id, err := pool.RegisterClient() | |
| 446 | + | require.NoError(t, err) | |
| 447 | + | first, err := pool.OpenStream(context.Background(), poolRequest(id, 1)) | |
| 448 | + | require.NoError(t, err) | |
| 449 | + | second, err := pool.OpenStream(context.Background(), poolRequest(id, 2)) | |
| 450 | + | require.NoError(t, err) | |
| 451 | + | require.EqualValues(t, 1, connects.Load(), "both local streams share one physical carriage") | |
| 452 | + | require.NoError(t, first.Close()) | |
| 453 | + | select { | |
| 454 | + | case <-second.Done(): | |
| 455 | + | t.Fatal("closing one local stream closed its sibling") | |
| 456 | + | default: | |
| 457 | + | } | |
| 458 | + | require.NoError(t, second.Close()) | |
| 459 | + | require.NoError(t, physical.Close()) | |
| 460 | + | } | |
| @@ -0,0 +1,460 @@ | |||||
| 1 | + | package broker | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "sync/atomic" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/vev/internal/domain" | |||
| 13 | + | "github.com/bnema/vev/internal/ports" | |||
| 14 | + | "github.com/bnema/vev/internal/protocol" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // scriptedLocalProbe scripts one local observation per received call: the test | |||
| 18 | + | // owns the completion, so the local probe never depends on wall time. | |||
| 19 | + | type scriptedLocalProbe struct { | |||
| 20 | + | calls chan localProbeCall | |||
| 21 | + | canceled chan struct{} | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | type localProbeCall struct { | |||
| 25 | + | result chan localProbeAnswer | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | type localProbeAnswer struct { | |||
| 29 | + | snapshot ports.BrokerDaemonObservation | |||
| 30 | + | err error | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func newScriptedLocalProbe(capacity int) *scriptedLocalProbe { | |||
| 34 | + | return &scriptedLocalProbe{calls: make(chan localProbeCall, capacity), canceled: make(chan struct{}, 1)} | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func (p *scriptedLocalProbe) ProbeLocal(ctx context.Context) (ports.BrokerDaemonObservation, error) { | |||
| 38 | + | call := localProbeCall{result: make(chan localProbeAnswer, 1)} | |||
| 39 | + | select { | |||
| 40 | + | case p.calls <- call: | |||
| 41 | + | case <-ctx.Done(): | |||
| 42 | + | p.noteCanceled() | |||
| 43 | + | return ports.BrokerDaemonObservation{}, ctx.Err() | |||
| 44 | + | } | |||
| 45 | + | select { | |||
| 46 | + | case answer := <-call.result: | |||
| 47 | + | return answer.snapshot, answer.err | |||
| 48 | + | case <-ctx.Done(): | |||
| 49 | + | p.noteCanceled() | |||
| 50 | + | return ports.BrokerDaemonObservation{}, ctx.Err() | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func (p *scriptedLocalProbe) noteCanceled() { | |||
| 55 | + | select { | |||
| 56 | + | case p.canceled <- struct{}{}: | |||
| 57 | + | default: | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func receiveLocalCall(t *testing.T, probe *scriptedLocalProbe) localProbeCall { | |||
| 62 | + | t.Helper() | |||
| 63 | + | select { | |||
| 64 | + | case call := <-probe.calls: | |||
| 65 | + | return call | |||
| 66 | + | case <-time.After(time.Second): | |||
| 67 | + | t.Fatal("timed out waiting for local probe") | |||
| 68 | + | return localProbeCall{} | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // newLocalTestRegistry builds a registry that observes the broker's own machine | |||
| 73 | + | // daemon through localProbe, beside an optional remote probe. | |||
| 74 | + | func newLocalTestRegistry(t *testing.T, store ports.BrokerHostStore, remoteProbe ports.BrokerHostProbe, localProbe LocalProbe, clock *manualClock) *Registry { | |||
| 75 | + | t.Helper() | |||
| 76 | + | registry, err := NewRegistryWithConfig(1, store, remoteProbe, clock, nil, RegistryConfig{ | |||
| 77 | + | Local: &LocalObservation{DisplayOrigin: "local", Policy: poolPolicy(), Probe: localProbe}, | |||
| 78 | + | }) | |||
| 79 | + | require.NoError(t, err) | |||
| 80 | + | registry.jitter = identityJitter | |||
| 81 | + | return registry | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // waitLocal waits for the published local entry (always index zero) to satisfy | |||
| 85 | + | // predicate and returns it. | |||
| 86 | + | func waitLocal(t *testing.T, r *Registry, predicate func(ports.BrokerDaemonObservation) bool) ports.BrokerDaemonObservation { | |||
| 87 | + | t.Helper() | |||
| 88 | + | snapshot := waitSnapshot(t, r, func(snapshot ports.BrokerSnapshot) bool { | |||
| 89 | + | return len(snapshot.Daemons) > 0 && snapshot.Daemons[0].Local && predicate(snapshot.Daemons[0]) | |||
| 90 | + | }) | |||
| 91 | + | return snapshot.Daemons[0] | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // TestRegistryPublishesObservedLocalDaemonFirst proves the local observation is | |||
| 95 | + | // published at index zero ahead of every remote, carries the configured | |||
| 96 | + | // authority plus the observed identity/incarnation/version, and is never | |||
| 97 | + | // durable: the store persists the remote observations only. | |||
| 98 | + | func TestRegistryPublishesObservedLocalDaemonFirst(t *testing.T) { | |||
| 99 | + | clock := newManualClock(time.Unix(100, 0)) | |||
| 100 | + | store := newTestStore() | |||
| 101 | + | local := newScriptedLocalProbe(1) | |||
| 102 | + | registry := newLocalTestRegistry(t, store, newTestProbe(1), local, clock) | |||
| 103 | + | reg := registration(t, "user@host:22", 1) | |||
| 104 | + | require.NoError(t, registry.setHosts(hostRecords(reg))) | |||
| 105 | + | startRegistry(t, registry) | |||
| 106 | + | ||||
| 107 | + | call := receiveLocalCall(t, local) | |||
| 108 | + | call.result <- localProbeAnswer{snapshot: ports.BrokerDaemonObservation{ | |||
| 109 | + | Identity: "local-daemon", | |||
| 110 | + | Incarnation: ports.BrokerDaemonIncarnation{9}, | |||
| 111 | + | ProtocolVersion: protocol.Version, | |||
| 112 | + | Availability: domain.RemoteAvailabilityReachable, | |||
| 113 | + | }} | |||
| 114 | + | ||||
| 115 | + | host := waitLocal(t, registry, func(o ports.BrokerDaemonObservation) bool { | |||
| 116 | + | return !o.Checking && o.Availability == domain.RemoteAvailabilityReachable | |||
| 117 | + | }) | |||
| 118 | + | require.True(t, host.Local) | |||
| 119 | + | require.Zero(t, host.Endpoint) | |||
| 120 | + | require.Equal(t, domain.RemoteRegistration{}, host.Registration) | |||
| 121 | + | require.Equal(t, "local", host.DisplayOrigin) | |||
| 122 | + | require.Equal(t, poolPolicy(), host.Policy) | |||
| 123 | + | require.Zero(t, host.Rank) | |||
| 124 | + | require.Equal(t, ports.BrokerDaemonIdentity("local-daemon"), host.Identity) | |||
| 125 | + | require.Equal(t, ports.BrokerDaemonIncarnation{9}, host.Incarnation) | |||
| 126 | + | require.Equal(t, protocol.Version, host.ProtocolVersion) | |||
| 127 | + | ||||
| 128 | + | snapshot := registry.Snapshot() | |||
| 129 | + | require.True(t, snapshot.Daemons[0].Local) | |||
| 130 | + | require.Equal(t, reg.Endpoint, snapshot.Daemons[1].Endpoint) | |||
| 131 | + | ||||
| 132 | + | // Never durable: the store receives remote observations only, and the local | |||
| 133 | + | // entry is dropped before persistence. | |||
| 134 | + | stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool { | |||
| 135 | + | return len(snapshot.Daemons) == 1 && !snapshot.Daemons[0].Local | |||
| 136 | + | }) | |||
| 137 | + | require.Equal(t, reg.Endpoint, stored.Daemons[0].Endpoint) | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // TestRegistryLocalObservationNeverInventsState proves an unreachable local | |||
| 141 | + | // daemon is published with the explicit unknown availability and zero observed | |||
| 142 | + | // identity, and a hostile probe result cannot supply local authority, | |||
| 143 | + | // endpoint, registration, policy, or display origin. | |||
| 144 | + | func TestRegistryLocalObservationNeverInventsState(t *testing.T) { | |||
| 145 | + | t.Run("failure keeps zero identity and typed availability", func(t *testing.T) { | |||
| 146 | + | clock := newManualClock(time.Unix(100, 0)) | |||
| 147 | + | local := newScriptedLocalProbe(1) | |||
| 148 | + | registry := newLocalTestRegistry(t, newTestStore(), newTestProbe(1), local, clock) | |||
| 149 | + | startRegistry(t, registry) | |||
| 150 | + | ||||
| 151 | + | call := receiveLocalCall(t, local) | |||
| 152 | + | call.result <- localProbeAnswer{err: errors.New("local carriage refused")} | |||
| 153 | + | ||||
| 154 | + | host := waitLocal(t, registry, func(o ports.BrokerDaemonObservation) bool { | |||
| 155 | + | return !o.Checking && o.Availability == domain.RemoteAvailabilityUnreachable | |||
| 156 | + | }) | |||
| 157 | + | require.True(t, host.Local) | |||
| 158 | + | require.Zero(t, host.Endpoint) | |||
| 159 | + | require.Equal(t, ports.BrokerDaemonIdentity(""), host.Identity) | |||
| 160 | + | require.True(t, host.Incarnation.IsZero()) | |||
| 161 | + | require.Zero(t, host.ProtocolVersion) | |||
| 162 | + | require.Equal(t, domain.RemoteFailureTransport, host.LastFailure.Kind) | |||
| 163 | + | require.Equal(t, 1, len(registry.Snapshot().Daemons)) | |||
| 164 | + | }) | |||
| 165 | + | ||||
| 166 | + | t.Run("hostile probe cannot supply local authority", func(t *testing.T) { | |||
| 167 | + | clock := newManualClock(time.Unix(100, 0)) | |||
| 168 | + | local := newScriptedLocalProbe(1) | |||
| 169 | + | registry := newLocalTestRegistry(t, newTestStore(), newTestProbe(1), local, clock) | |||
| 170 | + | startRegistry(t, registry) | |||
| 171 | + | ||||
| 172 | + | hostilePolicy := poolPolicy() | |||
| 173 | + | hostilePolicy.Trust = "attacker" | |||
| 174 | + | call := receiveLocalCall(t, local) | |||
| 175 | + | call.result <- localProbeAnswer{snapshot: ports.BrokerDaemonObservation{ | |||
| 176 | + | Local: false, | |||
| 177 | + | Endpoint: "attacker@evil", | |||
| 178 | + | DisplayOrigin: "evil", | |||
| 179 | + | Rank: 42, | |||
| 180 | + | Registration: registration(t, "attacker@evil", 9), | |||
| 181 | + | Policy: hostilePolicy, | |||
| 182 | + | Identity: "local-daemon", | |||
| 183 | + | Incarnation: ports.BrokerDaemonIncarnation{9}, | |||
| 184 | + | ProtocolVersion: protocol.Version, | |||
| 185 | + | Availability: domain.RemoteAvailabilityReachable, | |||
| 186 | + | }} | |||
| 187 | + | ||||
| 188 | + | host := waitLocal(t, registry, func(o ports.BrokerDaemonObservation) bool { | |||
| 189 | + | return !o.Checking && o.Availability == domain.RemoteAvailabilityReachable | |||
| 190 | + | }) | |||
| 191 | + | require.True(t, host.Local) | |||
| 192 | + | require.Zero(t, host.Endpoint) | |||
| 193 | + | require.Equal(t, domain.RemoteRegistration{}, host.Registration) | |||
| 194 | + | require.Equal(t, "local", host.DisplayOrigin) | |||
| 195 | + | require.Equal(t, poolPolicy(), host.Policy) | |||
| 196 | + | require.NotEqual(t, hostilePolicy, host.Policy) | |||
| 197 | + | require.Zero(t, host.Rank) | |||
| 198 | + | }) | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | // TestRegistryLocalObservationValidatesProbeResults proves an invalid observed | |||
| 202 | + | // projection is classified as an invalid response, never published as an | |||
| 203 | + | // observation the wire refuses: a partial identity (identity without a version) | |||
| 204 | + | // is rejected without inventing the missing half. | |||
| 205 | + | func TestRegistryLocalObservationValidatesProbeResults(t *testing.T) { | |||
| 206 | + | clock := newManualClock(time.Unix(100, 0)) | |||
| 207 | + | local := newScriptedLocalProbe(1) | |||
| 208 | + | registry := newLocalTestRegistry(t, newTestStore(), newTestProbe(1), local, clock) | |||
| 209 | + | startRegistry(t, registry) | |||
| 210 | + | ||||
| 211 | + | call := receiveLocalCall(t, local) | |||
| 212 | + | call.result <- localProbeAnswer{snapshot: ports.BrokerDaemonObservation{ | |||
| 213 | + | Identity: "local-daemon", // no protocol version: partial identity | |||
| 214 | + | Incarnation: ports.BrokerDaemonIncarnation{9}, | |||
| 215 | + | Availability: domain.RemoteAvailabilityReachable, | |||
| 216 | + | }} | |||
| 217 | + | ||||
| 218 | + | host := waitLocal(t, registry, func(o ports.BrokerDaemonObservation) bool { | |||
| 219 | + | return !o.Checking && o.Availability == domain.RemoteAvailabilityInvalidResponse | |||
| 220 | + | }) | |||
| 221 | + | require.True(t, host.Local) | |||
| 222 | + | require.Equal(t, domain.RemoteFailureInvalidResponse, host.LastFailure.Kind) | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | // TestRegistryLocalObservationConstruction pins the composition refusals: a | |||
| 226 | + | // read-only registry owns no local producer, a local producer requires a probe, | |||
| 227 | + | // and its policy and display origin are validated before anything is published. | |||
| 228 | + | func TestRegistryLocalObservationConstruction(t *testing.T) { | |||
| 229 | + | clock := newManualClock(time.Unix(100, 0)) | |||
| 230 | + | valid := &LocalObservation{DisplayOrigin: "local", Policy: poolPolicy(), Probe: newScriptedLocalProbe(1)} | |||
| 231 | + | ||||
| 232 | + | invalidPolicy := poolPolicy() | |||
| 233 | + | invalidPolicy.ProtocolVersion = 0 | |||
| 234 | + | ||||
| 235 | + | tests := []struct { | |||
| 236 | + | name string | |||
| 237 | + | cfg RegistryConfig | |||
| 238 | + | remoteProbe ports.BrokerHostProbe | |||
| 239 | + | }{ | |||
| 240 | + | { | |||
| 241 | + | name: "observation-disabled registry cannot observe locally", | |||
| 242 | + | cfg: RegistryConfig{ObservationDisabled: true, Local: valid}, | |||
| 243 | + | remoteProbe: newTestProbe(1), | |||
| 244 | + | }, | |||
| 245 | + | { | |||
| 246 | + | name: "local producer requires a probe", | |||
| 247 | + | cfg: RegistryConfig{Local: &LocalObservation{DisplayOrigin: "local", Policy: poolPolicy()}}, | |||
| 248 | + | remoteProbe: newTestProbe(1), | |||
| 249 | + | }, | |||
| 250 | + | { | |||
| 251 | + | name: "local authority policy is validated", | |||
| 252 | + | cfg: RegistryConfig{Local: &LocalObservation{DisplayOrigin: "local", Policy: invalidPolicy, Probe: newScriptedLocalProbe(1)}}, | |||
| 253 | + | remoteProbe: newTestProbe(1), | |||
| 254 | + | }, | |||
| 255 | + | { | |||
| 256 | + | name: "local display origin is required", | |||
| 257 | + | cfg: RegistryConfig{Local: &LocalObservation{DisplayOrigin: "", Policy: poolPolicy(), Probe: newScriptedLocalProbe(1)}}, | |||
| 258 | + | remoteProbe: newTestProbe(1), | |||
| 259 | + | }, | |||
| 260 | + | { | |||
| 261 | + | name: "local display origin must be normalized", | |||
| 262 | + | cfg: RegistryConfig{Local: &LocalObservation{DisplayOrigin: "ev\u202eil", Policy: poolPolicy(), Probe: newScriptedLocalProbe(1)}}, | |||
| 263 | + | remoteProbe: newTestProbe(1), | |||
| 264 | + | }, | |||
| 265 | + | { | |||
| 266 | + | name: "no producer at all is still refused", | |||
| 267 | + | cfg: RegistryConfig{}, | |||
| 268 | + | remoteProbe: nil, | |||
| 269 | + | }, | |||
| 270 | + | } | |||
| 271 | + | for _, tt := range tests { | |||
| 272 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 273 | + | _, err := NewRegistryWithConfig(1, newTestStore(), tt.remoteProbe, clock, nil, tt.cfg) | |||
| 274 | + | require.Error(t, err) | |||
| 275 | + | }) | |||
| 276 | + | } | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | // TestRegistryLocalOnlyRequiresNoRemoteProbe proves a composition that observes | |||
| 280 | + | // only the broker's own machine daemon needs no remote probe, and that such a | |||
| 281 | + | // registry refuses remote membership rather than silently never probing it. | |||
| 282 | + | func TestRegistryLocalOnlyRequiresNoRemoteProbe(t *testing.T) { | |||
| 283 | + | clock := newManualClock(time.Unix(100, 0)) | |||
| 284 | + | local := newScriptedLocalProbe(1) | |||
| 285 | + | registry, err := NewRegistryWithConfig(1, newTestStore(), nil, clock, nil, RegistryConfig{ | |||
| 286 | + | Local: &LocalObservation{DisplayOrigin: "local", Policy: poolPolicy(), Probe: local}, | |||
| 287 | + | }) | |||
| 288 | + | require.NoError(t, err) | |||
| 289 | + | registry.jitter = identityJitter | |||
| 290 | + | ||||
| 291 | + | reg := registration(t, "user@host:22", 1) | |||
| 292 | + | require.Error(t, registry.setHosts(hostRecords(reg))) | |||
| 293 | + | require.Error(t, registry.ReplaceHosts(hostRecords(reg))) | |||
| 294 | + | ||||
| 295 | + | startRegistry(t, registry) | |||
| 296 | + | call := receiveLocalCall(t, local) | |||
| 297 | + | call.result <- localProbeAnswer{snapshot: ports.BrokerDaemonObservation{ | |||
| 298 | + | Identity: "local-daemon", | |||
| 299 | + | Incarnation: ports.BrokerDaemonIncarnation{9}, | |||
| 300 | + | ProtocolVersion: protocol.Version, | |||
| 301 | + | Availability: domain.RemoteAvailabilityReachable, | |||
| 302 | + | }} | |||
| 303 | + | host := waitLocal(t, registry, func(o ports.BrokerDaemonObservation) bool { | |||
| 304 | + | return !o.Checking && o.Availability == domain.RemoteAvailabilityReachable | |||
| 305 | + | }) | |||
| 306 | + | require.True(t, host.Local) | |||
| 307 | + | require.Len(t, registry.Snapshot().Daemons, 1) | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | // localEntryCount counts the published local entries. A valid snapshot carries | |||
| 311 | + | // at most one, so this is also the assertion that no second local entry is | |||
| 312 | + | // ever invented. | |||
| 313 | + | func localEntryCount(snapshot ports.BrokerSnapshot) int { | |||
| 314 | + | count := 0 | |||
| 315 | + | for _, daemon := range snapshot.Daemons { | |||
| 316 | + | if daemon.Local { | |||
| 317 | + | count++ | |||
| 318 | + | } | |||
| 319 | + | } | |||
| 320 | + | return count | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | // remoteEndpoints lists the published remote endpoints in publication order, | |||
| 324 | + | // excluding the local entry. | |||
| 325 | + | func remoteEndpoints(snapshot ports.BrokerSnapshot) []string { | |||
| 326 | + | var endpoints []string | |||
| 327 | + | for _, daemon := range snapshot.Daemons { | |||
| 328 | + | if daemon.Local { | |||
| 329 | + | continue | |||
| 330 | + | } | |||
| 331 | + | endpoints = append(endpoints, daemon.Endpoint) | |||
| 332 | + | } | |||
| 333 | + | return endpoints | |||
| 334 | + | } | |||
| 335 | + | ||||
| 336 | + | // TestRegistryLocalEntryStaysFirstAcrossMembershipChanges proves the published | |||
| 337 | + | // snapshot always carries exactly one local entry at index zero while the | |||
| 338 | + | // remote membership churns through add, reorder, removal, re-add, and clear, | |||
| 339 | + | // with the remotes in registration order behind it. The local entry stays first | |||
| 340 | + | // because the registry owns it separately from durable membership: it is never | |||
| 341 | + | // reordered by a membership change and never replaced by a remote. | |||
| 342 | + | func TestRegistryLocalEntryStaysFirstAcrossMembershipChanges(t *testing.T) { | |||
| 343 | + | first := registration(t, "user@first:22", 1) | |||
| 344 | + | second := registration(t, "user@second:22", 2) | |||
| 345 | + | third := registration(t, "user@third:22", 3) | |||
| 346 | + | ||||
| 347 | + | steps := []struct { | |||
| 348 | + | name string | |||
| 349 | + | apply func(t *testing.T, r *Registry) | |||
| 350 | + | remotes []string | |||
| 351 | + | }{ | |||
| 352 | + | { | |||
| 353 | + | name: "no remotes publishes the local entry alone", | |||
| 354 | + | apply: func(*testing.T, *Registry) {}, | |||
| 355 | + | }, | |||
| 356 | + | { | |||
| 357 | + | name: "adding remotes keeps the local entry first", | |||
| 358 | + | apply: func(t *testing.T, r *Registry) { require.NoError(t, r.setHosts(hostRecords(first, second))) }, | |||
| 359 | + | remotes: []string{first.Endpoint, second.Endpoint}, | |||
| 360 | + | }, | |||
| 361 | + | { | |||
| 362 | + | name: "reordering remotes keeps the local entry first", | |||
| 363 | + | apply: func(t *testing.T, r *Registry) { require.NoError(t, r.setHosts(hostRecords(second, first))) }, | |||
| 364 | + | remotes: []string{second.Endpoint, first.Endpoint}, | |||
| 365 | + | }, | |||
| 366 | + | { | |||
| 367 | + | name: "durable replacement keeps the local entry first", | |||
| 368 | + | apply: func(t *testing.T, r *Registry) { require.NoError(t, r.ReplaceHosts(hostRecords(first))) }, | |||
| 369 | + | remotes: []string{first.Endpoint}, | |||
| 370 | + | }, | |||
| 371 | + | { | |||
| 372 | + | name: "removing then re-adding a remote keeps the local entry first", | |||
| 373 | + | apply: func(t *testing.T, r *Registry) { | |||
| 374 | + | require.NoError(t, r.ReplaceHosts(hostRecords())) | |||
| 375 | + | require.NoError(t, r.ReplaceHosts(hostRecords(first, third))) | |||
| 376 | + | }, | |||
| 377 | + | remotes: []string{first.Endpoint, third.Endpoint}, | |||
| 378 | + | }, | |||
| 379 | + | { | |||
| 380 | + | name: "clearing durable membership leaves the local entry alone", | |||
| 381 | + | apply: func(t *testing.T, r *Registry) { require.NoError(t, r.ReplaceHosts(hostRecords())) }, | |||
| 382 | + | remotes: nil, | |||
| 383 | + | }, | |||
| 384 | + | } | |||
| 385 | + | registry := newLocalTestRegistry(t, newTestStore(), newTestProbe(1), newScriptedLocalProbe(1), newManualClock(time.Unix(100, 0))) | |||
| 386 | + | for _, tt := range steps { | |||
| 387 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 388 | + | tt.apply(t, registry) | |||
| 389 | + | snapshot := registry.Snapshot() | |||
| 390 | + | require.NotEmpty(t, snapshot.Daemons) | |||
| 391 | + | require.True(t, snapshot.Daemons[0].Local, "the local entry is always at index zero") | |||
| 392 | + | require.Equal(t, 1, localEntryCount(snapshot), "exactly one local entry is published") | |||
| 393 | + | require.Equal(t, tt.remotes, remoteEndpoints(snapshot), "remotes keep registration order behind the local entry") | |||
| 394 | + | }) | |||
| 395 | + | } | |||
| 396 | + | } | |||
| 397 | + | ||||
| 398 | + | // TestRegistryLocalOnlyRefusesRestoredRemoteMembership proves a local-only | |||
| 399 | + | // registry (a configured local observation and no remote probe) refuses restored | |||
| 400 | + | // durable remote membership at the projectMembership boundary, so it never owns | |||
| 401 | + | // a remote host it could never probe. The setHosts/ReplaceHosts refusals are | |||
| 402 | + | // covered beside this test. | |||
| 403 | + | func TestRegistryLocalOnlyRefusesRestoredRemoteMembership(t *testing.T) { | |||
| 404 | + | clock := newManualClock(time.Unix(100, 0)) | |||
| 405 | + | local := func() *LocalObservation { | |||
| 406 | + | return &LocalObservation{DisplayOrigin: "local", Policy: poolPolicy(), Probe: newScriptedLocalProbe(1)} | |||
| 407 | + | } | |||
| 408 | + | reg := registration(t, "user@host:22", 1) | |||
| 409 | + | ||||
| 410 | + | t.Run("construction refuses a store with restored remote membership", func(t *testing.T) { | |||
| 411 | + | store := newTestStore() | |||
| 412 | + | store.hosts = ports.BrokerHosts{Revision: 1, Hosts: hostRecords(reg)} | |||
| 413 | + | _, err := NewRegistryWithConfig(1, store, nil, clock, nil, RegistryConfig{Local: local()}) | |||
| 414 | + | require.Error(t, err) | |||
| 415 | + | require.Contains(t, err.Error(), "remote hosts require a host probe") | |||
| 416 | + | }) | |||
| 417 | + | ||||
| 418 | + | t.Run("projectMembership refuses restored remote membership directly", func(t *testing.T) { | |||
| 419 | + | registry, err := NewRegistryWithConfig(1, newTestStore(), nil, clock, nil, RegistryConfig{Local: local()}) | |||
| 420 | + | require.NoError(t, err) | |||
| 421 | + | // Empty restored membership is accepted: the local-only registry owns no | |||
| 422 | + | // remote host and needs no remote probe. | |||
| 423 | + | require.NoError(t, registry.projectMembership(ports.BrokerHosts{Revision: 1})) | |||
| 424 | + | err = registry.projectMembership(ports.BrokerHosts{Revision: 1, Hosts: hostRecords(reg)}) | |||
| 425 | + | require.Error(t, err) | |||
| 426 | + | require.Contains(t, err.Error(), "remote hosts require a host probe") | |||
| 427 | + | require.Empty(t, remoteEndpoints(registry.Snapshot()), "the refused membership is never published") | |||
| 428 | + | }) | |||
| 429 | + | } | |||
| 430 | + | ||||
| 431 | + | // TestPoolLocalStreamsShareOnePhysicalEntry proves two local open-stream | |||
| 432 | + | // requests under one broker-owned local binding resolve to the same pooled | |||
| 433 | + | // (identity, policy) physical entry: the pool dials one daemonmux carriage, | |||
| 434 | + | // both logical streams ride it, and closing one never closes its sibling. It | |||
| 435 | + | // lives here because pool_test.go is outside this change's write scope; the | |||
| 436 | + | // fake connector/resolver harness it reuses is defined there. | |||
| 437 | + | func TestPoolLocalStreamsShareOnePhysicalEntry(t *testing.T) { | |||
| 438 | + | var connects atomic.Int32 | |||
| 439 | + | var physical *fakePhysical | |||
| 440 | + | pool, _ := setupPool(t, func(_ context.Context, endpoint ports.BrokerResolvedEndpoint) (ports.BrokerPhysicalConnection, error) { | |||
| 441 | + | connects.Add(1) | |||
| 442 | + | physical = &fakePhysical{endpoint: endpoint, done: make(chan struct{})} | |||
| 443 | + | return physical, nil | |||
| 444 | + | }) | |||
| 445 | + | id, err := pool.RegisterClient() | |||
| 446 | + | require.NoError(t, err) | |||
| 447 | + | first, err := pool.OpenStream(context.Background(), poolRequest(id, 1)) | |||
| 448 | + | require.NoError(t, err) | |||
| 449 | + | second, err := pool.OpenStream(context.Background(), poolRequest(id, 2)) | |||
| 450 | + | require.NoError(t, err) | |||
| 451 | + | require.EqualValues(t, 1, connects.Load(), "both local streams share one physical carriage") | |||
| 452 | + | require.NoError(t, first.Close()) | |||
| 453 | + | select { | |||
| 454 | + | case <-second.Done(): | |||
| 455 | + | t.Fatal("closing one local stream closed its sibling") | |||
| 456 | + | default: | |||
| 457 | + | } | |||
| 458 | + | require.NoError(t, second.Close()) | |||
| 459 | + | require.NoError(t, physical.Close()) | |||
| 460 | + | } | |||