refactor(app): keep only the production broker composition
- commit
- 4e0fee95183da1e4ccc96439e2e4ad30aa7aedaa
- author
- brice · · 2026-09-23 15:39 UTC
- parent
- f7c33a7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
22 files · +343 -1816
M internal/adapters/brokerconfig/config.go +0 -82
| @@ -120,53 +120,6 @@ type Layout struct { | |||
| 120 | 120 | reserved []string | |
| 121 | 121 | } | |
| 122 | 122 | ||
| 123 | - | // ResolveLayout validates one offline root and derives its sandbox paths. | |
| 124 | - | // | |
| 125 | - | // The root must be absolute and clean, must not be the filesystem root, must | |
| 126 | - | // not contain a symlinked component, and must not equal, contain, or sit inside | |
| 127 | - | // any reserved production directory. Reserved directories are compared cleaned | |
| 128 | - | // and absolute; empty entries are ignored. The result is purely textual: no | |
| 129 | - | // directory is created here, and the caller secures the paths with | |
| 130 | - | // pkg/safedir.EnsurePrivate. | |
| 131 | - | func ResolveLayout(rawRoot string, reserved []string) (Layout, error) { | |
| 132 | - | if rawRoot == "" { | |
| 133 | - | return Layout{}, errors.New("brokerconfig: offline root is empty") | |
| 134 | - | } | |
| 135 | - | if !filepath.IsAbs(rawRoot) { | |
| 136 | - | return Layout{}, fmt.Errorf("brokerconfig: offline root %q is not absolute", rawRoot) | |
| 137 | - | } | |
| 138 | - | if cleaned := filepath.Clean(rawRoot); cleaned != rawRoot { | |
| 139 | - | return Layout{}, fmt.Errorf("brokerconfig: offline root %q is not clean", rawRoot) | |
| 140 | - | } | |
| 141 | - | if rawRoot == string(filepath.Separator) { | |
| 142 | - | return Layout{}, errors.New("brokerconfig: offline root must not be the filesystem root") | |
| 143 | - | } | |
| 144 | - | if err := rejectSymlinkedComponents(rawRoot); err != nil { | |
| 145 | - | return Layout{}, err | |
| 146 | - | } | |
| 147 | - | for _, other := range reserved { | |
| 148 | - | if other == "" { | |
| 149 | - | continue | |
| 150 | - | } | |
| 151 | - | other = filepath.Clean(other) | |
| 152 | - | if !filepath.IsAbs(other) { | |
| 153 | - | return Layout{}, fmt.Errorf("brokerconfig: reserved directory %q is not absolute", other) | |
| 154 | - | } | |
| 155 | - | if overlaps(rawRoot, other) { | |
| 156 | - | return Layout{}, fmt.Errorf("brokerconfig: offline root %q overlaps production path %q", rawRoot, other) | |
| 157 | - | } | |
| 158 | - | } | |
| 159 | - | runtime := filepath.Join(rawRoot, RuntimeDirName) | |
| 160 | - | return Layout{ | |
| 161 | - | Root: rawRoot, | |
| 162 | - | Runtime: runtime, | |
| 163 | - | State: filepath.Join(rawRoot, StateDirName), | |
| 164 | - | Log: filepath.Join(rawRoot, LogDirName), | |
| 165 | - | Spawn: filepath.Join(runtime, SpawnDirName), | |
| 166 | - | reserved: append([]string(nil), reserved...), | |
| 167 | - | }, nil | |
| 168 | - | } | |
| 169 | - | ||
| 170 | 123 | // VerifyCreated re-walks every sandbox-owned path and refuses any existing | |
| 171 | 124 | // component that is a symlink. | |
| 172 | 125 | // | |
| @@ -289,22 +242,6 @@ type Config struct { | |||
| 289 | 242 | warmIdleTimeout time.Duration | |
| 290 | 243 | } | |
| 291 | 244 | ||
| 292 | - | // Load reads and strictly validates the config marker inside layout.Root. | |
| 293 | - | // | |
| 294 | - | // The file must be a regular owner-only file that is not a symlink and is | |
| 295 | - | // bounded by MaxConfigBytes; the JSON must carry the exact marker, reject | |
| 296 | - | // unknown fields, duplicate keys, trailing data, invalid UTF-8, and excessive | |
| 297 | - | // nesting. Every registration is validated independently and endpoints must be | |
| 298 | - | // unique. Two registrations may alias one identical route (the same opaque | |
| 299 | - | // address) under different endpoints, but two that share the pool's exact | |
| 300 | - | // (authenticated identity, policy) pair must agree on that one route: the pool | |
| 301 | - | // keys a physical transport by the pair and ignores the address, so a second | |
| 302 | - | // route for the same pair is refused rather than silently dialed or ignored. A | |
| 303 | - | // missing or malformed config is an error, never an empty default. | |
| 304 | - | func Load(layout Layout) (*Config, error) { | |
| 305 | - | return LoadPath(layout, filepath.Join(layout.Root, ConfigFileName)) | |
| 306 | - | } | |
| 307 | - | ||
| 308 | 245 | // LoadPath parses the broker configuration at an explicit application-owned | |
| 309 | 246 | // path while retaining the route and reserved-path validation from layout. | |
| 310 | 247 | func LoadPath(layout Layout, path string) (*Config, error) { | |
| @@ -543,25 +480,6 @@ func (c *Config) RouteByAddress(address string) (Route, bool) { | |||
| 543 | 480 | return route, ok | |
| 544 | 481 | } | |
| 545 | 482 | ||
| 546 | - | // LocalMuxRoute returns the single provisioned Unix route a remote-side mux | |
| 547 | - | // helper bridges to. A configuration that provisions no Unix route, or more | |
| 548 | - | // than one, is refused: a helper must never guess which daemon-mux endpoint it | |
| 549 | - | // serves. The local binding's own carriage is deliberately excluded: the broker | |
| 550 | - | // dials it itself, and no remote-side helper ever bridges it. | |
| 551 | - | func (c *Config) LocalMuxRoute() (Route, error) { | |
| 552 | - | if c == nil { | |
| 553 | - | return Route{}, errors.New("brokerconfig: no configuration") | |
| 554 | - | } | |
| 555 | - | switch len(c.unixRoutes) { | |
| 556 | - | case 0: | |
| 557 | - | return Route{}, errors.New("brokerconfig: no Unix daemon-mux route is provisioned") | |
| 558 | - | case 1: | |
| 559 | - | return c.unixRoutes[0], nil | |
| 560 | - | default: | |
| 561 | - | return Route{}, errors.New("brokerconfig: more than one Unix daemon-mux route is provisioned") | |
| 562 | - | } | |
| 563 | - | } | |
| 564 | - | ||
| 565 | 483 | // Resolver returns the immutable endpoint resolver over this configuration. | |
| 566 | 484 | func (c *Config) Resolver() *Resolver { | |
| 567 | 485 | if c == nil { | |
| @@ -120,53 +120,6 @@ type Layout struct { | |||||
| 120 | reserved []string | 120 | reserved []string | ||
| 121 | } | 121 | } | ||
| 122 | 122 | ||||
| 123 | - | // ResolveLayout validates one offline root and derives its sandbox paths. | |||
| 124 | - | // | |||
| 125 | - | // The root must be absolute and clean, must not be the filesystem root, must | |||
| 126 | - | // not contain a symlinked component, and must not equal, contain, or sit inside | |||
| 127 | - | // any reserved production directory. Reserved directories are compared cleaned | |||
| 128 | - | // and absolute; empty entries are ignored. The result is purely textual: no | |||
| 129 | - | // directory is created here, and the caller secures the paths with | |||
| 130 | - | // pkg/safedir.EnsurePrivate. | |||
| 131 | - | func ResolveLayout(rawRoot string, reserved []string) (Layout, error) { | |||
| 132 | - | if rawRoot == "" { | |||
| 133 | - | return Layout{}, errors.New("brokerconfig: offline root is empty") | |||
| 134 | - | } | |||
| 135 | - | if !filepath.IsAbs(rawRoot) { | |||
| 136 | - | return Layout{}, fmt.Errorf("brokerconfig: offline root %q is not absolute", rawRoot) | |||
| 137 | - | } | |||
| 138 | - | if cleaned := filepath.Clean(rawRoot); cleaned != rawRoot { | |||
| 139 | - | return Layout{}, fmt.Errorf("brokerconfig: offline root %q is not clean", rawRoot) | |||
| 140 | - | } | |||
| 141 | - | if rawRoot == string(filepath.Separator) { | |||
| 142 | - | return Layout{}, errors.New("brokerconfig: offline root must not be the filesystem root") | |||
| 143 | - | } | |||
| 144 | - | if err := rejectSymlinkedComponents(rawRoot); err != nil { | |||
| 145 | - | return Layout{}, err | |||
| 146 | - | } | |||
| 147 | - | for _, other := range reserved { | |||
| 148 | - | if other == "" { | |||
| 149 | - | continue | |||
| 150 | - | } | |||
| 151 | - | other = filepath.Clean(other) | |||
| 152 | - | if !filepath.IsAbs(other) { | |||
| 153 | - | return Layout{}, fmt.Errorf("brokerconfig: reserved directory %q is not absolute", other) | |||
| 154 | - | } | |||
| 155 | - | if overlaps(rawRoot, other) { | |||
| 156 | - | return Layout{}, fmt.Errorf("brokerconfig: offline root %q overlaps production path %q", rawRoot, other) | |||
| 157 | - | } | |||
| 158 | - | } | |||
| 159 | - | runtime := filepath.Join(rawRoot, RuntimeDirName) | |||
| 160 | - | return Layout{ | |||
| 161 | - | Root: rawRoot, | |||
| 162 | - | Runtime: runtime, | |||
| 163 | - | State: filepath.Join(rawRoot, StateDirName), | |||
| 164 | - | Log: filepath.Join(rawRoot, LogDirName), | |||
| 165 | - | Spawn: filepath.Join(runtime, SpawnDirName), | |||
| 166 | - | reserved: append([]string(nil), reserved...), | |||
| 167 | - | }, nil | |||
| 168 | - | } | |||
| 169 | - | ||||
| 170 | // VerifyCreated re-walks every sandbox-owned path and refuses any existing | 123 | // VerifyCreated re-walks every sandbox-owned path and refuses any existing | ||
| 171 | // component that is a symlink. | 124 | // component that is a symlink. | ||
| 172 | // | 125 | // | ||
| @@ -289,22 +242,6 @@ type Config struct { | |||||
| 289 | warmIdleTimeout time.Duration | 242 | warmIdleTimeout time.Duration | ||
| 290 | } | 243 | } | ||
| 291 | 244 | ||||
| 292 | - | // Load reads and strictly validates the config marker inside layout.Root. | |||
| 293 | - | // | |||
| 294 | - | // The file must be a regular owner-only file that is not a symlink and is | |||
| 295 | - | // bounded by MaxConfigBytes; the JSON must carry the exact marker, reject | |||
| 296 | - | // unknown fields, duplicate keys, trailing data, invalid UTF-8, and excessive | |||
| 297 | - | // nesting. Every registration is validated independently and endpoints must be | |||
| 298 | - | // unique. Two registrations may alias one identical route (the same opaque | |||
| 299 | - | // address) under different endpoints, but two that share the pool's exact | |||
| 300 | - | // (authenticated identity, policy) pair must agree on that one route: the pool | |||
| 301 | - | // keys a physical transport by the pair and ignores the address, so a second | |||
| 302 | - | // route for the same pair is refused rather than silently dialed or ignored. A | |||
| 303 | - | // missing or malformed config is an error, never an empty default. | |||
| 304 | - | func Load(layout Layout) (*Config, error) { | |||
| 305 | - | return LoadPath(layout, filepath.Join(layout.Root, ConfigFileName)) | |||
| 306 | - | } | |||
| 307 | - | ||||
| 308 | // LoadPath parses the broker configuration at an explicit application-owned | 245 | // LoadPath parses the broker configuration at an explicit application-owned | ||
| 309 | // path while retaining the route and reserved-path validation from layout. | 246 | // path while retaining the route and reserved-path validation from layout. | ||
| 310 | func LoadPath(layout Layout, path string) (*Config, error) { | 247 | func LoadPath(layout Layout, path string) (*Config, error) { | ||
| @@ -543,25 +480,6 @@ func (c *Config) RouteByAddress(address string) (Route, bool) { | |||||
| 543 | return route, ok | 480 | return route, ok | ||
| 544 | } | 481 | } | ||
| 545 | 482 | ||||
| 546 | - | // LocalMuxRoute returns the single provisioned Unix route a remote-side mux | |||
| 547 | - | // helper bridges to. A configuration that provisions no Unix route, or more | |||
| 548 | - | // than one, is refused: a helper must never guess which daemon-mux endpoint it | |||
| 549 | - | // serves. The local binding's own carriage is deliberately excluded: the broker | |||
| 550 | - | // dials it itself, and no remote-side helper ever bridges it. | |||
| 551 | - | func (c *Config) LocalMuxRoute() (Route, error) { | |||
| 552 | - | if c == nil { | |||
| 553 | - | return Route{}, errors.New("brokerconfig: no configuration") | |||
| 554 | - | } | |||
| 555 | - | switch len(c.unixRoutes) { | |||
| 556 | - | case 0: | |||
| 557 | - | return Route{}, errors.New("brokerconfig: no Unix daemon-mux route is provisioned") | |||
| 558 | - | case 1: | |||
| 559 | - | return c.unixRoutes[0], nil | |||
| 560 | - | default: | |||
| 561 | - | return Route{}, errors.New("brokerconfig: more than one Unix daemon-mux route is provisioned") | |||
| 562 | - | } | |||
| 563 | - | } | |||
| 564 | - | ||||
| 565 | // Resolver returns the immutable endpoint resolver over this configuration. | 483 | // Resolver returns the immutable endpoint resolver over this configuration. | ||
| 566 | func (c *Config) Resolver() *Resolver { | 484 | func (c *Config) Resolver() *Resolver { | ||
| 567 | if c == nil { | 485 | if c == nil { | ||
A internal/adapters/brokerconfig/legacy_sandbox_test.go +89 -0
| @@ -0,0 +1,89 @@ | |||
| 1 | + | package brokerconfig | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "fmt" | |
| 6 | + | "path/filepath" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // ResolveLayout validates one offline root and derives its sandbox paths. | |
| 10 | + | // | |
| 11 | + | // The root must be absolute and clean, must not be the filesystem root, must | |
| 12 | + | // not contain a symlinked component, and must not equal, contain, or sit inside | |
| 13 | + | // any reserved production directory. Reserved directories are compared cleaned | |
| 14 | + | // and absolute; empty entries are ignored. The result is purely textual: no | |
| 15 | + | // directory is created here, and the caller secures the paths with | |
| 16 | + | // pkg/safedir.EnsurePrivate. | |
| 17 | + | func ResolveLayout(rawRoot string, reserved []string) (Layout, error) { | |
| 18 | + | if rawRoot == "" { | |
| 19 | + | return Layout{}, errors.New("brokerconfig: offline root is empty") | |
| 20 | + | } | |
| 21 | + | if !filepath.IsAbs(rawRoot) { | |
| 22 | + | return Layout{}, fmt.Errorf("brokerconfig: offline root %q is not absolute", rawRoot) | |
| 23 | + | } | |
| 24 | + | if cleaned := filepath.Clean(rawRoot); cleaned != rawRoot { | |
| 25 | + | return Layout{}, fmt.Errorf("brokerconfig: offline root %q is not clean", rawRoot) | |
| 26 | + | } | |
| 27 | + | if rawRoot == string(filepath.Separator) { | |
| 28 | + | return Layout{}, errors.New("brokerconfig: offline root must not be the filesystem root") | |
| 29 | + | } | |
| 30 | + | if err := rejectSymlinkedComponents(rawRoot); err != nil { | |
| 31 | + | return Layout{}, err | |
| 32 | + | } | |
| 33 | + | for _, other := range reserved { | |
| 34 | + | if other == "" { | |
| 35 | + | continue | |
| 36 | + | } | |
| 37 | + | other = filepath.Clean(other) | |
| 38 | + | if !filepath.IsAbs(other) { | |
| 39 | + | return Layout{}, fmt.Errorf("brokerconfig: reserved directory %q is not absolute", other) | |
| 40 | + | } | |
| 41 | + | if overlaps(rawRoot, other) { | |
| 42 | + | return Layout{}, fmt.Errorf("brokerconfig: offline root %q overlaps production path %q", rawRoot, other) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | runtime := filepath.Join(rawRoot, RuntimeDirName) | |
| 46 | + | return Layout{ | |
| 47 | + | Root: rawRoot, | |
| 48 | + | Runtime: runtime, | |
| 49 | + | State: filepath.Join(rawRoot, StateDirName), | |
| 50 | + | Log: filepath.Join(rawRoot, LogDirName), | |
| 51 | + | Spawn: filepath.Join(runtime, SpawnDirName), | |
| 52 | + | reserved: append([]string(nil), reserved...), | |
| 53 | + | }, nil | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // Load reads and strictly validates the config marker inside layout.Root. | |
| 57 | + | // | |
| 58 | + | // The file must be a regular owner-only file that is not a symlink and is | |
| 59 | + | // bounded by MaxConfigBytes; the JSON must carry the exact marker, reject | |
| 60 | + | // unknown fields, duplicate keys, trailing data, invalid UTF-8, and excessive | |
| 61 | + | // nesting. Every registration is validated independently and endpoints must be | |
| 62 | + | // unique. Two registrations may alias one identical route (the same opaque | |
| 63 | + | // address) under different endpoints, but two that share the pool's exact | |
| 64 | + | // (authenticated identity, policy) pair must agree on that one route: the pool | |
| 65 | + | // keys a physical transport by the pair and ignores the address, so a second | |
| 66 | + | // route for the same pair is refused rather than silently dialed or ignored. A | |
| 67 | + | // missing or malformed config is an error, never an empty default. | |
| 68 | + | func Load(layout Layout) (*Config, error) { | |
| 69 | + | return LoadPath(layout, filepath.Join(layout.Root, ConfigFileName)) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // LocalMuxRoute returns the single provisioned Unix route a remote-side mux | |
| 73 | + | // helper bridges to. A configuration that provisions no Unix route, or more | |
| 74 | + | // than one, is refused: a helper must never guess which daemon-mux endpoint it | |
| 75 | + | // serves. The local binding's own carriage is deliberately excluded: the broker | |
| 76 | + | // dials it itself, and no remote-side helper ever bridges it. | |
| 77 | + | func (c *Config) LocalMuxRoute() (Route, error) { | |
| 78 | + | if c == nil { | |
| 79 | + | return Route{}, errors.New("brokerconfig: no configuration") | |
| 80 | + | } | |
| 81 | + | switch len(c.unixRoutes) { | |
| 82 | + | case 0: | |
| 83 | + | return Route{}, errors.New("brokerconfig: no Unix daemon-mux route is provisioned") | |
| 84 | + | case 1: | |
| 85 | + | return c.unixRoutes[0], nil | |
| 86 | + | default: | |
| 87 | + | return Route{}, errors.New("brokerconfig: more than one Unix daemon-mux route is provisioned") | |
| 88 | + | } | |
| 89 | + | } | |
| @@ -0,0 +1,89 @@ | |||||
| 1 | + | package brokerconfig | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "fmt" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // ResolveLayout validates one offline root and derives its sandbox paths. | |||
| 10 | + | // | |||
| 11 | + | // The root must be absolute and clean, must not be the filesystem root, must | |||
| 12 | + | // not contain a symlinked component, and must not equal, contain, or sit inside | |||
| 13 | + | // any reserved production directory. Reserved directories are compared cleaned | |||
| 14 | + | // and absolute; empty entries are ignored. The result is purely textual: no | |||
| 15 | + | // directory is created here, and the caller secures the paths with | |||
| 16 | + | // pkg/safedir.EnsurePrivate. | |||
| 17 | + | func ResolveLayout(rawRoot string, reserved []string) (Layout, error) { | |||
| 18 | + | if rawRoot == "" { | |||
| 19 | + | return Layout{}, errors.New("brokerconfig: offline root is empty") | |||
| 20 | + | } | |||
| 21 | + | if !filepath.IsAbs(rawRoot) { | |||
| 22 | + | return Layout{}, fmt.Errorf("brokerconfig: offline root %q is not absolute", rawRoot) | |||
| 23 | + | } | |||
| 24 | + | if cleaned := filepath.Clean(rawRoot); cleaned != rawRoot { | |||
| 25 | + | return Layout{}, fmt.Errorf("brokerconfig: offline root %q is not clean", rawRoot) | |||
| 26 | + | } | |||
| 27 | + | if rawRoot == string(filepath.Separator) { | |||
| 28 | + | return Layout{}, errors.New("brokerconfig: offline root must not be the filesystem root") | |||
| 29 | + | } | |||
| 30 | + | if err := rejectSymlinkedComponents(rawRoot); err != nil { | |||
| 31 | + | return Layout{}, err | |||
| 32 | + | } | |||
| 33 | + | for _, other := range reserved { | |||
| 34 | + | if other == "" { | |||
| 35 | + | continue | |||
| 36 | + | } | |||
| 37 | + | other = filepath.Clean(other) | |||
| 38 | + | if !filepath.IsAbs(other) { | |||
| 39 | + | return Layout{}, fmt.Errorf("brokerconfig: reserved directory %q is not absolute", other) | |||
| 40 | + | } | |||
| 41 | + | if overlaps(rawRoot, other) { | |||
| 42 | + | return Layout{}, fmt.Errorf("brokerconfig: offline root %q overlaps production path %q", rawRoot, other) | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | runtime := filepath.Join(rawRoot, RuntimeDirName) | |||
| 46 | + | return Layout{ | |||
| 47 | + | Root: rawRoot, | |||
| 48 | + | Runtime: runtime, | |||
| 49 | + | State: filepath.Join(rawRoot, StateDirName), | |||
| 50 | + | Log: filepath.Join(rawRoot, LogDirName), | |||
| 51 | + | Spawn: filepath.Join(runtime, SpawnDirName), | |||
| 52 | + | reserved: append([]string(nil), reserved...), | |||
| 53 | + | }, nil | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // Load reads and strictly validates the config marker inside layout.Root. | |||
| 57 | + | // | |||
| 58 | + | // The file must be a regular owner-only file that is not a symlink and is | |||
| 59 | + | // bounded by MaxConfigBytes; the JSON must carry the exact marker, reject | |||
| 60 | + | // unknown fields, duplicate keys, trailing data, invalid UTF-8, and excessive | |||
| 61 | + | // nesting. Every registration is validated independently and endpoints must be | |||
| 62 | + | // unique. Two registrations may alias one identical route (the same opaque | |||
| 63 | + | // address) under different endpoints, but two that share the pool's exact | |||
| 64 | + | // (authenticated identity, policy) pair must agree on that one route: the pool | |||
| 65 | + | // keys a physical transport by the pair and ignores the address, so a second | |||
| 66 | + | // route for the same pair is refused rather than silently dialed or ignored. A | |||
| 67 | + | // missing or malformed config is an error, never an empty default. | |||
| 68 | + | func Load(layout Layout) (*Config, error) { | |||
| 69 | + | return LoadPath(layout, filepath.Join(layout.Root, ConfigFileName)) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // LocalMuxRoute returns the single provisioned Unix route a remote-side mux | |||
| 73 | + | // helper bridges to. A configuration that provisions no Unix route, or more | |||
| 74 | + | // than one, is refused: a helper must never guess which daemon-mux endpoint it | |||
| 75 | + | // serves. The local binding's own carriage is deliberately excluded: the broker | |||
| 76 | + | // dials it itself, and no remote-side helper ever bridges it. | |||
| 77 | + | func (c *Config) LocalMuxRoute() (Route, error) { | |||
| 78 | + | if c == nil { | |||
| 79 | + | return Route{}, errors.New("brokerconfig: no configuration") | |||
| 80 | + | } | |||
| 81 | + | switch len(c.unixRoutes) { | |||
| 82 | + | case 0: | |||
| 83 | + | return Route{}, errors.New("brokerconfig: no Unix daemon-mux route is provisioned") | |||
| 84 | + | case 1: | |||
| 85 | + | return c.unixRoutes[0], nil | |||
| 86 | + | default: | |||
| 87 | + | return Route{}, errors.New("brokerconfig: more than one Unix daemon-mux route is provisioned") | |||
| 88 | + | } | |||
| 89 | + | } | |||
A internal/app/app_sources_test.go +33 -0
| @@ -0,0 +1,33 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // loadAppSources reads every Go file of this package, keyed by file name, so | |
| 12 | + | // composition tests can assert which files own a given call. | |
| 13 | + | func loadAppSources(t *testing.T, includeTests bool) map[string]string { | |
| 14 | + | t.Helper() | |
| 15 | + | ||
| 16 | + | entries, err := os.ReadDir(".") | |
| 17 | + | require.NoError(t, err, "read app package dir") | |
| 18 | + | sources := make(map[string]string) | |
| 19 | + | for _, entry := range entries { | |
| 20 | + | name := entry.Name() | |
| 21 | + | if entry.IsDir() || !strings.HasSuffix(name, ".go") { | |
| 22 | + | continue | |
| 23 | + | } | |
| 24 | + | if !includeTests && strings.HasSuffix(name, "_test.go") { | |
| 25 | + | continue | |
| 26 | + | } | |
| 27 | + | raw, err := os.ReadFile(name) | |
| 28 | + | require.NoError(t, err, "read app source %q", name) | |
| 29 | + | sources[name] = string(raw) | |
| 30 | + | } | |
| 31 | + | require.NotEmpty(t, sources, "app sources must not be empty") | |
| 32 | + | return sources | |
| 33 | + | } | |
| @@ -0,0 +1,33 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // loadAppSources reads every Go file of this package, keyed by file name, so | |||
| 12 | + | // composition tests can assert which files own a given call. | |||
| 13 | + | func loadAppSources(t *testing.T, includeTests bool) map[string]string { | |||
| 14 | + | t.Helper() | |||
| 15 | + | ||||
| 16 | + | entries, err := os.ReadDir(".") | |||
| 17 | + | require.NoError(t, err, "read app package dir") | |||
| 18 | + | sources := make(map[string]string) | |||
| 19 | + | for _, entry := range entries { | |||
| 20 | + | name := entry.Name() | |||
| 21 | + | if entry.IsDir() || !strings.HasSuffix(name, ".go") { | |||
| 22 | + | continue | |||
| 23 | + | } | |||
| 24 | + | if !includeTests && strings.HasSuffix(name, "_test.go") { | |||
| 25 | + | continue | |||
| 26 | + | } | |||
| 27 | + | raw, err := os.ReadFile(name) | |||
| 28 | + | require.NoError(t, err, "read app source %q", name) | |||
| 29 | + | sources[name] = string(raw) | |||
| 30 | + | } | |||
| 31 | + | require.NotEmpty(t, sources, "app sources must not be empty") | |||
| 32 | + | return sources | |||
| 33 | + | } | |||
M internal/app/broker_daemon_start.go +0 -75
| @@ -8,7 +8,6 @@ import ( | |||
| 8 | 8 | "path/filepath" | |
| 9 | 9 | "syscall" | |
| 10 | 10 | ||
| 11 | - | "github.com/bnema/vev/internal/adapters/brokerconfig" | |
| 12 | 11 | "github.com/bnema/vev/internal/adapters/daemonmux" | |
| 13 | 12 | "github.com/bnema/vev/internal/adapters/ipc" | |
| 14 | 13 | "github.com/bnema/vev/internal/ports" | |
| @@ -287,77 +286,3 @@ func parseBrokerDaemonStartArgvFlag(value string) (ports.BrokerDaemonStartMode, | |||
| 287 | 286 | return 0, fmt.Errorf("vev: unknown daemon start mode %q (want existing-only or if-needed)", value) | |
| 288 | 287 | } | |
| 289 | 288 | } | |
| 290 | - | ||
| 291 | - | // helperCarriageTarget resolves the dial target for the private daemonmux | |
| 292 | - | // carriage a remote-side mux helper bridges to, strictly from that helper's own | |
| 293 | - | // broker-owned configuration. A helper owns no request and no client authority: | |
| 294 | - | // the target is the one provisioned entry whose route is exactly the carriage | |
| 295 | - | // being bridged, so the helper can only ever narrow what its own configuration | |
| 296 | - | // already permits. | |
| 297 | - | // | |
| 298 | - | // The provisioned local binding wins when it names the carriage, because the | |
| 299 | - | // local binding is the broker's own daemon authority. Otherwise the single | |
| 300 | - | // registration that provisions this carriage supplies the fence, identity, and | |
| 301 | - | // policy. A configuration that provisions neither yields no target, and a | |
| 302 | - | // start-if-needed request is then refused rather than widened. | |
| 303 | - | func helperCarriageTarget(config *brokerconfig.Config, route brokerconfig.Route) (ports.BrokerDialTarget, error) { | |
| 304 | - | if binding, ok := config.LocalBinding(); ok && binding.Route.Address() == route.Address() { | |
| 305 | - | return ports.BrokerDialTarget{ | |
| 306 | - | Fence: ports.BrokerEndpointFence{Local: true}, | |
| 307 | - | Address: binding.Route.Address(), | |
| 308 | - | Policy: binding.Policy, | |
| 309 | - | ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: binding.Identity, Bound: binding.Identity != ""}, | |
| 310 | - | }, nil | |
| 311 | - | } | |
| 312 | - | for _, endpoint := range config.Endpoints() { | |
| 313 | - | registration, ok := config.Registration(endpoint) | |
| 314 | - | if !ok || registration.Route.Address() != route.Address() { | |
| 315 | - | continue | |
| 316 | - | } | |
| 317 | - | return ports.BrokerDialTarget{ | |
| 318 | - | Fence: ports.BrokerEndpointFence{Registration: registration.Registration}, | |
| 319 | - | Address: registration.Route.Address(), | |
| 320 | - | Policy: registration.Policy, | |
| 321 | - | ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: registration.Identity, Bound: registration.Identity != ""}, | |
| 322 | - | }, nil | |
| 323 | - | } | |
| 324 | - | return ports.BrokerDialTarget{}, localDaemonStartRefused("the helper configuration provisions no daemon authority for its mux carriage") | |
| 325 | - | } | |
| 326 | - | ||
| 327 | - | // dialMuxHelperCarriage dials the private daemonmux carriage one remote-side mux | |
| 328 | - | // helper bridges to, under the start authorization the broker propagated. The | |
| 329 | - | // helper applies the same local election the broker does, from its own | |
| 330 | - | // broker-owned configuration, and it never starts a broker, an observer, or a | |
| 331 | - | // recursive helper: an existing-only mode is one carriage dial, and a | |
| 332 | - | // start-if-needed mode is the shared daemon start gated by the helper's own | |
| 333 | - | // provisioned policy and identity. | |
| 334 | - | func dialMuxHelperCarriage(ctx context.Context, config *brokerconfig.Config, mode ports.BrokerDaemonStartMode) (daemonmux.RawFramedTransport, error) { | |
| 335 | - | if config == nil { | |
| 336 | - | return nil, errors.New("vev: broker mux helper requires a configuration") | |
| 337 | - | } | |
| 338 | - | if err := mode.Validate(); err != nil { | |
| 339 | - | return nil, err | |
| 340 | - | } | |
| 341 | - | route, err := config.LocalMuxRoute() | |
| 342 | - | if err != nil { | |
| 343 | - | return nil, err | |
| 344 | - | } | |
| 345 | - | target, err := helperCarriageTarget(config, route) | |
| 346 | - | if err != nil { | |
| 347 | - | return nil, err | |
| 348 | - | } | |
| 349 | - | target.StartMode = mode | |
| 350 | - | if err := target.Validate(); err != nil { | |
| 351 | - | return nil, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: err} | |
| 352 | - | } | |
| 353 | - | // Only a policy that names the launch token may start the daemon. A carriage | |
| 354 | - | // provisioned under a non-launching policy refuses the start outright. The | |
| 355 | - | // existing-only mode never reaches this check: dialBrokerDaemonCarriage | |
| 356 | - | // returns its single dial's result immediately. | |
| 357 | - | if mode == ports.BrokerDaemonStartIfNeeded { | |
| 358 | - | if err := daemonStartRefusal(target); err != nil { | |
| 359 | - | return nil, err | |
| 360 | - | } | |
| 361 | - | } | |
| 362 | - | return dialBrokerDaemonCarriage(ctx, route.Path(), target) | |
| 363 | - | } | |
| @@ -8,7 +8,6 @@ import ( | |||||
| 8 | "path/filepath" | 8 | "path/filepath" | ||
| 9 | "syscall" | 9 | "syscall" | ||
| 10 | 10 | ||||
| 11 | - | "github.com/bnema/vev/internal/adapters/brokerconfig" | |||
| 12 | "github.com/bnema/vev/internal/adapters/daemonmux" | 11 | "github.com/bnema/vev/internal/adapters/daemonmux" | ||
| 13 | "github.com/bnema/vev/internal/adapters/ipc" | 12 | "github.com/bnema/vev/internal/adapters/ipc" | ||
| 14 | "github.com/bnema/vev/internal/ports" | 13 | "github.com/bnema/vev/internal/ports" | ||
| @@ -287,77 +286,3 @@ func parseBrokerDaemonStartArgvFlag(value string) (ports.BrokerDaemonStartMode, | |||||
| 287 | return 0, fmt.Errorf("vev: unknown daemon start mode %q (want existing-only or if-needed)", value) | 286 | return 0, fmt.Errorf("vev: unknown daemon start mode %q (want existing-only or if-needed)", value) | ||
| 288 | } | 287 | } | ||
| 289 | } | 288 | } | ||
| 290 | - | ||||
| 291 | - | // helperCarriageTarget resolves the dial target for the private daemonmux | |||
| 292 | - | // carriage a remote-side mux helper bridges to, strictly from that helper's own | |||
| 293 | - | // broker-owned configuration. A helper owns no request and no client authority: | |||
| 294 | - | // the target is the one provisioned entry whose route is exactly the carriage | |||
| 295 | - | // being bridged, so the helper can only ever narrow what its own configuration | |||
| 296 | - | // already permits. | |||
| 297 | - | // | |||
| 298 | - | // The provisioned local binding wins when it names the carriage, because the | |||
| 299 | - | // local binding is the broker's own daemon authority. Otherwise the single | |||
| 300 | - | // registration that provisions this carriage supplies the fence, identity, and | |||
| 301 | - | // policy. A configuration that provisions neither yields no target, and a | |||
| 302 | - | // start-if-needed request is then refused rather than widened. | |||
| 303 | - | func helperCarriageTarget(config *brokerconfig.Config, route brokerconfig.Route) (ports.BrokerDialTarget, error) { | |||
| 304 | - | if binding, ok := config.LocalBinding(); ok && binding.Route.Address() == route.Address() { | |||
| 305 | - | return ports.BrokerDialTarget{ | |||
| 306 | - | Fence: ports.BrokerEndpointFence{Local: true}, | |||
| 307 | - | Address: binding.Route.Address(), | |||
| 308 | - | Policy: binding.Policy, | |||
| 309 | - | ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: binding.Identity, Bound: binding.Identity != ""}, | |||
| 310 | - | }, nil | |||
| 311 | - | } | |||
| 312 | - | for _, endpoint := range config.Endpoints() { | |||
| 313 | - | registration, ok := config.Registration(endpoint) | |||
| 314 | - | if !ok || registration.Route.Address() != route.Address() { | |||
| 315 | - | continue | |||
| 316 | - | } | |||
| 317 | - | return ports.BrokerDialTarget{ | |||
| 318 | - | Fence: ports.BrokerEndpointFence{Registration: registration.Registration}, | |||
| 319 | - | Address: registration.Route.Address(), | |||
| 320 | - | Policy: registration.Policy, | |||
| 321 | - | ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: registration.Identity, Bound: registration.Identity != ""}, | |||
| 322 | - | }, nil | |||
| 323 | - | } | |||
| 324 | - | return ports.BrokerDialTarget{}, localDaemonStartRefused("the helper configuration provisions no daemon authority for its mux carriage") | |||
| 325 | - | } | |||
| 326 | - | ||||
| 327 | - | // dialMuxHelperCarriage dials the private daemonmux carriage one remote-side mux | |||
| 328 | - | // helper bridges to, under the start authorization the broker propagated. The | |||
| 329 | - | // helper applies the same local election the broker does, from its own | |||
| 330 | - | // broker-owned configuration, and it never starts a broker, an observer, or a | |||
| 331 | - | // recursive helper: an existing-only mode is one carriage dial, and a | |||
| 332 | - | // start-if-needed mode is the shared daemon start gated by the helper's own | |||
| 333 | - | // provisioned policy and identity. | |||
| 334 | - | func dialMuxHelperCarriage(ctx context.Context, config *brokerconfig.Config, mode ports.BrokerDaemonStartMode) (daemonmux.RawFramedTransport, error) { | |||
| 335 | - | if config == nil { | |||
| 336 | - | return nil, errors.New("vev: broker mux helper requires a configuration") | |||
| 337 | - | } | |||
| 338 | - | if err := mode.Validate(); err != nil { | |||
| 339 | - | return nil, err | |||
| 340 | - | } | |||
| 341 | - | route, err := config.LocalMuxRoute() | |||
| 342 | - | if err != nil { | |||
| 343 | - | return nil, err | |||
| 344 | - | } | |||
| 345 | - | target, err := helperCarriageTarget(config, route) | |||
| 346 | - | if err != nil { | |||
| 347 | - | return nil, err | |||
| 348 | - | } | |||
| 349 | - | target.StartMode = mode | |||
| 350 | - | if err := target.Validate(); err != nil { | |||
| 351 | - | return nil, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: err} | |||
| 352 | - | } | |||
| 353 | - | // Only a policy that names the launch token may start the daemon. A carriage | |||
| 354 | - | // provisioned under a non-launching policy refuses the start outright. The | |||
| 355 | - | // existing-only mode never reaches this check: dialBrokerDaemonCarriage | |||
| 356 | - | // returns its single dial's result immediately. | |||
| 357 | - | if mode == ports.BrokerDaemonStartIfNeeded { | |||
| 358 | - | if err := daemonStartRefusal(target); err != nil { | |||
| 359 | - | return nil, err | |||
| 360 | - | } | |||
| 361 | - | } | |||
| 362 | - | return dialBrokerDaemonCarriage(ctx, route.Path(), target) | |||
| 363 | - | } | |||
M internal/app/broker_daemon_start_test.go +3 -211
| @@ -4,7 +4,6 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "io" | |
| 7 | - | "log/slog" | |
| 8 | 7 | "os" | |
| 9 | 8 | "path/filepath" | |
| 10 | 9 | "sync" | |
| @@ -214,30 +213,6 @@ func startableLocalTarget(t *testing.T, carriage string, mode ports.BrokerDaemon | |||
| 214 | 213 | return target | |
| 215 | 214 | } | |
| 216 | 215 | ||
| 217 | - | // writePrivateSandboxConfig writes one marker-valid sandbox whose single local | |
| 218 | - | // binding is the supplied private carriage under the supplied policy. | |
| 219 | - | func writePrivateSandboxConfig(t *testing.T, root, carriage string, policy ports.BrokerPolicy) { | |
| 220 | - | t.Helper() | |
| 221 | - | writeSandboxConfig(t, root, map[string]any{ | |
| 222 | - | "marker": brokerconfig.Marker, | |
| 223 | - | "registrations": []any{}, | |
| 224 | - | "local": map[string]any{ | |
| 225 | - | "identity": brokerLocalTestIdentity, | |
| 226 | - | "displayOrigin": "local", | |
| 227 | - | "route": carriage, | |
| 228 | - | "policy": map[string]any{ | |
| 229 | - | "protocolVersion": policy.ProtocolVersion, | |
| 230 | - | "catalogSchemaVersion": policy.CatalogSchemaVersion, | |
| 231 | - | "environmentPolicy": "client-owned", | |
| 232 | - | "transport": policy.Transport, | |
| 233 | - | "trust": policy.Trust, | |
| 234 | - | "launch": policy.Launch, | |
| 235 | - | "isolation": policy.Isolation, | |
| 236 | - | }, | |
| 237 | - | }, | |
| 238 | - | }) | |
| 239 | - | } | |
| 240 | - | ||
| 241 | 216 | // TestDaemonStartExistingOnlyNeverSpawns proves an existing-only acquisition is | |
| 242 | 217 | // exactly one carriage dial: no lifecycle probe, no spawn lock, no process, and | |
| 243 | 218 | // no fallback of any kind, even though the carriage is absent. | |
| @@ -635,105 +610,6 @@ func TestDaemonStartRefusesInvalidTarget(t *testing.T) { | |||
| 635 | 610 | require.Zero(t, spawns) | |
| 636 | 611 | } | |
| 637 | 612 | ||
| 638 | - | // helperSandboxConfigForCarriage provisions one sandbox whose single | |
| 639 | - | // registration bridges the supplied private Unix carriage under the supplied | |
| 640 | - | // policy, and returns its loaded configuration. A remote-side mux helper owns | |
| 641 | - | // exactly this shape: the provisioning is the helper's own authority. | |
| 642 | - | func helperSandboxConfigForCarriage(t *testing.T, carriage string, policy ports.BrokerPolicy) *brokerconfig.Config { | |
| 643 | - | t.Helper() | |
| 644 | - | root := filepath.Dir(carriage) | |
| 645 | - | writeSandboxConfig(t, root, map[string]any{ | |
| 646 | - | "marker": brokerconfig.Marker, | |
| 647 | - | "registrations": []any{map[string]any{ | |
| 648 | - | "endpoint": brokerTestEndpoint, | |
| 649 | - | "incarnation": "0102030405060708090a0b0c0d0e0f10", | |
| 650 | - | "generation": 1, | |
| 651 | - | "identity": brokerTestIdentity, | |
| 652 | - | "route": carriage, | |
| 653 | - | "policy": map[string]any{ | |
| 654 | - | "protocolVersion": policy.ProtocolVersion, | |
| 655 | - | "catalogSchemaVersion": policy.CatalogSchemaVersion, | |
| 656 | - | "environmentPolicy": "client-owned", | |
| 657 | - | "transport": policy.Transport, | |
| 658 | - | "trust": policy.Trust, | |
| 659 | - | "launch": policy.Launch, | |
| 660 | - | "isolation": policy.Isolation, | |
| 661 | - | }, | |
| 662 | - | }}, | |
| 663 | - | }) | |
| 664 | - | config, err := brokerconfig.Load(mustOfflineLayout(t, root)) | |
| 665 | - | require.NoError(t, err) | |
| 666 | - | return config | |
| 667 | - | } | |
| 668 | - | ||
| 669 | - | // TestMuxHelperCarriageStartAuthorization proves the remote-side helper applies | |
| 670 | - | // the propagated authorization against its own provisioned authority: an | |
| 671 | - | // existing-only request is one carriage dial, a start-if-needed request is gated | |
| 672 | - | // by the helper's own policy, and an unknown mode is refused outright. | |
| 673 | - | func TestMuxHelperCarriageStartAuthorization(t *testing.T) { | |
| 674 | - | isolateSandboxEnv(t) | |
| 675 | - | dir, carriage := testDaemonCarriage(t) | |
| 676 | - | config := helperSandboxConfigForCarriage(t, carriage, brokerTestPolicy()) | |
| 677 | - | ||
| 678 | - | scripted := &scriptedDaemonStarter{carriage: carriage} | |
| 679 | - | withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff)) | |
| 680 | - | ||
| 681 | - | _, err := dialMuxHelperCarriage(context.Background(), config, ports.BrokerDaemonExistingOnly) | |
| 682 | - | require.ErrorIs(t, err, os.ErrNotExist) | |
| 683 | - | dials, spawns := scripted.counters() | |
| 684 | - | require.Equal(t, 1, dials, "existing-only is exactly one carriage dial") | |
| 685 | - | require.Zero(t, spawns) | |
| 686 | - | ||
| 687 | - | // The provisioned policy does not name the launch authority, so a | |
| 688 | - | // start-if-needed request is refused before any dial and never spawns: the | |
| 689 | - | // helper's own authority is static, so the refusal is decided up front. | |
| 690 | - | _, err = dialMuxHelperCarriage(context.Background(), config, ports.BrokerDaemonStartIfNeeded) | |
| 691 | - | require.Error(t, err) | |
| 692 | - | var typed ports.BrokerError | |
| 693 | - | require.ErrorAs(t, err, &typed) | |
| 694 | - | require.Equal(t, ports.BrokerErrorConflictingPolicy, typed.Code) | |
| 695 | - | dials, spawns = scripted.counters() | |
| 696 | - | require.Equal(t, 1, dials, "a policy refusal is decided before any dial") | |
| 697 | - | require.Zero(t, spawns, "a non-launching helper policy must never spawn") | |
| 698 | - | require.NoFileExists(t, filepath.Join(dir, spawnLockName)) | |
| 699 | - | ||
| 700 | - | // An unknown mode and a missing configuration are never defaulted. | |
| 701 | - | _, err = dialMuxHelperCarriage(context.Background(), config, ports.BrokerDaemonStartMode(9)) | |
| 702 | - | require.Error(t, err) | |
| 703 | - | _, err = dialMuxHelperCarriage(context.Background(), nil, ports.BrokerDaemonExistingOnly) | |
| 704 | - | require.Error(t, err) | |
| 705 | - | dials, spawns = scripted.counters() | |
| 706 | - | require.Equal(t, 1, dials, "a refused mode never reaches a dial") | |
| 707 | - | require.Zero(t, spawns) | |
| 708 | - | } | |
| 709 | - | ||
| 710 | - | // TestMuxHelperCarriageStartsUnderItsOwnLaunchAuthority proves the helper starts | |
| 711 | - | // the daemon only when its own provisioned policy authorizes launching, and that | |
| 712 | - | // it starts the daemon published on its own machine rather than another broker. | |
| 713 | - | func TestMuxHelperCarriageStartsUnderItsOwnLaunchAuthority(t *testing.T) { | |
| 714 | - | isolateSandboxEnv(t) | |
| 715 | - | _, carriage := testDaemonCarriage(t) | |
| 716 | - | config := helperSandboxConfigForCarriage(t, carriage, brokerTestPolicyForLaunch()) | |
| 717 | - | ||
| 718 | - | scripted := &scriptedDaemonStarter{carriage: carriage} | |
| 719 | - | withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff)) | |
| 720 | - | ||
| 721 | - | raw, err := dialMuxHelperCarriage(context.Background(), config, ports.BrokerDaemonStartIfNeeded) | |
| 722 | - | require.NoError(t, err) | |
| 723 | - | require.NotNil(t, raw) | |
| 724 | - | _, spawns := scripted.counters() | |
| 725 | - | require.Equal(t, 1, spawns) | |
| 726 | - | } | |
| 727 | - | ||
| 728 | - | // brokerTestPolicyForLaunch is the sandbox policy with the launch authority the | |
| 729 | - | // broker's own production local daemon names, so a helper test can exercise the | |
| 730 | - | // authorized start path without provisioning production layout. | |
| 731 | - | func brokerTestPolicyForLaunch() ports.BrokerPolicy { | |
| 732 | - | policy := brokerTestPolicy() | |
| 733 | - | policy.Launch = brokerLaunchAuthority | |
| 734 | - | return policy | |
| 735 | - | } | |
| 736 | - | ||
| 737 | 613 | // TestDaemonStartArgvFlagRoundTrips proves the helper argument spelling is one | |
| 738 | 614 | // closed value both sides agree on, and that an unknown value is refused in | |
| 739 | 615 | // either direction. | |
| @@ -761,97 +637,13 @@ func TestDaemonStartArgvFlagRoundTrips(t *testing.T) { | |||
| 761 | 637 | } | |
| 762 | 638 | } | |
| 763 | 639 | ||
| 764 | - | // testLocalSandboxConfigForCarriage provisions one sandbox whose single local | |
| 765 | - | // binding is the supplied private carriage, and returns its loaded | |
| 766 | - | // configuration and binding. | |
| 640 | + | // testLocalSandboxConfigForCarriage derives local authority from production state. | |
| 767 | 641 | func testLocalSandboxConfigForCarriage(t *testing.T, carriage string) (*brokerconfig.Config, brokerconfig.LocalBinding) { | |
| 768 | 642 | t.Helper() | |
| 769 | - | root := filepath.Dir(carriage) | |
| 770 | - | writePrivateSandboxConfig(t, root, carriage, brokerTestPolicy()) | |
| 771 | - | layout := mustOfflineLayout(t, root) | |
| 772 | - | config, err := brokerconfig.Load(layout) | |
| 643 | + | layout := emptyProductionBrokerLayout(t, "") | |
| 644 | + | config, err := brokerconfig.LoadProduction(layout, productionBrokerConfigPath(), brokerLocalTestIdentity, brokerTestPolicy(), carriage) | |
| 773 | 645 | require.NoError(t, err) | |
| 774 | 646 | binding, ok := config.LocalBinding() | |
| 775 | 647 | require.True(t, ok) | |
| 776 | 648 | return config, binding | |
| 777 | 649 | } | |
| 778 | - | ||
| 779 | - | func mustOfflineLayout(t *testing.T, root string) brokerconfig.Layout { | |
| 780 | - | t.Helper() | |
| 781 | - | layout, err := offlineLayout(root) | |
| 782 | - | require.NoError(t, err) | |
| 783 | - | return layout | |
| 784 | - | } | |
| 785 | - | ||
| 786 | - | func mustLocalMuxAddress(t *testing.T, config *brokerconfig.Config) string { | |
| 787 | - | t.Helper() | |
| 788 | - | route, err := config.LocalMuxRoute() | |
| 789 | - | require.NoError(t, err) | |
| 790 | - | return route.Address() | |
| 791 | - | } | |
| 792 | - | ||
| 793 | - | // TestBrokerMuxConnectorObservationNeverSpawns proves the composed broker | |
| 794 | - | // connector dials the broker-owned local carriage for an observation under the | |
| 795 | - | // existing-only authorization and spawns nothing, so observing a stopped local | |
| 796 | - | // daemon stays a reported failure rather than a start. | |
| 797 | - | func TestBrokerMuxConnectorObservationNeverSpawns(t *testing.T) { | |
| 798 | - | isolateSandboxEnv(t) | |
| 799 | - | _, carriage := testDaemonCarriage(t) | |
| 800 | - | config, binding := testLocalSandboxConfigForCarriage(t, carriage) | |
| 801 | - | ||
| 802 | - | scripted := &scriptedDaemonStarter{carriage: carriage} | |
| 803 | - | withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff)) | |
| 804 | - | ||
| 805 | - | connector, err := brokerMuxConnector(config, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 806 | - | require.NoError(t, err) | |
| 807 | - | target, err := config.Resolver().ResolveDialTarget(context.Background(), ports.BrokerOpenStreamRequest{ | |
| 808 | - | Purpose: ports.BrokerStreamObservation, | |
| 809 | - | Local: true, | |
| 810 | - | Stream: 1, | |
| 811 | - | Policy: binding.Policy, | |
| 812 | - | StartMode: ports.BrokerDaemonExistingOnly, | |
| 813 | - | }) | |
| 814 | - | require.NoError(t, err) | |
| 815 | - | ||
| 816 | - | _, err = connector.Connect(context.Background(), target) | |
| 817 | - | require.ErrorIs(t, err, os.ErrNotExist) | |
| 818 | - | dials, spawns := scripted.counters() | |
| 819 | - | require.Equal(t, 1, dials, "the observation dials its local carriage once") | |
| 820 | - | require.Zero(t, spawns, "an observation must never start a daemon") | |
| 821 | - | } | |
| 822 | - | ||
| 823 | - | // TestBrokerMuxConnectorRefusesMismatchedAuthority proves the transport never | |
| 824 | - | // dials under an authority the configuration did not provision: a target whose | |
| 825 | - | // policy or address no longer matches the route is refused before any dial. | |
| 826 | - | func TestBrokerMuxConnectorRefusesMismatchedAuthority(t *testing.T) { | |
| 827 | - | isolateSandboxEnv(t) | |
| 828 | - | _, carriage := testDaemonCarriage(t) | |
| 829 | - | config, _ := testLocalSandboxConfigForCarriage(t, carriage) | |
| 830 | - | ||
| 831 | - | scripted := &scriptedDaemonStarter{carriage: carriage} | |
| 832 | - | withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff)) | |
| 833 | - | ||
| 834 | - | connector, err := brokerMuxConnector(config, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 835 | - | require.NoError(t, err) | |
| 836 | - | ||
| 837 | - | target := startableLocalTarget(t, carriage, ports.BrokerDaemonExistingOnly) | |
| 838 | - | // The address is provisioned, but the policy is not. | |
| 839 | - | target.Policy.Launch = "other-launch" | |
| 840 | - | require.NoError(t, target.Validate()) | |
| 841 | - | _, err = connector.Connect(context.Background(), target) | |
| 842 | - | require.Error(t, err) | |
| 843 | - | var typed ports.BrokerError | |
| 844 | - | require.ErrorAs(t, err, &typed) | |
| 845 | - | require.Equal(t, ports.BrokerErrorConflictingPolicy, typed.Code) | |
| 846 | - | ||
| 847 | - | // An address the configuration never produced is refused too. | |
| 848 | - | unknown := target | |
| 849 | - | unknown.Address = "0000000000000000000000000000000000000000000000000000000000000000" | |
| 850 | - | require.NoError(t, unknown.Validate()) | |
| 851 | - | _, err = connector.Connect(context.Background(), unknown) | |
| 852 | - | require.Error(t, err) | |
| 853 | - | ||
| 854 | - | dials, spawns := scripted.counters() | |
| 855 | - | require.Zero(t, dials, "an unmatched authority must never reach a dial") | |
| 856 | - | require.Zero(t, spawns) | |
| 857 | - | } | |
| @@ -4,7 +4,6 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "io" | 6 | "io" | ||
| 7 | - | "log/slog" | |||
| 8 | "os" | 7 | "os" | ||
| 9 | "path/filepath" | 8 | "path/filepath" | ||
| 10 | "sync" | 9 | "sync" | ||
| @@ -214,30 +213,6 @@ func startableLocalTarget(t *testing.T, carriage string, mode ports.BrokerDaemon | |||||
| 214 | return target | 213 | return target | ||
| 215 | } | 214 | } | ||
| 216 | 215 | ||||
| 217 | - | // writePrivateSandboxConfig writes one marker-valid sandbox whose single local | |||
| 218 | - | // binding is the supplied private carriage under the supplied policy. | |||
| 219 | - | func writePrivateSandboxConfig(t *testing.T, root, carriage string, policy ports.BrokerPolicy) { | |||
| 220 | - | t.Helper() | |||
| 221 | - | writeSandboxConfig(t, root, map[string]any{ | |||
| 222 | - | "marker": brokerconfig.Marker, | |||
| 223 | - | "registrations": []any{}, | |||
| 224 | - | "local": map[string]any{ | |||
| 225 | - | "identity": brokerLocalTestIdentity, | |||
| 226 | - | "displayOrigin": "local", | |||
| 227 | - | "route": carriage, | |||
| 228 | - | "policy": map[string]any{ | |||
| 229 | - | "protocolVersion": policy.ProtocolVersion, | |||
| 230 | - | "catalogSchemaVersion": policy.CatalogSchemaVersion, | |||
| 231 | - | "environmentPolicy": "client-owned", | |||
| 232 | - | "transport": policy.Transport, | |||
| 233 | - | "trust": policy.Trust, | |||
| 234 | - | "launch": policy.Launch, | |||
| 235 | - | "isolation": policy.Isolation, | |||
| 236 | - | }, | |||
| 237 | - | }, | |||
| 238 | - | }) | |||
| 239 | - | } | |||
| 240 | - | ||||
| 241 | // TestDaemonStartExistingOnlyNeverSpawns proves an existing-only acquisition is | 216 | // TestDaemonStartExistingOnlyNeverSpawns proves an existing-only acquisition is | ||
| 242 | // exactly one carriage dial: no lifecycle probe, no spawn lock, no process, and | 217 | // exactly one carriage dial: no lifecycle probe, no spawn lock, no process, and | ||
| 243 | // no fallback of any kind, even though the carriage is absent. | 218 | // no fallback of any kind, even though the carriage is absent. | ||
| @@ -635,105 +610,6 @@ func TestDaemonStartRefusesInvalidTarget(t *testing.T) { | |||||
| 635 | require.Zero(t, spawns) | 610 | require.Zero(t, spawns) | ||
| 636 | } | 611 | } | ||
| 637 | 612 | ||||
| 638 | - | // helperSandboxConfigForCarriage provisions one sandbox whose single | |||
| 639 | - | // registration bridges the supplied private Unix carriage under the supplied | |||
| 640 | - | // policy, and returns its loaded configuration. A remote-side mux helper owns | |||
| 641 | - | // exactly this shape: the provisioning is the helper's own authority. | |||
| 642 | - | func helperSandboxConfigForCarriage(t *testing.T, carriage string, policy ports.BrokerPolicy) *brokerconfig.Config { | |||
| 643 | - | t.Helper() | |||
| 644 | - | root := filepath.Dir(carriage) | |||
| 645 | - | writeSandboxConfig(t, root, map[string]any{ | |||
| 646 | - | "marker": brokerconfig.Marker, | |||
| 647 | - | "registrations": []any{map[string]any{ | |||
| 648 | - | "endpoint": brokerTestEndpoint, | |||
| 649 | - | "incarnation": "0102030405060708090a0b0c0d0e0f10", | |||
| 650 | - | "generation": 1, | |||
| 651 | - | "identity": brokerTestIdentity, | |||
| 652 | - | "route": carriage, | |||
| 653 | - | "policy": map[string]any{ | |||
| 654 | - | "protocolVersion": policy.ProtocolVersion, | |||
| 655 | - | "catalogSchemaVersion": policy.CatalogSchemaVersion, | |||
| 656 | - | "environmentPolicy": "client-owned", | |||
| 657 | - | "transport": policy.Transport, | |||
| 658 | - | "trust": policy.Trust, | |||
| 659 | - | "launch": policy.Launch, | |||
| 660 | - | "isolation": policy.Isolation, | |||
| 661 | - | }, | |||
| 662 | - | }}, | |||
| 663 | - | }) | |||
| 664 | - | config, err := brokerconfig.Load(mustOfflineLayout(t, root)) | |||
| 665 | - | require.NoError(t, err) | |||
| 666 | - | return config | |||
| 667 | - | } | |||
| 668 | - | ||||
| 669 | - | // TestMuxHelperCarriageStartAuthorization proves the remote-side helper applies | |||
| 670 | - | // the propagated authorization against its own provisioned authority: an | |||
| 671 | - | // existing-only request is one carriage dial, a start-if-needed request is gated | |||
| 672 | - | // by the helper's own policy, and an unknown mode is refused outright. | |||
| 673 | - | func TestMuxHelperCarriageStartAuthorization(t *testing.T) { | |||
| 674 | - | isolateSandboxEnv(t) | |||
| 675 | - | dir, carriage := testDaemonCarriage(t) | |||
| 676 | - | config := helperSandboxConfigForCarriage(t, carriage, brokerTestPolicy()) | |||
| 677 | - | ||||
| 678 | - | scripted := &scriptedDaemonStarter{carriage: carriage} | |||
| 679 | - | withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff)) | |||
| 680 | - | ||||
| 681 | - | _, err := dialMuxHelperCarriage(context.Background(), config, ports.BrokerDaemonExistingOnly) | |||
| 682 | - | require.ErrorIs(t, err, os.ErrNotExist) | |||
| 683 | - | dials, spawns := scripted.counters() | |||
| 684 | - | require.Equal(t, 1, dials, "existing-only is exactly one carriage dial") | |||
| 685 | - | require.Zero(t, spawns) | |||
| 686 | - | ||||
| 687 | - | // The provisioned policy does not name the launch authority, so a | |||
| 688 | - | // start-if-needed request is refused before any dial and never spawns: the | |||
| 689 | - | // helper's own authority is static, so the refusal is decided up front. | |||
| 690 | - | _, err = dialMuxHelperCarriage(context.Background(), config, ports.BrokerDaemonStartIfNeeded) | |||
| 691 | - | require.Error(t, err) | |||
| 692 | - | var typed ports.BrokerError | |||
| 693 | - | require.ErrorAs(t, err, &typed) | |||
| 694 | - | require.Equal(t, ports.BrokerErrorConflictingPolicy, typed.Code) | |||
| 695 | - | dials, spawns = scripted.counters() | |||
| 696 | - | require.Equal(t, 1, dials, "a policy refusal is decided before any dial") | |||
| 697 | - | require.Zero(t, spawns, "a non-launching helper policy must never spawn") | |||
| 698 | - | require.NoFileExists(t, filepath.Join(dir, spawnLockName)) | |||
| 699 | - | ||||
| 700 | - | // An unknown mode and a missing configuration are never defaulted. | |||
| 701 | - | _, err = dialMuxHelperCarriage(context.Background(), config, ports.BrokerDaemonStartMode(9)) | |||
| 702 | - | require.Error(t, err) | |||
| 703 | - | _, err = dialMuxHelperCarriage(context.Background(), nil, ports.BrokerDaemonExistingOnly) | |||
| 704 | - | require.Error(t, err) | |||
| 705 | - | dials, spawns = scripted.counters() | |||
| 706 | - | require.Equal(t, 1, dials, "a refused mode never reaches a dial") | |||
| 707 | - | require.Zero(t, spawns) | |||
| 708 | - | } | |||
| 709 | - | ||||
| 710 | - | // TestMuxHelperCarriageStartsUnderItsOwnLaunchAuthority proves the helper starts | |||
| 711 | - | // the daemon only when its own provisioned policy authorizes launching, and that | |||
| 712 | - | // it starts the daemon published on its own machine rather than another broker. | |||
| 713 | - | func TestMuxHelperCarriageStartsUnderItsOwnLaunchAuthority(t *testing.T) { | |||
| 714 | - | isolateSandboxEnv(t) | |||
| 715 | - | _, carriage := testDaemonCarriage(t) | |||
| 716 | - | config := helperSandboxConfigForCarriage(t, carriage, brokerTestPolicyForLaunch()) | |||
| 717 | - | ||||
| 718 | - | scripted := &scriptedDaemonStarter{carriage: carriage} | |||
| 719 | - | withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff)) | |||
| 720 | - | ||||
| 721 | - | raw, err := dialMuxHelperCarriage(context.Background(), config, ports.BrokerDaemonStartIfNeeded) | |||
| 722 | - | require.NoError(t, err) | |||
| 723 | - | require.NotNil(t, raw) | |||
| 724 | - | _, spawns := scripted.counters() | |||
| 725 | - | require.Equal(t, 1, spawns) | |||
| 726 | - | } | |||
| 727 | - | ||||
| 728 | - | // brokerTestPolicyForLaunch is the sandbox policy with the launch authority the | |||
| 729 | - | // broker's own production local daemon names, so a helper test can exercise the | |||
| 730 | - | // authorized start path without provisioning production layout. | |||
| 731 | - | func brokerTestPolicyForLaunch() ports.BrokerPolicy { | |||
| 732 | - | policy := brokerTestPolicy() | |||
| 733 | - | policy.Launch = brokerLaunchAuthority | |||
| 734 | - | return policy | |||
| 735 | - | } | |||
| 736 | - | ||||
| 737 | // TestDaemonStartArgvFlagRoundTrips proves the helper argument spelling is one | 613 | // TestDaemonStartArgvFlagRoundTrips proves the helper argument spelling is one | ||
| 738 | // closed value both sides agree on, and that an unknown value is refused in | 614 | // closed value both sides agree on, and that an unknown value is refused in | ||
| 739 | // either direction. | 615 | // either direction. | ||
| @@ -761,97 +637,13 @@ func TestDaemonStartArgvFlagRoundTrips(t *testing.T) { | |||||
| 761 | } | 637 | } | ||
| 762 | } | 638 | } | ||
| 763 | 639 | ||||
| 764 | - | // testLocalSandboxConfigForCarriage provisions one sandbox whose single local | 640 | + | // testLocalSandboxConfigForCarriage derives local authority from production state. |
| 765 | - | // binding is the supplied private carriage, and returns its loaded | |||
| 766 | - | // configuration and binding. | |||
| 767 | func testLocalSandboxConfigForCarriage(t *testing.T, carriage string) (*brokerconfig.Config, brokerconfig.LocalBinding) { | 641 | func testLocalSandboxConfigForCarriage(t *testing.T, carriage string) (*brokerconfig.Config, brokerconfig.LocalBinding) { | ||
| 768 | t.Helper() | 642 | t.Helper() | ||
| 769 | - | root := filepath.Dir(carriage) | 643 | + | layout := emptyProductionBrokerLayout(t, "") |
| 770 | - | writePrivateSandboxConfig(t, root, carriage, brokerTestPolicy()) | 644 | + | config, err := brokerconfig.LoadProduction(layout, productionBrokerConfigPath(), brokerLocalTestIdentity, brokerTestPolicy(), carriage) |
| 771 | - | layout := mustOfflineLayout(t, root) | |||
| 772 | - | config, err := brokerconfig.Load(layout) | |||
| 773 | require.NoError(t, err) | 645 | require.NoError(t, err) | ||
| 774 | binding, ok := config.LocalBinding() | 646 | binding, ok := config.LocalBinding() | ||
| 775 | require.True(t, ok) | 647 | require.True(t, ok) | ||
| 776 | return config, binding | 648 | return config, binding | ||
| 777 | } | 649 | } | ||
| 778 | - | ||||
| 779 | - | func mustOfflineLayout(t *testing.T, root string) brokerconfig.Layout { | |||
| 780 | - | t.Helper() | |||
| 781 | - | layout, err := offlineLayout(root) | |||
| 782 | - | require.NoError(t, err) | |||
| 783 | - | return layout | |||
| 784 | - | } | |||
| 785 | - | ||||
| 786 | - | func mustLocalMuxAddress(t *testing.T, config *brokerconfig.Config) string { | |||
| 787 | - | t.Helper() | |||
| 788 | - | route, err := config.LocalMuxRoute() | |||
| 789 | - | require.NoError(t, err) | |||
| 790 | - | return route.Address() | |||
| 791 | - | } | |||
| 792 | - | ||||
| 793 | - | // TestBrokerMuxConnectorObservationNeverSpawns proves the composed broker | |||
| 794 | - | // connector dials the broker-owned local carriage for an observation under the | |||
| 795 | - | // existing-only authorization and spawns nothing, so observing a stopped local | |||
| 796 | - | // daemon stays a reported failure rather than a start. | |||
| 797 | - | func TestBrokerMuxConnectorObservationNeverSpawns(t *testing.T) { | |||
| 798 | - | isolateSandboxEnv(t) | |||
| 799 | - | _, carriage := testDaemonCarriage(t) | |||
| 800 | - | config, binding := testLocalSandboxConfigForCarriage(t, carriage) | |||
| 801 | - | ||||
| 802 | - | scripted := &scriptedDaemonStarter{carriage: carriage} | |||
| 803 | - | withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff)) | |||
| 804 | - | ||||
| 805 | - | connector, err := brokerMuxConnector(config, slog.New(slog.NewTextHandler(io.Discard, nil))) | |||
| 806 | - | require.NoError(t, err) | |||
| 807 | - | target, err := config.Resolver().ResolveDialTarget(context.Background(), ports.BrokerOpenStreamRequest{ | |||
| 808 | - | Purpose: ports.BrokerStreamObservation, | |||
| 809 | - | Local: true, | |||
| 810 | - | Stream: 1, | |||
| 811 | - | Policy: binding.Policy, | |||
| 812 | - | StartMode: ports.BrokerDaemonExistingOnly, | |||
| 813 | - | }) | |||
| 814 | - | require.NoError(t, err) | |||
| 815 | - | ||||
| 816 | - | _, err = connector.Connect(context.Background(), target) | |||
| 817 | - | require.ErrorIs(t, err, os.ErrNotExist) | |||
| 818 | - | dials, spawns := scripted.counters() | |||
| 819 | - | require.Equal(t, 1, dials, "the observation dials its local carriage once") | |||
| 820 | - | require.Zero(t, spawns, "an observation must never start a daemon") | |||
| 821 | - | } | |||
| 822 | - | ||||
| 823 | - | // TestBrokerMuxConnectorRefusesMismatchedAuthority proves the transport never | |||
| 824 | - | // dials under an authority the configuration did not provision: a target whose | |||
| 825 | - | // policy or address no longer matches the route is refused before any dial. | |||
| 826 | - | func TestBrokerMuxConnectorRefusesMismatchedAuthority(t *testing.T) { | |||
| 827 | - | isolateSandboxEnv(t) | |||
| 828 | - | _, carriage := testDaemonCarriage(t) | |||
| 829 | - | config, _ := testLocalSandboxConfigForCarriage(t, carriage) | |||
| 830 | - | ||||
| 831 | - | scripted := &scriptedDaemonStarter{carriage: carriage} | |||
| 832 | - | withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff)) | |||
| 833 | - | ||||
| 834 | - | connector, err := brokerMuxConnector(config, slog.New(slog.NewTextHandler(io.Discard, nil))) | |||
| 835 | - | require.NoError(t, err) | |||
| 836 | - | ||||
| 837 | - | target := startableLocalTarget(t, carriage, ports.BrokerDaemonExistingOnly) | |||
| 838 | - | // The address is provisioned, but the policy is not. | |||
| 839 | - | target.Policy.Launch = "other-launch" | |||
| 840 | - | require.NoError(t, target.Validate()) | |||
| 841 | - | _, err = connector.Connect(context.Background(), target) | |||
| 842 | - | require.Error(t, err) | |||
| 843 | - | var typed ports.BrokerError | |||
| 844 | - | require.ErrorAs(t, err, &typed) | |||
| 845 | - | require.Equal(t, ports.BrokerErrorConflictingPolicy, typed.Code) | |||
| 846 | - | ||||
| 847 | - | // An address the configuration never produced is refused too. | |||
| 848 | - | unknown := target | |||
| 849 | - | unknown.Address = "0000000000000000000000000000000000000000000000000000000000000000" | |||
| 850 | - | require.NoError(t, unknown.Validate()) | |||
| 851 | - | _, err = connector.Connect(context.Background(), unknown) | |||
| 852 | - | require.Error(t, err) | |||
| 853 | - | ||||
| 854 | - | dials, spawns := scripted.counters() | |||
| 855 | - | require.Zero(t, dials, "an unmatched authority must never reach a dial") | |||
| 856 | - | require.Zero(t, spawns) | |||
| 857 | - | } | |||
M internal/app/broker_offline.go +51 -162
| @@ -10,8 +10,6 @@ import ( | |||
| 10 | 10 | "log/slog" | |
| 11 | 11 | "os" | |
| 12 | 12 | "os/signal" | |
| 13 | - | "path/filepath" | |
| 14 | - | "strings" | |
| 15 | 13 | "syscall" | |
| 16 | 14 | "time" | |
| 17 | 15 | ||
| @@ -46,10 +44,7 @@ import ( | |||
| 46 | 44 | // until a signal or its parent context ends it, or until the broker idles out, | |
| 47 | 45 | // and then shuts down in a fixed order. | |
| 48 | 46 | ||
| 49 | - | const ( | |
| 50 | - | brokerServeCommand = "_broker-serve" | |
| 51 | - | productionBrokerServeCommand = "_broker-production-serve" | |
| 52 | - | ) | |
| 47 | + | const productionBrokerServeCommand = "_broker-production-serve" | |
| 53 | 48 | ||
| 54 | 49 | // brokerServePoolLimits bound the offline broker pool. They are explicit, not | |
| 55 | 50 | // derived from the environment, and small enough that a stalled peer is | |
| @@ -73,70 +68,17 @@ func brokerPoolLimits(config *brokerconfig.Config) broker.PoolLimits { | |||
| 73 | 68 | ||
| 74 | 69 | // brokerServeOptions is the parsed hidden invocation. | |
| 75 | 70 | type brokerServeOptions struct { | |
| 76 | - | offlineRoot string | |
| 77 | - | production bool | |
| 78 | 71 | // idleGrace is zero when the operator did not override it; the supervisor | |
| 79 | 72 | // then applies its own 5m default. | |
| 80 | 73 | idleGrace time.Duration | |
| 81 | 74 | } | |
| 82 | 75 | ||
| 83 | - | // parseBrokerServeArgs strictly parses `_broker-serve --offline-root ABS | |
| 84 | - | // [--idle-grace DURATION]`. Unknown flags, duplicate flags, positionals, a | |
| 85 | - | // missing value, and a non-positive or unparsable duration are refused; the | |
| 86 | - | // path itself is validated later against the filesystem. | |
| 76 | + | // parseProductionBrokerServeArgs parses the argument-free hidden serve entry. | |
| 87 | 77 | func parseProductionBrokerServeArgs(args []string) (command, error) { | |
| 88 | 78 | if len(args) != 0 { | |
| 89 | 79 | return command{}, usagef("`%s` does not accept arguments", productionBrokerServeCommand) | |
| 90 | 80 | } | |
| 91 | - | return command{kind: kindProductionBrokerServe, brokerServe: brokerServeOptions{production: true}}, nil | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | func parseBrokerServeArgs(args []string) (command, error) { | |
| 95 | - | if len(args) == 0 { | |
| 96 | - | return command{}, usagef("`%s` requires --offline-root", brokerServeCommand) | |
| 97 | - | } | |
| 98 | - | options := brokerServeOptions{} | |
| 99 | - | var rootSet, graceSet bool | |
| 100 | - | for index := 0; index < len(args); { | |
| 101 | - | switch args[index] { | |
| 102 | - | case "--offline-root": | |
| 103 | - | if rootSet { | |
| 104 | - | return command{}, usagef("`%s` received duplicate --offline-root", brokerServeCommand) | |
| 105 | - | } | |
| 106 | - | if index+1 >= len(args) || args[index+1] == "" { | |
| 107 | - | return command{}, usagef("`--offline-root` requires a path") | |
| 108 | - | } | |
| 109 | - | options.offlineRoot = args[index+1] | |
| 110 | - | rootSet = true | |
| 111 | - | index += 2 | |
| 112 | - | case "--idle-grace": | |
| 113 | - | if graceSet { | |
| 114 | - | return command{}, usagef("`%s` received duplicate --idle-grace", brokerServeCommand) | |
| 115 | - | } | |
| 116 | - | if index+1 >= len(args) { | |
| 117 | - | return command{}, usagef("`--idle-grace` requires a duration") | |
| 118 | - | } | |
| 119 | - | grace, err := time.ParseDuration(args[index+1]) | |
| 120 | - | if err != nil { | |
| 121 | - | return command{}, usagef("`--idle-grace` %q is not a duration", args[index+1]) | |
| 122 | - | } | |
| 123 | - | if grace <= 0 { | |
| 124 | - | return command{}, usagef("`--idle-grace` must be positive") | |
| 125 | - | } | |
| 126 | - | options.idleGrace = grace | |
| 127 | - | graceSet = true | |
| 128 | - | index += 2 | |
| 129 | - | default: | |
| 130 | - | if strings.HasPrefix(args[index], "-") { | |
| 131 | - | return command{}, usagef("unknown flag %q for `%s`", args[index], brokerServeCommand) | |
| 132 | - | } | |
| 133 | - | return command{}, usagef("`%s` does not accept positional arguments", brokerServeCommand) | |
| 134 | - | } | |
| 135 | - | } | |
| 136 | - | if !rootSet { | |
| 137 | - | return command{}, usagef("`%s` requires --offline-root", brokerServeCommand) | |
| 138 | - | } | |
| 139 | - | return command{kind: kindBrokerServe, brokerServe: options}, nil | |
| 81 | + | return command{kind: kindProductionBrokerServe, brokerServe: brokerServeOptions{}}, nil | |
| 140 | 82 | } | |
| 141 | 83 | ||
| 142 | 84 | // brokerServeDeps are the composition seams. Production supplies the real | |
| @@ -194,16 +136,12 @@ func defaultBrokerServeDeps() brokerServeDeps { | |||
| 194 | 136 | func runBrokerServeCommand(ctx context.Context, options brokerServeOptions) (retErr error) { | |
| 195 | 137 | ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) | |
| 196 | 138 | defer stop() | |
| 197 | - | var observerCloser io.Closer | |
| 198 | - | if options.production { | |
| 199 | - | observer, closer, err := newPerformanceTrace(clock.New()) | |
| 200 | - | if err != nil { | |
| 201 | - | return fmt.Errorf("vev: performance trace: %w", err) | |
| 202 | - | } | |
| 203 | - | observerCloser = closer | |
| 204 | - | if observer != nil { | |
| 205 | - | observer.ObserveRuntime(ports.NewRuntimeMark("broker", ports.RuntimeTransportDiagnostic, 0, true)) | |
| 206 | - | } | |
| 139 | + | observer, observerCloser, err := newPerformanceTrace(clock.New()) | |
| 140 | + | if err != nil { | |
| 141 | + | return fmt.Errorf("vev: performance trace: %w", err) | |
| 142 | + | } | |
| 143 | + | if observer != nil { | |
| 144 | + | observer.ObserveRuntime(ports.NewRuntimeMark("broker", ports.RuntimeTransportDiagnostic, 0, true)) | |
| 207 | 145 | } | |
| 208 | 146 | if observerCloser != nil { | |
| 209 | 147 | defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }() | |
| @@ -235,12 +173,6 @@ func newBrokerEpoch() (ports.BrokerEpoch, error) { | |||
| 235 | 173 | return epoch, nil | |
| 236 | 174 | } | |
| 237 | 175 | ||
| 238 | - | // offlineLayout resolves the hidden offline sandbox layout for one operator | |
| 239 | - | // root, refusing any root that overlaps the production runtime or state paths. | |
| 240 | - | func offlineLayout(offlineRoot string) (brokerconfig.Layout, error) { | |
| 241 | - | return brokerconfig.ResolveLayout(offlineRoot, []string{ipc.SocketDir(), platform.StateDir()}) | |
| 242 | - | } | |
| 243 | - | ||
| 244 | 176 | // effectiveIdleGrace resolves the sandbox idle grace from an explicit flag and | |
| 245 | 177 | // the provisioned configuration. The provisioned value is the single | |
| 246 | 178 | // deterministic source: a status probe cannot read a running broker's effective | |
| @@ -274,50 +206,32 @@ func effectiveIdleGrace(config *brokerconfig.Config, explicit time.Duration) (ti | |||
| 274 | 206 | func runBrokerServe(ctx context.Context, options brokerServeOptions, deps brokerServeDeps) (retErr error) { | |
| 275 | 207 | ctx, cancel := context.WithCancel(ctx) | |
| 276 | 208 | defer cancel() | |
| 277 | - | var layout brokerconfig.Layout | |
| 278 | - | var configPath string | |
| 279 | - | var err error | |
| 280 | - | if options.production { | |
| 281 | - | if err := ensureProductionBrokerConfig(); err != nil { | |
| 282 | - | return err | |
| 283 | - | } | |
| 284 | - | if err := safedir.EnsurePrivate(ipc.SocketDir()); err != nil { | |
| 285 | - | return fmt.Errorf("vev: secure broker runtime parent: %w", err) | |
| 286 | - | } | |
| 287 | - | layout = productionBrokerLayout() | |
| 288 | - | configPath = productionBrokerConfigPath() | |
| 289 | - | } else { | |
| 290 | - | layout, err = offlineLayout(options.offlineRoot) | |
| 291 | - | if err != nil { | |
| 292 | - | return err | |
| 293 | - | } | |
| 294 | - | configPath = filepath.Join(layout.Root, brokerconfig.ConfigFileName) | |
| 209 | + | if err := ensureProductionBrokerConfig(); err != nil { | |
| 210 | + | return err | |
| 295 | 211 | } | |
| 212 | + | if err := safedir.EnsurePrivate(ipc.SocketDir()); err != nil { | |
| 213 | + | return fmt.Errorf("vev: secure broker runtime parent: %w", err) | |
| 214 | + | } | |
| 215 | + | layout := productionBrokerLayout() | |
| 216 | + | configPath := productionBrokerConfigPath() | |
| 296 | 217 | for _, dir := range []string{layout.Root, layout.Runtime, layout.State, layout.Log} { | |
| 297 | 218 | if err := safedir.EnsurePrivate(dir); err != nil { | |
| 298 | 219 | return fmt.Errorf("vev: secure broker sandbox directory %s: %w", dir, err) | |
| 299 | 220 | } | |
| 300 | 221 | } | |
| 301 | - | // ResolveLayout validates the root textually and stops at the first | |
| 302 | - | // component that does not exist yet; now that EnsurePrivate has created the | |
| 303 | - | // sandbox paths, re-walk every component so a component swapped for a | |
| 304 | - | // symlink in that window fails closed instead of being followed. | |
| 222 | + | // Re-walk every component now that EnsurePrivate created them, so a | |
| 223 | + | // component swapped for a symlink in that window fails closed. | |
| 305 | 224 | if err := layout.VerifyCreated(); err != nil { | |
| 306 | - | return fmt.Errorf("vev: verify broker sandbox paths: %w", err) | |
| 225 | + | return fmt.Errorf("vev: verify broker paths: %w", err) | |
| 307 | 226 | } | |
| 308 | - | var config *brokerconfig.Config | |
| 309 | - | if options.production { | |
| 310 | - | // The broker is allowed to precede the daemon. Identity is therefore an | |
| 311 | - | // optional existing authority here; acquisition binds it after an | |
| 312 | - | // authenticated StartIfNeeded connection when the daemon is first born. | |
| 313 | - | identity, loadErr := daemonidentity.Load(platform.StateDir()) | |
| 314 | - | if loadErr != nil && !errors.Is(loadErr, os.ErrNotExist) { | |
| 315 | - | return fmt.Errorf("vev: load daemon authority: %w", loadErr) | |
| 316 | - | } | |
| 317 | - | config, err = brokerconfig.LoadProduction(layout, configPath, identity, localDaemonPolicy(), daemonmux.SocketPath(ipc.SocketDir())) | |
| 318 | - | } else { | |
| 319 | - | config, err = brokerconfig.LoadPath(layout, configPath) | |
| 227 | + | // The broker is allowed to precede the daemon. Identity is therefore an | |
| 228 | + | // optional existing authority here; acquisition binds it after an | |
| 229 | + | // authenticated StartIfNeeded connection when the daemon is first born. | |
| 230 | + | identity, err := daemonidentity.Load(platform.StateDir()) | |
| 231 | + | if err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 232 | + | return fmt.Errorf("vev: load daemon authority: %w", err) | |
| 320 | 233 | } | |
| 234 | + | config, err := brokerconfig.LoadProduction(layout, configPath, identity, localDaemonPolicy(), daemonmux.SocketPath(ipc.SocketDir())) | |
| 321 | 235 | if err != nil { | |
| 322 | 236 | return err | |
| 323 | 237 | } | |
| @@ -337,15 +251,13 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker | |||
| 337 | 251 | } | |
| 338 | 252 | defer func() { retErr = errors.Join(retErr, logCloser.Close()) }() | |
| 339 | 253 | ||
| 340 | - | if options.production { | |
| 341 | - | // The durable route schema intentionally delegates SSH trust to OpenSSH. | |
| 342 | - | // Refuse route overrides that OpenSSH configuration must represent instead of dropping | |
| 343 | - | // security inputs during production import. | |
| 344 | - | for _, endpoint := range config.Endpoints() { | |
| 345 | - | registration, _ := config.Registration(endpoint) | |
| 346 | - | if registration.Route.KnownHostsFile() != "" || registration.Route.ConnectTimeout() != 0 { | |
| 347 | - | return errors.New("vev: production broker routes require SSH trust and timeouts in OpenSSH configuration") | |
| 348 | - | } | |
| 254 | + | // The durable route schema delegates SSH trust to OpenSSH. Refuse route | |
| 255 | + | // overrides that OpenSSH configuration must represent instead of dropping | |
| 256 | + | // security inputs during import. | |
| 257 | + | for _, endpoint := range config.Endpoints() { | |
| 258 | + | registration, _ := config.Registration(endpoint) | |
| 259 | + | if registration.Route.KnownHostsFile() != "" || registration.Route.ConnectTimeout() != 0 { | |
| 260 | + | return errors.New("vev: broker routes require SSH trust and timeouts in OpenSSH configuration") | |
| 349 | 261 | } | |
| 350 | 262 | } | |
| 351 | 263 | initialHosts, err := configuredBrokerHosts(config) | |
| @@ -388,53 +300,32 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker | |||
| 388 | 300 | } | |
| 389 | 301 | defer startupLease.Release() | |
| 390 | 302 | ||
| 391 | - | // The sandbox retains its explicit route/trust fixture contract. Production | |
| 392 | - | // imports membership once, then uses only durable remote authority. | |
| 393 | - | dynamicRoutes := &brokerRoutes{local: config.Resolver()} | |
| 394 | - | var resolver ports.BrokerRouteAuthority = config.Resolver() | |
| 395 | - | connector, err := brokerMuxConnector(config, log) | |
| 396 | - | if options.production { | |
| 397 | - | resolver = dynamicRoutes | |
| 398 | - | connector, err = brokerDynamicMuxConnector(config, dynamicRoutes, log) | |
| 399 | - | } | |
| 303 | + | // Membership is imported once, then only durable remote authority is used. | |
| 304 | + | resolver := &brokerRoutes{local: config.Resolver()} | |
| 305 | + | connector, err := brokerDynamicMuxConnector(config, resolver, log) | |
| 400 | 306 | if err != nil { | |
| 401 | 307 | return err | |
| 402 | 308 | } | |
| 403 | - | var localIdentityLoaders []localIdentityLoader | |
| 404 | - | if options.production { | |
| 405 | - | localIdentityLoaders = append(localIdentityLoaders, func() (ports.BrokerDaemonIdentity, error) { | |
| 406 | - | identity, loadErr := daemonidentity.Load(platform.StateDir()) | |
| 407 | - | if errors.Is(loadErr, os.ErrNotExist) { | |
| 408 | - | return "", nil | |
| 409 | - | } | |
| 410 | - | return identity, loadErr | |
| 411 | - | }) | |
| 309 | + | loadLocalIdentity := func() (ports.BrokerDaemonIdentity, error) { | |
| 310 | + | identity, loadErr := daemonidentity.Load(platform.StateDir()) | |
| 311 | + | if errors.Is(loadErr, os.ErrNotExist) { | |
| 312 | + | return "", nil | |
| 313 | + | } | |
| 314 | + | return identity, loadErr | |
| 412 | 315 | } | |
| 413 | - | registryConfig, err := offlineRegistryConfig(config, epoch, connector, localIdentityLoaders...) | |
| 316 | + | registryConfig, err := offlineRegistryConfig(config, epoch, connector, loadLocalIdentity) | |
| 414 | 317 | if err != nil { | |
| 415 | 318 | return err | |
| 416 | 319 | } | |
| 417 | - | var remoteProbe ports.BrokerHostProbe | |
| 418 | - | var concreteRemoteProbe *brokerRemoteProbe | |
| 419 | - | if options.production || len(config.Endpoints()) != 0 { | |
| 420 | - | concreteRemoteProbe = &brokerRemoteProbe{ | |
| 421 | - | epoch: epoch, routes: resolver, connector: connector, | |
| 422 | - | } | |
| 423 | - | remoteProbe = concreteRemoteProbe | |
| 424 | - | } | |
| 320 | + | remoteProbe := &brokerRemoteProbe{epoch: epoch, routes: resolver, connector: connector} | |
| 425 | 321 | registry, err := broker.NewRegistryWithConfig(epoch, store, remoteProbe, clk, log, registryConfig) | |
| 426 | 322 | if err != nil { | |
| 427 | 323 | return err | |
| 428 | 324 | } | |
| 429 | - | dynamicRoutes.hosts = registry | |
| 430 | - | binder := ports.BrokerIdentityBinder(registry) | |
| 431 | - | if options.production { | |
| 432 | - | binder = productionIdentityBinder{delegate: registry, stateDir: platform.StateDir(), policy: localDaemonPolicy()} | |
| 433 | - | } | |
| 434 | - | if concreteRemoteProbe != nil { | |
| 435 | - | concreteRemoteProbe.binder = binder | |
| 436 | - | concreteRemoteProbe.hosts = registry | |
| 437 | - | } | |
| 325 | + | resolver.hosts = registry | |
| 326 | + | binder := productionIdentityBinder{delegate: registry, stateDir: platform.StateDir(), policy: localDaemonPolicy()} | |
| 327 | + | remoteProbe.binder = binder | |
| 328 | + | remoteProbe.hosts = registry | |
| 438 | 329 | // RegisterRunner starts immediately: finish the cyclic composition before | |
| 439 | 330 | // publishing it to any goroutine. Shutdown remains listener -> pool -> registry. | |
| 440 | 331 | if err := supervisor.RegisterRunner("registry", registry); err != nil { | |
| @@ -445,9 +336,7 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker | |||
| 445 | 336 | return err | |
| 446 | 337 | } | |
| 447 | 338 | // Observation borrows pooled transports rather than dialing its own. | |
| 448 | - | if concreteRemoteProbe != nil { | |
| 449 | - | concreteRemoteProbe.shared.share(pool) | |
| 450 | - | } | |
| 339 | + | remoteProbe.shared.share(pool) | |
| 451 | 340 | if registryConfig.Local != nil { | |
| 452 | 341 | if local, ok := registryConfig.Local.Probe.(*localRouteProbe); ok { | |
| 453 | 342 | local.shared.share(pool) | |
| @@ -476,7 +365,7 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker | |||
| 476 | 365 | go drainBrokerAccept(listener, log, started, drained, failed) | |
| 477 | 366 | <-started | |
| 478 | 367 | startupLease.Release() | |
| 479 | - | log.Info("broker_ready", "socket", socketPath, "endpoints", len(config.Endpoints()), "production", options.production) | |
| 368 | + | log.Info("broker_ready", "socket", socketPath, "endpoints", len(config.Endpoints())) | |
| 480 | 369 | if deps.onReady != nil { | |
| 481 | 370 | deps.onReady(socketPath) | |
| 482 | 371 | } | |
| @@ -495,7 +384,7 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker | |||
| 495 | 384 | closeErr := supervisor.Close() | |
| 496 | 385 | supervisorClosed = true | |
| 497 | 386 | <-drained | |
| 498 | - | log.Info("broker_stopped", "production", options.production) | |
| 387 | + | log.Info("broker_stopped") | |
| 499 | 388 | return closeErr | |
| 500 | 389 | } | |
| 501 | 390 | ||
| @@ -10,8 +10,6 @@ import ( | |||||
| 10 | "log/slog" | 10 | "log/slog" | ||
| 11 | "os" | 11 | "os" | ||
| 12 | "os/signal" | 12 | "os/signal" | ||
| 13 | - | "path/filepath" | |||
| 14 | - | "strings" | |||
| 15 | "syscall" | 13 | "syscall" | ||
| 16 | "time" | 14 | "time" | ||
| 17 | 15 | ||||
| @@ -46,10 +44,7 @@ import ( | |||||
| 46 | // until a signal or its parent context ends it, or until the broker idles out, | 44 | // until a signal or its parent context ends it, or until the broker idles out, | ||
| 47 | // and then shuts down in a fixed order. | 45 | // and then shuts down in a fixed order. | ||
| 48 | 46 | ||||
| 49 | - | const ( | 47 | + | const productionBrokerServeCommand = "_broker-production-serve" |
| 50 | - | brokerServeCommand = "_broker-serve" | |||
| 51 | - | productionBrokerServeCommand = "_broker-production-serve" | |||
| 52 | - | ) | |||
| 53 | 48 | ||||
| 54 | // brokerServePoolLimits bound the offline broker pool. They are explicit, not | 49 | // brokerServePoolLimits bound the offline broker pool. They are explicit, not | ||
| 55 | // derived from the environment, and small enough that a stalled peer is | 50 | // derived from the environment, and small enough that a stalled peer is | ||
| @@ -73,70 +68,17 @@ func brokerPoolLimits(config *brokerconfig.Config) broker.PoolLimits { | |||||
| 73 | 68 | ||||
| 74 | // brokerServeOptions is the parsed hidden invocation. | 69 | // brokerServeOptions is the parsed hidden invocation. | ||
| 75 | type brokerServeOptions struct { | 70 | type brokerServeOptions struct { | ||
| 76 | - | offlineRoot string | |||
| 77 | - | production bool | |||
| 78 | // idleGrace is zero when the operator did not override it; the supervisor | 71 | // idleGrace is zero when the operator did not override it; the supervisor | ||
| 79 | // then applies its own 5m default. | 72 | // then applies its own 5m default. | ||
| 80 | idleGrace time.Duration | 73 | idleGrace time.Duration | ||
| 81 | } | 74 | } | ||
| 82 | 75 | ||||
| 83 | - | // parseBrokerServeArgs strictly parses `_broker-serve --offline-root ABS | 76 | + | // parseProductionBrokerServeArgs parses the argument-free hidden serve entry. |
| 84 | - | // [--idle-grace DURATION]`. Unknown flags, duplicate flags, positionals, a | |||
| 85 | - | // missing value, and a non-positive or unparsable duration are refused; the | |||
| 86 | - | // path itself is validated later against the filesystem. | |||
| 87 | func parseProductionBrokerServeArgs(args []string) (command, error) { | 77 | func parseProductionBrokerServeArgs(args []string) (command, error) { | ||
| 88 | if len(args) != 0 { | 78 | if len(args) != 0 { | ||
| 89 | return command{}, usagef("`%s` does not accept arguments", productionBrokerServeCommand) | 79 | return command{}, usagef("`%s` does not accept arguments", productionBrokerServeCommand) | ||
| 90 | } | 80 | } | ||
| 91 | - | return command{kind: kindProductionBrokerServe, brokerServe: brokerServeOptions{production: true}}, nil | 81 | + | return command{kind: kindProductionBrokerServe, brokerServe: brokerServeOptions{}}, nil |
| 92 | - | } | |||
| 93 | - | ||||
| 94 | - | func parseBrokerServeArgs(args []string) (command, error) { | |||
| 95 | - | if len(args) == 0 { | |||
| 96 | - | return command{}, usagef("`%s` requires --offline-root", brokerServeCommand) | |||
| 97 | - | } | |||
| 98 | - | options := brokerServeOptions{} | |||
| 99 | - | var rootSet, graceSet bool | |||
| 100 | - | for index := 0; index < len(args); { | |||
| 101 | - | switch args[index] { | |||
| 102 | - | case "--offline-root": | |||
| 103 | - | if rootSet { | |||
| 104 | - | return command{}, usagef("`%s` received duplicate --offline-root", brokerServeCommand) | |||
| 105 | - | } | |||
| 106 | - | if index+1 >= len(args) || args[index+1] == "" { | |||
| 107 | - | return command{}, usagef("`--offline-root` requires a path") | |||
| 108 | - | } | |||
| 109 | - | options.offlineRoot = args[index+1] | |||
| 110 | - | rootSet = true | |||
| 111 | - | index += 2 | |||
| 112 | - | case "--idle-grace": | |||
| 113 | - | if graceSet { | |||
| 114 | - | return command{}, usagef("`%s` received duplicate --idle-grace", brokerServeCommand) | |||
| 115 | - | } | |||
| 116 | - | if index+1 >= len(args) { | |||
| 117 | - | return command{}, usagef("`--idle-grace` requires a duration") | |||
| 118 | - | } | |||
| 119 | - | grace, err := time.ParseDuration(args[index+1]) | |||
| 120 | - | if err != nil { | |||
| 121 | - | return command{}, usagef("`--idle-grace` %q is not a duration", args[index+1]) | |||
| 122 | - | } | |||
| 123 | - | if grace <= 0 { | |||
| 124 | - | return command{}, usagef("`--idle-grace` must be positive") | |||
| 125 | - | } | |||
| 126 | - | options.idleGrace = grace | |||
| 127 | - | graceSet = true | |||
| 128 | - | index += 2 | |||
| 129 | - | default: | |||
| 130 | - | if strings.HasPrefix(args[index], "-") { | |||
| 131 | - | return command{}, usagef("unknown flag %q for `%s`", args[index], brokerServeCommand) | |||
| 132 | - | } | |||
| 133 | - | return command{}, usagef("`%s` does not accept positional arguments", brokerServeCommand) | |||
| 134 | - | } | |||
| 135 | - | } | |||
| 136 | - | if !rootSet { | |||
| 137 | - | return command{}, usagef("`%s` requires --offline-root", brokerServeCommand) | |||
| 138 | - | } | |||
| 139 | - | return command{kind: kindBrokerServe, brokerServe: options}, nil | |||
| 140 | } | 82 | } | ||
| 141 | 83 | ||||
| 142 | // brokerServeDeps are the composition seams. Production supplies the real | 84 | // brokerServeDeps are the composition seams. Production supplies the real | ||
| @@ -194,16 +136,12 @@ func defaultBrokerServeDeps() brokerServeDeps { | |||||
| 194 | func runBrokerServeCommand(ctx context.Context, options brokerServeOptions) (retErr error) { | 136 | func runBrokerServeCommand(ctx context.Context, options brokerServeOptions) (retErr error) { | ||
| 195 | ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) | 137 | ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) | ||
| 196 | defer stop() | 138 | defer stop() | ||
| 197 | - | var observerCloser io.Closer | 139 | + | observer, observerCloser, err := newPerformanceTrace(clock.New()) |
| 198 | - | if options.production { | 140 | + | if err != nil { |
| 199 | - | observer, closer, err := newPerformanceTrace(clock.New()) | 141 | + | return fmt.Errorf("vev: performance trace: %w", err) |
| 200 | - | if err != nil { | 142 | + | } |
| 201 | - | return fmt.Errorf("vev: performance trace: %w", err) | 143 | + | if observer != nil { |
| 202 | - | } | 144 | + | observer.ObserveRuntime(ports.NewRuntimeMark("broker", ports.RuntimeTransportDiagnostic, 0, true)) |
| 203 | - | observerCloser = closer | |||
| 204 | - | if observer != nil { | |||
| 205 | - | observer.ObserveRuntime(ports.NewRuntimeMark("broker", ports.RuntimeTransportDiagnostic, 0, true)) | |||
| 206 | - | } | |||
| 207 | } | 145 | } | ||
| 208 | if observerCloser != nil { | 146 | if observerCloser != nil { | ||
| 209 | defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }() | 147 | defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }() | ||
| @@ -235,12 +173,6 @@ func newBrokerEpoch() (ports.BrokerEpoch, error) { | |||||
| 235 | return epoch, nil | 173 | return epoch, nil | ||
| 236 | } | 174 | } | ||
| 237 | 175 | ||||
| 238 | - | // offlineLayout resolves the hidden offline sandbox layout for one operator | |||
| 239 | - | // root, refusing any root that overlaps the production runtime or state paths. | |||
| 240 | - | func offlineLayout(offlineRoot string) (brokerconfig.Layout, error) { | |||
| 241 | - | return brokerconfig.ResolveLayout(offlineRoot, []string{ipc.SocketDir(), platform.StateDir()}) | |||
| 242 | - | } | |||
| 243 | - | ||||
| 244 | // effectiveIdleGrace resolves the sandbox idle grace from an explicit flag and | 176 | // effectiveIdleGrace resolves the sandbox idle grace from an explicit flag and | ||
| 245 | // the provisioned configuration. The provisioned value is the single | 177 | // the provisioned configuration. The provisioned value is the single | ||
| 246 | // deterministic source: a status probe cannot read a running broker's effective | 178 | // deterministic source: a status probe cannot read a running broker's effective | ||
| @@ -274,50 +206,32 @@ func effectiveIdleGrace(config *brokerconfig.Config, explicit time.Duration) (ti | |||||
| 274 | func runBrokerServe(ctx context.Context, options brokerServeOptions, deps brokerServeDeps) (retErr error) { | 206 | func runBrokerServe(ctx context.Context, options brokerServeOptions, deps brokerServeDeps) (retErr error) { | ||
| 275 | ctx, cancel := context.WithCancel(ctx) | 207 | ctx, cancel := context.WithCancel(ctx) | ||
| 276 | defer cancel() | 208 | defer cancel() | ||
| 277 | - | var layout brokerconfig.Layout | 209 | + | if err := ensureProductionBrokerConfig(); err != nil { |
| 278 | - | var configPath string | 210 | + | return err |
| 279 | - | var err error | |||
| 280 | - | if options.production { | |||
| 281 | - | if err := ensureProductionBrokerConfig(); err != nil { | |||
| 282 | - | return err | |||
| 283 | - | } | |||
| 284 | - | if err := safedir.EnsurePrivate(ipc.SocketDir()); err != nil { | |||
| 285 | - | return fmt.Errorf("vev: secure broker runtime parent: %w", err) | |||
| 286 | - | } | |||
| 287 | - | layout = productionBrokerLayout() | |||
| 288 | - | configPath = productionBrokerConfigPath() | |||
| 289 | - | } else { | |||
| 290 | - | layout, err = offlineLayout(options.offlineRoot) | |||
| 291 | - | if err != nil { | |||
| 292 | - | return err | |||
| 293 | - | } | |||
| 294 | - | configPath = filepath.Join(layout.Root, brokerconfig.ConfigFileName) | |||
| 295 | } | 211 | } | ||
| 212 | + | if err := safedir.EnsurePrivate(ipc.SocketDir()); err != nil { | |||
| 213 | + | return fmt.Errorf("vev: secure broker runtime parent: %w", err) | |||
| 214 | + | } | |||
| 215 | + | layout := productionBrokerLayout() | |||
| 216 | + | configPath := productionBrokerConfigPath() | |||
| 296 | for _, dir := range []string{layout.Root, layout.Runtime, layout.State, layout.Log} { | 217 | for _, dir := range []string{layout.Root, layout.Runtime, layout.State, layout.Log} { | ||
| 297 | if err := safedir.EnsurePrivate(dir); err != nil { | 218 | if err := safedir.EnsurePrivate(dir); err != nil { | ||
| 298 | return fmt.Errorf("vev: secure broker sandbox directory %s: %w", dir, err) | 219 | return fmt.Errorf("vev: secure broker sandbox directory %s: %w", dir, err) | ||
| 299 | } | 220 | } | ||
| 300 | } | 221 | } | ||
| 301 | - | // ResolveLayout validates the root textually and stops at the first | 222 | + | // Re-walk every component now that EnsurePrivate created them, so a |
| 302 | - | // component that does not exist yet; now that EnsurePrivate has created the | 223 | + | // component swapped for a symlink in that window fails closed. |
| 303 | - | // sandbox paths, re-walk every component so a component swapped for a | |||
| 304 | - | // symlink in that window fails closed instead of being followed. | |||
| 305 | if err := layout.VerifyCreated(); err != nil { | 224 | if err := layout.VerifyCreated(); err != nil { | ||
| 306 | - | return fmt.Errorf("vev: verify broker sandbox paths: %w", err) | 225 | + | return fmt.Errorf("vev: verify broker paths: %w", err) |
| 307 | } | 226 | } | ||
| 308 | - | var config *brokerconfig.Config | 227 | + | // The broker is allowed to precede the daemon. Identity is therefore an |
| 309 | - | if options.production { | 228 | + | // optional existing authority here; acquisition binds it after an |
| 310 | - | // The broker is allowed to precede the daemon. Identity is therefore an | 229 | + | // authenticated StartIfNeeded connection when the daemon is first born. |
| 311 | - | // optional existing authority here; acquisition binds it after an | 230 | + | identity, err := daemonidentity.Load(platform.StateDir()) |
| 312 | - | // authenticated StartIfNeeded connection when the daemon is first born. | 231 | + | if err != nil && !errors.Is(err, os.ErrNotExist) { |
| 313 | - | identity, loadErr := daemonidentity.Load(platform.StateDir()) | 232 | + | return fmt.Errorf("vev: load daemon authority: %w", err) |
| 314 | - | if loadErr != nil && !errors.Is(loadErr, os.ErrNotExist) { | |||
| 315 | - | return fmt.Errorf("vev: load daemon authority: %w", loadErr) | |||
| 316 | - | } | |||
| 317 | - | config, err = brokerconfig.LoadProduction(layout, configPath, identity, localDaemonPolicy(), daemonmux.SocketPath(ipc.SocketDir())) | |||
| 318 | - | } else { | |||
| 319 | - | config, err = brokerconfig.LoadPath(layout, configPath) | |||
| 320 | } | 233 | } | ||
| 234 | + | config, err := brokerconfig.LoadProduction(layout, configPath, identity, localDaemonPolicy(), daemonmux.SocketPath(ipc.SocketDir())) | |||
| 321 | if err != nil { | 235 | if err != nil { | ||
| 322 | return err | 236 | return err | ||
| 323 | } | 237 | } | ||
| @@ -337,15 +251,13 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker | |||||
| 337 | } | 251 | } | ||
| 338 | defer func() { retErr = errors.Join(retErr, logCloser.Close()) }() | 252 | defer func() { retErr = errors.Join(retErr, logCloser.Close()) }() | ||
| 339 | 253 | ||||
| 340 | - | if options.production { | 254 | + | // The durable route schema delegates SSH trust to OpenSSH. Refuse route |
| 341 | - | // The durable route schema intentionally delegates SSH trust to OpenSSH. | 255 | + | // overrides that OpenSSH configuration must represent instead of dropping |
| 342 | - | // Refuse route overrides that OpenSSH configuration must represent instead of dropping | 256 | + | // security inputs during import. |
| 343 | - | // security inputs during production import. | 257 | + | for _, endpoint := range config.Endpoints() { |
| 344 | - | for _, endpoint := range config.Endpoints() { | 258 | + | registration, _ := config.Registration(endpoint) |
| 345 | - | registration, _ := config.Registration(endpoint) | 259 | + | if registration.Route.KnownHostsFile() != "" || registration.Route.ConnectTimeout() != 0 { |
| 346 | - | if registration.Route.KnownHostsFile() != "" || registration.Route.ConnectTimeout() != 0 { | 260 | + | return errors.New("vev: broker routes require SSH trust and timeouts in OpenSSH configuration") |
| 347 | - | return errors.New("vev: production broker routes require SSH trust and timeouts in OpenSSH configuration") | |||
| 348 | - | } | |||
| 349 | } | 261 | } | ||
| 350 | } | 262 | } | ||
| 351 | initialHosts, err := configuredBrokerHosts(config) | 263 | initialHosts, err := configuredBrokerHosts(config) | ||
| @@ -388,53 +300,32 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker | |||||
| 388 | } | 300 | } | ||
| 389 | defer startupLease.Release() | 301 | defer startupLease.Release() | ||
| 390 | 302 | ||||
| 391 | - | // The sandbox retains its explicit route/trust fixture contract. Production | 303 | + | // Membership is imported once, then only durable remote authority is used. |
| 392 | - | // imports membership once, then uses only durable remote authority. | 304 | + | resolver := &brokerRoutes{local: config.Resolver()} |
| 393 | - | dynamicRoutes := &brokerRoutes{local: config.Resolver()} | 305 | + | connector, err := brokerDynamicMuxConnector(config, resolver, log) |
| 394 | - | var resolver ports.BrokerRouteAuthority = config.Resolver() | |||
| 395 | - | connector, err := brokerMuxConnector(config, log) | |||
| 396 | - | if options.production { | |||
| 397 | - | resolver = dynamicRoutes | |||
| 398 | - | connector, err = brokerDynamicMuxConnector(config, dynamicRoutes, log) | |||
| 399 | - | } | |||
| 400 | if err != nil { | 306 | if err != nil { | ||
| 401 | return err | 307 | return err | ||
| 402 | } | 308 | } | ||
| 403 | - | var localIdentityLoaders []localIdentityLoader | 309 | + | loadLocalIdentity := func() (ports.BrokerDaemonIdentity, error) { |
| 404 | - | if options.production { | 310 | + | identity, loadErr := daemonidentity.Load(platform.StateDir()) |
| 405 | - | localIdentityLoaders = append(localIdentityLoaders, func() (ports.BrokerDaemonIdentity, error) { | 311 | + | if errors.Is(loadErr, os.ErrNotExist) { |
| 406 | - | identity, loadErr := daemonidentity.Load(platform.StateDir()) | 312 | + | return "", nil |
| 407 | - | if errors.Is(loadErr, os.ErrNotExist) { | 313 | + | } |
| 408 | - | return "", nil | 314 | + | return identity, loadErr |
| 409 | - | } | |||
| 410 | - | return identity, loadErr | |||
| 411 | - | }) | |||
| 412 | } | 315 | } | ||
| 413 | - | registryConfig, err := offlineRegistryConfig(config, epoch, connector, localIdentityLoaders...) | 316 | + | registryConfig, err := offlineRegistryConfig(config, epoch, connector, loadLocalIdentity) |
| 414 | if err != nil { | 317 | if err != nil { | ||
| 415 | return err | 318 | return err | ||
| 416 | } | 319 | } | ||
| 417 | - | var remoteProbe ports.BrokerHostProbe | 320 | + | remoteProbe := &brokerRemoteProbe{epoch: epoch, routes: resolver, connector: connector} |
| 418 | - | var concreteRemoteProbe *brokerRemoteProbe | |||
| 419 | - | if options.production || len(config.Endpoints()) != 0 { | |||
| 420 | - | concreteRemoteProbe = &brokerRemoteProbe{ | |||
| 421 | - | epoch: epoch, routes: resolver, connector: connector, | |||
| 422 | - | } | |||
| 423 | - | remoteProbe = concreteRemoteProbe | |||
| 424 | - | } | |||
| 425 | registry, err := broker.NewRegistryWithConfig(epoch, store, remoteProbe, clk, log, registryConfig) | 321 | registry, err := broker.NewRegistryWithConfig(epoch, store, remoteProbe, clk, log, registryConfig) | ||
| 426 | if err != nil { | 322 | if err != nil { | ||
| 427 | return err | 323 | return err | ||
| 428 | } | 324 | } | ||
| 429 | - | dynamicRoutes.hosts = registry | 325 | + | resolver.hosts = registry |
| 430 | - | binder := ports.BrokerIdentityBinder(registry) | 326 | + | binder := productionIdentityBinder{delegate: registry, stateDir: platform.StateDir(), policy: localDaemonPolicy()} |
| 431 | - | if options.production { | 327 | + | remoteProbe.binder = binder |
| 432 | - | binder = productionIdentityBinder{delegate: registry, stateDir: platform.StateDir(), policy: localDaemonPolicy()} | 328 | + | remoteProbe.hosts = registry |
| 433 | - | } | |||
| 434 | - | if concreteRemoteProbe != nil { | |||
| 435 | - | concreteRemoteProbe.binder = binder | |||
| 436 | - | concreteRemoteProbe.hosts = registry | |||
| 437 | - | } | |||
| 438 | // RegisterRunner starts immediately: finish the cyclic composition before | 329 | // RegisterRunner starts immediately: finish the cyclic composition before | ||
| 439 | // publishing it to any goroutine. Shutdown remains listener -> pool -> registry. | 330 | // publishing it to any goroutine. Shutdown remains listener -> pool -> registry. | ||
| 440 | if err := supervisor.RegisterRunner("registry", registry); err != nil { | 331 | if err := supervisor.RegisterRunner("registry", registry); err != nil { | ||
| @@ -445,9 +336,7 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker | |||||
| 445 | return err | 336 | return err | ||
| 446 | } | 337 | } | ||
| 447 | // Observation borrows pooled transports rather than dialing its own. | 338 | // Observation borrows pooled transports rather than dialing its own. | ||
| 448 | - | if concreteRemoteProbe != nil { | 339 | + | remoteProbe.shared.share(pool) |
| 449 | - | concreteRemoteProbe.shared.share(pool) | |||
| 450 | - | } | |||
| 451 | if registryConfig.Local != nil { | 340 | if registryConfig.Local != nil { | ||
| 452 | if local, ok := registryConfig.Local.Probe.(*localRouteProbe); ok { | 341 | if local, ok := registryConfig.Local.Probe.(*localRouteProbe); ok { | ||
| 453 | local.shared.share(pool) | 342 | local.shared.share(pool) | ||
| @@ -476,7 +365,7 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker | |||||
| 476 | go drainBrokerAccept(listener, log, started, drained, failed) | 365 | go drainBrokerAccept(listener, log, started, drained, failed) | ||
| 477 | <-started | 366 | <-started | ||
| 478 | startupLease.Release() | 367 | startupLease.Release() | ||
| 479 | - | log.Info("broker_ready", "socket", socketPath, "endpoints", len(config.Endpoints()), "production", options.production) | 368 | + | log.Info("broker_ready", "socket", socketPath, "endpoints", len(config.Endpoints())) |
| 480 | if deps.onReady != nil { | 369 | if deps.onReady != nil { | ||
| 481 | deps.onReady(socketPath) | 370 | deps.onReady(socketPath) | ||
| 482 | } | 371 | } | ||
| @@ -495,7 +384,7 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker | |||||
| 495 | closeErr := supervisor.Close() | 384 | closeErr := supervisor.Close() | ||
| 496 | supervisorClosed = true | 385 | supervisorClosed = true | ||
| 497 | <-drained | 386 | <-drained | ||
| 498 | - | log.Info("broker_stopped", "production", options.production) | 387 | + | log.Info("broker_stopped") |
| 499 | return closeErr | 388 | return closeErr | ||
| 500 | } | 389 | } | ||
| 501 | 390 | ||||
D internal/app/broker_offline_client.go +0 -181
| @@ -1,181 +0,0 @@ | |||
| 1 | - | package app | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "errors" | |
| 6 | - | "io" | |
| 7 | - | "log/slog" | |
| 8 | - | "os" | |
| 9 | - | "strings" | |
| 10 | - | ||
| 11 | - | "github.com/bnema/vev/internal/adapters/brokeripc" | |
| 12 | - | "github.com/bnema/vev/internal/adapters/clock" | |
| 13 | - | "github.com/bnema/vev/internal/adapters/term" | |
| 14 | - | "github.com/bnema/vev/internal/adapters/uiterm" | |
| 15 | - | "github.com/bnema/vev/internal/domain" | |
| 16 | - | "github.com/bnema/vev/internal/ports" | |
| 17 | - | "github.com/bnema/vev/internal/usecase/client" | |
| 18 | - | ) | |
| 19 | - | ||
| 20 | - | // brokerClientCommand is the hidden client entry point. It is deliberately | |
| 21 | - | // separate from every ordinary attach, UI-driver, and web command: only an | |
| 22 | - | // explicitly supplied offline root can select the broker sandbox. | |
| 23 | - | const brokerClientCommand = "_broker-client" | |
| 24 | - | ||
| 25 | - | const ( | |
| 26 | - | offlineClientTerminal = "terminal" | |
| 27 | - | offlineClientUIDriver = "ui-driver" | |
| 28 | - | ) | |
| 29 | - | ||
| 30 | - | type brokerClientOptions struct { | |
| 31 | - | offlineRoot string | |
| 32 | - | harness string | |
| 33 | - | // picker starts the harness with no initial navigation at all, exactly like | |
| 34 | - | // `--ui-driver --picker`: the client presents the picker and opens nothing. | |
| 35 | - | picker bool | |
| 36 | - | } | |
| 37 | - | ||
| 38 | - | func parseBrokerClientArgs(args []string) (command, error) { | |
| 39 | - | options := brokerClientOptions{harness: offlineClientTerminal} | |
| 40 | - | var rootSet, harnessSet, pickerSet bool | |
| 41 | - | for index := 0; index < len(args); { | |
| 42 | - | switch args[index] { | |
| 43 | - | case "--offline-root": | |
| 44 | - | if rootSet { | |
| 45 | - | return command{}, usagef("`%s` received duplicate --offline-root", brokerClientCommand) | |
| 46 | - | } | |
| 47 | - | if index+1 >= len(args) || args[index+1] == "" { | |
| 48 | - | return command{}, usagef("`--offline-root` requires a path") | |
| 49 | - | } | |
| 50 | - | options.offlineRoot, rootSet = args[index+1], true | |
| 51 | - | index += 2 | |
| 52 | - | case "--harness": | |
| 53 | - | if harnessSet { | |
| 54 | - | return command{}, usagef("`%s` received duplicate --harness", brokerClientCommand) | |
| 55 | - | } | |
| 56 | - | if index+1 >= len(args) { | |
| 57 | - | return command{}, usagef("`--harness` requires terminal or ui-driver") | |
| 58 | - | } | |
| 59 | - | options.harness, harnessSet = args[index+1], true | |
| 60 | - | if options.harness != offlineClientTerminal && options.harness != offlineClientUIDriver { | |
| 61 | - | return command{}, usagef("`--harness` requires terminal or ui-driver") | |
| 62 | - | } | |
| 63 | - | index += 2 | |
| 64 | - | case "--picker": | |
| 65 | - | if pickerSet { | |
| 66 | - | return command{}, usagef("`%s` received duplicate --picker", brokerClientCommand) | |
| 67 | - | } | |
| 68 | - | options.picker, pickerSet = true, true | |
| 69 | - | index++ | |
| 70 | - | default: | |
| 71 | - | if strings.HasPrefix(args[index], "-") { | |
| 72 | - | return command{}, usagef("unknown flag %q for `%s`", args[index], brokerClientCommand) | |
| 73 | - | } | |
| 74 | - | return command{}, usagef("`%s` does not accept positional arguments", brokerClientCommand) | |
| 75 | - | } | |
| 76 | - | } | |
| 77 | - | if !rootSet { | |
| 78 | - | return command{}, usagef("`%s` requires --offline-root", brokerClientCommand) | |
| 79 | - | } | |
| 80 | - | if pickerSet && options.harness != offlineClientUIDriver { | |
| 81 | - | return command{}, usagef("`%s` --picker requires --harness ui-driver", brokerClientCommand) | |
| 82 | - | } | |
| 83 | - | return command{kind: kindBrokerClient, brokerClient: options}, nil | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | func runBrokerClientCommand(ctx context.Context, options brokerClientOptions) error { | |
| 87 | - | layout, err := offlineLayout(options.offlineRoot) | |
| 88 | - | if err != nil { | |
| 89 | - | return err | |
| 90 | - | } | |
| 91 | - | log := slog.New(slog.NewTextHandler(io.Discard, nil)) | |
| 92 | - | switch options.harness { | |
| 93 | - | case offlineClientTerminal: | |
| 94 | - | return runOfflineClient(ctx, brokeripc.SocketPath(layout.Runtime), term.New(), nil, log, nil, nil) | |
| 95 | - | case offlineClientUIDriver: | |
| 96 | - | terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: uiDriverDefaultColumns, Rows: uiDriverDefaultRows}}, "") | |
| 97 | - | if err != nil { | |
| 98 | - | return err | |
| 99 | - | } | |
| 100 | - | defer terminal.Close() | |
| 101 | - | ui := client.NewUI(terminal, clock.New()) | |
| 102 | - | navigation := localEphemeralNavigation() | |
| 103 | - | if options.picker { | |
| 104 | - | navigation = client.InitialNavigation{} | |
| 105 | - | } | |
| 106 | - | return runOfflineUIDriverClient(ctx, offlineUIDriverHarness{ | |
| 107 | - | socket: brokeripc.SocketPath(layout.Runtime), | |
| 108 | - | terminal: terminal, | |
| 109 | - | ui: ui, | |
| 110 | - | log: log, | |
| 111 | - | navigation: navigation, | |
| 112 | - | stream: &stdioStream{reader: os.Stdin, writer: os.Stdout}, | |
| 113 | - | }) | |
| 114 | - | default: | |
| 115 | - | return usagef("unknown offline client harness %q", options.harness) | |
| 116 | - | } | |
| 117 | - | } | |
| 118 | - | ||
| 119 | - | // runOfflineClient is the sandbox terminal harness over the shared broker | |
| 120 | - | // client composition. It supplies the real broker IPC connector for one | |
| 121 | - | // offline root and the no-argument ephemeral local creation, then delegates the | |
| 122 | - | // whole run to runBrokerClient: the sandbox adds no picker, supervisor, dialer, | |
| 123 | - | // or stream of its own. | |
| 124 | - | func runOfflineClient(ctx context.Context, socket string, terminal ports.Terminal, ui *client.UI, log *slog.Logger, onState func(client.State), onFailure func(error)) error { | |
| 125 | - | if terminal == nil { | |
| 126 | - | return errors.New("vev: offline client requires a terminal") | |
| 127 | - | } | |
| 128 | - | if log != nil { | |
| 129 | - | log.Debug("broker_offline_client", "socket", socket) | |
| 130 | - | } | |
| 131 | - | return runBrokerClient(ctx, brokerClientConfig{ | |
| 132 | - | Connector: brokeripc.NewConnector(socket, brokeripc.Config{}), | |
| 133 | - | Terminal: terminal, | |
| 134 | - | UI: ui, | |
| 135 | - | // No-argument composition creates one ephemeral local session through | |
| 136 | - | // the closed initial-navigation union; there is no special attach path. | |
| 137 | - | InitialNavigation: localEphemeralNavigation(), | |
| 138 | - | AttachmentEnvironment: terminalAttachmentEnvironment(), | |
| 139 | - | SessionEnvironment: client.SessionEnvironment{Provenance: client.SessionEnvironmentLocalCLI}, | |
| 140 | - | OnState: onState, | |
| 141 | - | OnFailure: onFailure, | |
| 142 | - | }) | |
| 143 | - | } | |
| 144 | - | ||
| 145 | - | // offlineUIDriverHarness is the sandbox harness input: one explicitly supplied | |
| 146 | - | // offline broker socket, the virtual terminal and UI service built over it, the | |
| 147 | - | // one-shot navigation the harness was asked to perform, and the stream the JSONL | |
| 148 | - | // protocol is served on. The CLI's `--picker` selects the zero picker intent; the | |
| 149 | - | // default is the same no-argument local ephemeral creation every frontend uses. | |
| 150 | - | type offlineUIDriverHarness struct { | |
| 151 | - | socket string | |
| 152 | - | terminal *uiterm.Terminal | |
| 153 | - | ui *client.UI | |
| 154 | - | log *slog.Logger | |
| 155 | - | navigation client.InitialNavigation | |
| 156 | - | resolver client.InitialNavigationResolver | |
| 157 | - | stream io.ReadWriteCloser | |
| 158 | - | } | |
| 159 | - | ||
| 160 | - | // runOfflineUIDriverClient composes the sandbox UI-driver harness over the same | |
| 161 | - | // runUIDriverClient path production uses. Its connector is the real broker IPC | |
| 162 | - | // connector of one explicitly supplied offline root: the sandbox adds no | |
| 163 | - | // production fallback, starts no broker of its own, and never reaches the | |
| 164 | - | // production XDG runtime or state. No session, endpoint, or process is | |
| 165 | - | // provisioned here: the harness reaches sessions only through the broker of its | |
| 166 | - | // own root, exactly like the production driver. | |
| 167 | - | func runOfflineUIDriverClient(ctx context.Context, harness offlineUIDriverHarness) error { | |
| 168 | - | if harness.log != nil { | |
| 169 | - | harness.log.Debug("broker_offline_ui_driver", "socket", harness.socket) | |
| 170 | - | } | |
| 171 | - | return runUIDriverClient(ctx, brokerClientConfig{ | |
| 172 | - | Connector: brokeripc.NewConnector(harness.socket, brokeripc.Config{}), | |
| 173 | - | Terminal: harness.terminal, | |
| 174 | - | Clock: clock.New(), | |
| 175 | - | UI: harness.ui, | |
| 176 | - | InitialNavigation: harness.navigation, | |
| 177 | - | ResolveInitialNavigation: harness.resolver, | |
| 178 | - | AttachmentEnvironment: terminalAttachmentEnvironment(), | |
| 179 | - | SessionEnvironment: client.SessionEnvironment{Provenance: client.SessionEnvironmentLocalCLI}, | |
| 180 | - | }, harness.stream) | |
| 181 | - | } | |
| @@ -1,181 +0,0 @@ | |||||
| 1 | - | package app | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "context" | |||
| 5 | - | "errors" | |||
| 6 | - | "io" | |||
| 7 | - | "log/slog" | |||
| 8 | - | "os" | |||
| 9 | - | "strings" | |||
| 10 | - | ||||
| 11 | - | "github.com/bnema/vev/internal/adapters/brokeripc" | |||
| 12 | - | "github.com/bnema/vev/internal/adapters/clock" | |||
| 13 | - | "github.com/bnema/vev/internal/adapters/term" | |||
| 14 | - | "github.com/bnema/vev/internal/adapters/uiterm" | |||
| 15 | - | "github.com/bnema/vev/internal/domain" | |||
| 16 | - | "github.com/bnema/vev/internal/ports" | |||
| 17 | - | "github.com/bnema/vev/internal/usecase/client" | |||
| 18 | - | ) | |||
| 19 | - | ||||
| 20 | - | // brokerClientCommand is the hidden client entry point. It is deliberately | |||
| 21 | - | // separate from every ordinary attach, UI-driver, and web command: only an | |||
| 22 | - | // explicitly supplied offline root can select the broker sandbox. | |||
| 23 | - | const brokerClientCommand = "_broker-client" | |||
| 24 | - | ||||
| 25 | - | const ( | |||
| 26 | - | offlineClientTerminal = "terminal" | |||
| 27 | - | offlineClientUIDriver = "ui-driver" | |||
| 28 | - | ) | |||
| 29 | - | ||||
| 30 | - | type brokerClientOptions struct { | |||
| 31 | - | offlineRoot string | |||
| 32 | - | harness string | |||
| 33 | - | // picker starts the harness with no initial navigation at all, exactly like | |||
| 34 | - | // `--ui-driver --picker`: the client presents the picker and opens nothing. | |||
| 35 | - | picker bool | |||
| 36 | - | } | |||
| 37 | - | ||||
| 38 | - | func parseBrokerClientArgs(args []string) (command, error) { | |||
| 39 | - | options := brokerClientOptions{harness: offlineClientTerminal} | |||
| 40 | - | var rootSet, harnessSet, pickerSet bool | |||
| 41 | - | for index := 0; index < len(args); { | |||
| 42 | - | switch args[index] { | |||
| 43 | - | case "--offline-root": | |||
| 44 | - | if rootSet { | |||
| 45 | - | return command{}, usagef("`%s` received duplicate --offline-root", brokerClientCommand) | |||
| 46 | - | } | |||
| 47 | - | if index+1 >= len(args) || args[index+1] == "" { | |||
| 48 | - | return command{}, usagef("`--offline-root` requires a path") | |||
| 49 | - | } | |||
| 50 | - | options.offlineRoot, rootSet = args[index+1], true | |||
| 51 | - | index += 2 | |||
| 52 | - | case "--harness": | |||
| 53 | - | if harnessSet { | |||
| 54 | - | return command{}, usagef("`%s` received duplicate --harness", brokerClientCommand) | |||
| 55 | - | } | |||
| 56 | - | if index+1 >= len(args) { | |||
| 57 | - | return command{}, usagef("`--harness` requires terminal or ui-driver") | |||
| 58 | - | } | |||
| 59 | - | options.harness, harnessSet = args[index+1], true | |||
| 60 | - | if options.harness != offlineClientTerminal && options.harness != offlineClientUIDriver { | |||
| 61 | - | return command{}, usagef("`--harness` requires terminal or ui-driver") | |||
| 62 | - | } | |||
| 63 | - | index += 2 | |||
| 64 | - | case "--picker": | |||
| 65 | - | if pickerSet { | |||
| 66 | - | return command{}, usagef("`%s` received duplicate --picker", brokerClientCommand) | |||
| 67 | - | } | |||
| 68 | - | options.picker, pickerSet = true, true | |||
| 69 | - | index++ | |||
| 70 | - | default: | |||
| 71 | - | if strings.HasPrefix(args[index], "-") { | |||
| 72 | - | return command{}, usagef("unknown flag %q for `%s`", args[index], brokerClientCommand) | |||
| 73 | - | } | |||
| 74 | - | return command{}, usagef("`%s` does not accept positional arguments", brokerClientCommand) | |||
| 75 | - | } | |||
| 76 | - | } | |||
| 77 | - | if !rootSet { | |||
| 78 | - | return command{}, usagef("`%s` requires --offline-root", brokerClientCommand) | |||
| 79 | - | } | |||
| 80 | - | if pickerSet && options.harness != offlineClientUIDriver { | |||
| 81 | - | return command{}, usagef("`%s` --picker requires --harness ui-driver", brokerClientCommand) | |||
| 82 | - | } | |||
| 83 | - | return command{kind: kindBrokerClient, brokerClient: options}, nil | |||
| 84 | - | } | |||
| 85 | - | ||||
| 86 | - | func runBrokerClientCommand(ctx context.Context, options brokerClientOptions) error { | |||
| 87 | - | layout, err := offlineLayout(options.offlineRoot) | |||
| 88 | - | if err != nil { | |||
| 89 | - | return err | |||
| 90 | - | } | |||
| 91 | - | log := slog.New(slog.NewTextHandler(io.Discard, nil)) | |||
| 92 | - | switch options.harness { | |||
| 93 | - | case offlineClientTerminal: | |||
| 94 | - | return runOfflineClient(ctx, brokeripc.SocketPath(layout.Runtime), term.New(), nil, log, nil, nil) | |||
| 95 | - | case offlineClientUIDriver: | |||
| 96 | - | terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: uiDriverDefaultColumns, Rows: uiDriverDefaultRows}}, "") | |||
| 97 | - | if err != nil { | |||
| 98 | - | return err | |||
| 99 | - | } | |||
| 100 | - | defer terminal.Close() | |||
| 101 | - | ui := client.NewUI(terminal, clock.New()) | |||
| 102 | - | navigation := localEphemeralNavigation() | |||
| 103 | - | if options.picker { | |||
| 104 | - | navigation = client.InitialNavigation{} | |||
| 105 | - | } | |||
| 106 | - | return runOfflineUIDriverClient(ctx, offlineUIDriverHarness{ | |||
| 107 | - | socket: brokeripc.SocketPath(layout.Runtime), | |||
| 108 | - | terminal: terminal, | |||
| 109 | - | ui: ui, | |||
| 110 | - | log: log, | |||
| 111 | - | navigation: navigation, | |||
| 112 | - | stream: &stdioStream{reader: os.Stdin, writer: os.Stdout}, | |||
| 113 | - | }) | |||
| 114 | - | default: | |||
| 115 | - | return usagef("unknown offline client harness %q", options.harness) | |||
| 116 | - | } | |||
| 117 | - | } | |||
| 118 | - | ||||
| 119 | - | // runOfflineClient is the sandbox terminal harness over the shared broker | |||
| 120 | - | // client composition. It supplies the real broker IPC connector for one | |||
| 121 | - | // offline root and the no-argument ephemeral local creation, then delegates the | |||
| 122 | - | // whole run to runBrokerClient: the sandbox adds no picker, supervisor, dialer, | |||
| 123 | - | // or stream of its own. | |||
| 124 | - | func runOfflineClient(ctx context.Context, socket string, terminal ports.Terminal, ui *client.UI, log *slog.Logger, onState func(client.State), onFailure func(error)) error { | |||
| 125 | - | if terminal == nil { | |||
| 126 | - | return errors.New("vev: offline client requires a terminal") | |||
| 127 | - | } | |||
| 128 | - | if log != nil { | |||
| 129 | - | log.Debug("broker_offline_client", "socket", socket) | |||
| 130 | - | } | |||
| 131 | - | return runBrokerClient(ctx, brokerClientConfig{ | |||
| 132 | - | Connector: brokeripc.NewConnector(socket, brokeripc.Config{}), | |||
| 133 | - | Terminal: terminal, | |||
| 134 | - | UI: ui, | |||
| 135 | - | // No-argument composition creates one ephemeral local session through | |||
| 136 | - | // the closed initial-navigation union; there is no special attach path. | |||
| 137 | - | InitialNavigation: localEphemeralNavigation(), | |||
| 138 | - | AttachmentEnvironment: terminalAttachmentEnvironment(), | |||
| 139 | - | SessionEnvironment: client.SessionEnvironment{Provenance: client.SessionEnvironmentLocalCLI}, | |||
| 140 | - | OnState: onState, | |||
| 141 | - | OnFailure: onFailure, | |||
| 142 | - | }) | |||
| 143 | - | } | |||
| 144 | - | ||||
| 145 | - | // offlineUIDriverHarness is the sandbox harness input: one explicitly supplied | |||
| 146 | - | // offline broker socket, the virtual terminal and UI service built over it, the | |||
| 147 | - | // one-shot navigation the harness was asked to perform, and the stream the JSONL | |||
| 148 | - | // protocol is served on. The CLI's `--picker` selects the zero picker intent; the | |||
| 149 | - | // default is the same no-argument local ephemeral creation every frontend uses. | |||
| 150 | - | type offlineUIDriverHarness struct { | |||
| 151 | - | socket string | |||
| 152 | - | terminal *uiterm.Terminal | |||
| 153 | - | ui *client.UI | |||
| 154 | - | log *slog.Logger | |||
| 155 | - | navigation client.InitialNavigation | |||
| 156 | - | resolver client.InitialNavigationResolver | |||
| 157 | - | stream io.ReadWriteCloser | |||
| 158 | - | } | |||
| 159 | - | ||||
| 160 | - | // runOfflineUIDriverClient composes the sandbox UI-driver harness over the same | |||
| 161 | - | // runUIDriverClient path production uses. Its connector is the real broker IPC | |||
| 162 | - | // connector of one explicitly supplied offline root: the sandbox adds no | |||
| 163 | - | // production fallback, starts no broker of its own, and never reaches the | |||
| 164 | - | // production XDG runtime or state. No session, endpoint, or process is | |||
| 165 | - | // provisioned here: the harness reaches sessions only through the broker of its | |||
| 166 | - | // own root, exactly like the production driver. | |||
| 167 | - | func runOfflineUIDriverClient(ctx context.Context, harness offlineUIDriverHarness) error { | |||
| 168 | - | if harness.log != nil { | |||
| 169 | - | harness.log.Debug("broker_offline_ui_driver", "socket", harness.socket) | |||
| 170 | - | } | |||
| 171 | - | return runUIDriverClient(ctx, brokerClientConfig{ | |||
| 172 | - | Connector: brokeripc.NewConnector(harness.socket, brokeripc.Config{}), | |||
| 173 | - | Terminal: harness.terminal, | |||
| 174 | - | Clock: clock.New(), | |||
| 175 | - | UI: harness.ui, | |||
| 176 | - | InitialNavigation: harness.navigation, | |||
| 177 | - | ResolveInitialNavigation: harness.resolver, | |||
| 178 | - | AttachmentEnvironment: terminalAttachmentEnvironment(), | |||
| 179 | - | SessionEnvironment: client.SessionEnvironment{Provenance: client.SessionEnvironmentLocalCLI}, | |||
| 180 | - | }, harness.stream) | |||
| 181 | - | } | |||
M internal/app/broker_offline_client_test.go +1 -1
| @@ -138,7 +138,7 @@ func startPersistentOfflineClientFixture(t *testing.T, shell string, shellArgs [ | |||
| 138 | 138 | // a manual clock rooted at Unix zero would correctly make the observation | |
| 139 | 139 | // stale to the real client clock before initial navigation resolves. | |
| 140 | 140 | deps, ready := testBrokerServeDeps(clock.New()) | |
| 141 | - | brokerDone := runSandbox(ctx, brokerServeOptions{production: true, idleGrace: time.Hour}, deps) | |
| 141 | + | brokerDone := runSandbox(ctx, brokerServeOptions{idleGrace: time.Hour}, deps) | |
| 142 | 142 | var socket string | |
| 143 | 143 | select { | |
| 144 | 144 | case socket = <-ready: | |
| @@ -138,7 +138,7 @@ func startPersistentOfflineClientFixture(t *testing.T, shell string, shellArgs [ | |||||
| 138 | // a manual clock rooted at Unix zero would correctly make the observation | 138 | // a manual clock rooted at Unix zero would correctly make the observation | ||
| 139 | // stale to the real client clock before initial navigation resolves. | 139 | // stale to the real client clock before initial navigation resolves. | ||
| 140 | deps, ready := testBrokerServeDeps(clock.New()) | 140 | deps, ready := testBrokerServeDeps(clock.New()) | ||
| 141 | - | brokerDone := runSandbox(ctx, brokerServeOptions{production: true, idleGrace: time.Hour}, deps) | 141 | + | brokerDone := runSandbox(ctx, brokerServeOptions{idleGrace: time.Hour}, deps) |
| 142 | var socket string | 142 | var socket string | ||
| 143 | select { | 143 | select { | ||
| 144 | case socket = <-ready: | 144 | case socket = <-ready: | ||
A internal/app/broker_offline_client_test_helpers_test.go +78 -0
| @@ -0,0 +1,78 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "io" | |
| 7 | + | "log/slog" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/adapters/brokeripc" | |
| 10 | + | "github.com/bnema/vev/internal/adapters/clock" | |
| 11 | + | "github.com/bnema/vev/internal/adapters/uiterm" | |
| 12 | + | "github.com/bnema/vev/internal/ports" | |
| 13 | + | "github.com/bnema/vev/internal/usecase/client" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // runOfflineClient is the sandbox terminal harness over the shared broker | |
| 17 | + | // client composition. It supplies the real broker IPC connector for one | |
| 18 | + | // offline root and the no-argument ephemeral local creation, then delegates the | |
| 19 | + | // whole run to runBrokerClient: the sandbox adds no picker, supervisor, dialer, | |
| 20 | + | // or stream of its own. | |
| 21 | + | func runOfflineClient(ctx context.Context, socket string, terminal ports.Terminal, ui *client.UI, log *slog.Logger, onState func(client.State), onFailure func(error)) error { | |
| 22 | + | if terminal == nil { | |
| 23 | + | return errors.New("vev: offline client requires a terminal") | |
| 24 | + | } | |
| 25 | + | if log != nil { | |
| 26 | + | log.Debug("broker_offline_client", "socket", socket) | |
| 27 | + | } | |
| 28 | + | return runBrokerClient(ctx, brokerClientConfig{ | |
| 29 | + | Connector: brokeripc.NewConnector(socket, brokeripc.Config{}), | |
| 30 | + | Terminal: terminal, | |
| 31 | + | UI: ui, | |
| 32 | + | // No-argument composition creates one ephemeral local session through | |
| 33 | + | // the closed initial-navigation union; there is no special attach path. | |
| 34 | + | InitialNavigation: localEphemeralNavigation(), | |
| 35 | + | AttachmentEnvironment: terminalAttachmentEnvironment(), | |
| 36 | + | SessionEnvironment: client.SessionEnvironment{Provenance: client.SessionEnvironmentLocalCLI}, | |
| 37 | + | OnState: onState, | |
| 38 | + | OnFailure: onFailure, | |
| 39 | + | }) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // offlineUIDriverHarness is the sandbox harness input: one explicitly supplied | |
| 43 | + | // offline broker socket, the virtual terminal and UI service built over it, the | |
| 44 | + | // one-shot navigation the harness was asked to perform, and the stream the JSONL | |
| 45 | + | // protocol is served on. The CLI's `--picker` selects the zero picker intent; the | |
| 46 | + | // default is the same no-argument local ephemeral creation every frontend uses. | |
| 47 | + | type offlineUIDriverHarness struct { | |
| 48 | + | socket string | |
| 49 | + | terminal *uiterm.Terminal | |
| 50 | + | ui *client.UI | |
| 51 | + | log *slog.Logger | |
| 52 | + | navigation client.InitialNavigation | |
| 53 | + | resolver client.InitialNavigationResolver | |
| 54 | + | stream io.ReadWriteCloser | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // runOfflineUIDriverClient composes the sandbox UI-driver harness over the same | |
| 58 | + | // runUIDriverClient path production uses. Its connector is the real broker IPC | |
| 59 | + | // connector of one explicitly supplied offline root: the sandbox adds no | |
| 60 | + | // production fallback, starts no broker of its own, and never reaches the | |
| 61 | + | // production XDG runtime or state. No session, endpoint, or process is | |
| 62 | + | // provisioned here: the harness reaches sessions only through the broker of its | |
| 63 | + | // own root, exactly like the production driver. | |
| 64 | + | func runOfflineUIDriverClient(ctx context.Context, harness offlineUIDriverHarness) error { | |
| 65 | + | if harness.log != nil { | |
| 66 | + | harness.log.Debug("broker_offline_ui_driver", "socket", harness.socket) | |
| 67 | + | } | |
| 68 | + | return runUIDriverClient(ctx, brokerClientConfig{ | |
| 69 | + | Connector: brokeripc.NewConnector(harness.socket, brokeripc.Config{}), | |
| 70 | + | Terminal: harness.terminal, | |
| 71 | + | Clock: clock.New(), | |
| 72 | + | UI: harness.ui, | |
| 73 | + | InitialNavigation: harness.navigation, | |
| 74 | + | ResolveInitialNavigation: harness.resolver, | |
| 75 | + | AttachmentEnvironment: terminalAttachmentEnvironment(), | |
| 76 | + | SessionEnvironment: client.SessionEnvironment{Provenance: client.SessionEnvironmentLocalCLI}, | |
| 77 | + | }, harness.stream) | |
| 78 | + | } | |
| @@ -0,0 +1,78 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "io" | |||
| 7 | + | "log/slog" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/adapters/brokeripc" | |||
| 10 | + | "github.com/bnema/vev/internal/adapters/clock" | |||
| 11 | + | "github.com/bnema/vev/internal/adapters/uiterm" | |||
| 12 | + | "github.com/bnema/vev/internal/ports" | |||
| 13 | + | "github.com/bnema/vev/internal/usecase/client" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // runOfflineClient is the sandbox terminal harness over the shared broker | |||
| 17 | + | // client composition. It supplies the real broker IPC connector for one | |||
| 18 | + | // offline root and the no-argument ephemeral local creation, then delegates the | |||
| 19 | + | // whole run to runBrokerClient: the sandbox adds no picker, supervisor, dialer, | |||
| 20 | + | // or stream of its own. | |||
| 21 | + | func runOfflineClient(ctx context.Context, socket string, terminal ports.Terminal, ui *client.UI, log *slog.Logger, onState func(client.State), onFailure func(error)) error { | |||
| 22 | + | if terminal == nil { | |||
| 23 | + | return errors.New("vev: offline client requires a terminal") | |||
| 24 | + | } | |||
| 25 | + | if log != nil { | |||
| 26 | + | log.Debug("broker_offline_client", "socket", socket) | |||
| 27 | + | } | |||
| 28 | + | return runBrokerClient(ctx, brokerClientConfig{ | |||
| 29 | + | Connector: brokeripc.NewConnector(socket, brokeripc.Config{}), | |||
| 30 | + | Terminal: terminal, | |||
| 31 | + | UI: ui, | |||
| 32 | + | // No-argument composition creates one ephemeral local session through | |||
| 33 | + | // the closed initial-navigation union; there is no special attach path. | |||
| 34 | + | InitialNavigation: localEphemeralNavigation(), | |||
| 35 | + | AttachmentEnvironment: terminalAttachmentEnvironment(), | |||
| 36 | + | SessionEnvironment: client.SessionEnvironment{Provenance: client.SessionEnvironmentLocalCLI}, | |||
| 37 | + | OnState: onState, | |||
| 38 | + | OnFailure: onFailure, | |||
| 39 | + | }) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // offlineUIDriverHarness is the sandbox harness input: one explicitly supplied | |||
| 43 | + | // offline broker socket, the virtual terminal and UI service built over it, the | |||
| 44 | + | // one-shot navigation the harness was asked to perform, and the stream the JSONL | |||
| 45 | + | // protocol is served on. The CLI's `--picker` selects the zero picker intent; the | |||
| 46 | + | // default is the same no-argument local ephemeral creation every frontend uses. | |||
| 47 | + | type offlineUIDriverHarness struct { | |||
| 48 | + | socket string | |||
| 49 | + | terminal *uiterm.Terminal | |||
| 50 | + | ui *client.UI | |||
| 51 | + | log *slog.Logger | |||
| 52 | + | navigation client.InitialNavigation | |||
| 53 | + | resolver client.InitialNavigationResolver | |||
| 54 | + | stream io.ReadWriteCloser | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // runOfflineUIDriverClient composes the sandbox UI-driver harness over the same | |||
| 58 | + | // runUIDriverClient path production uses. Its connector is the real broker IPC | |||
| 59 | + | // connector of one explicitly supplied offline root: the sandbox adds no | |||
| 60 | + | // production fallback, starts no broker of its own, and never reaches the | |||
| 61 | + | // production XDG runtime or state. No session, endpoint, or process is | |||
| 62 | + | // provisioned here: the harness reaches sessions only through the broker of its | |||
| 63 | + | // own root, exactly like the production driver. | |||
| 64 | + | func runOfflineUIDriverClient(ctx context.Context, harness offlineUIDriverHarness) error { | |||
| 65 | + | if harness.log != nil { | |||
| 66 | + | harness.log.Debug("broker_offline_ui_driver", "socket", harness.socket) | |||
| 67 | + | } | |||
| 68 | + | return runUIDriverClient(ctx, brokerClientConfig{ | |||
| 69 | + | Connector: brokeripc.NewConnector(harness.socket, brokeripc.Config{}), | |||
| 70 | + | Terminal: harness.terminal, | |||
| 71 | + | Clock: clock.New(), | |||
| 72 | + | UI: harness.ui, | |||
| 73 | + | InitialNavigation: harness.navigation, | |||
| 74 | + | ResolveInitialNavigation: harness.resolver, | |||
| 75 | + | AttachmentEnvironment: terminalAttachmentEnvironment(), | |||
| 76 | + | SessionEnvironment: client.SessionEnvironment{Provenance: client.SessionEnvironmentLocalCLI}, | |||
| 77 | + | }, harness.stream) | |||
| 78 | + | } | |||
M internal/app/broker_offline_mux.go +15 -83
| @@ -101,46 +101,36 @@ const brokerMuxWaitDelay = 2 * time.Second | |||
| 101 | 101 | // brokerDaemonStartArgvFlag's exact spelling, so the two sides can never drift. | |
| 102 | 102 | const brokerDaemonStartArg = "--daemon-start" | |
| 103 | 103 | ||
| 104 | + | // brokerScopeArg is the scope flag carried by every durable remote route argv | |
| 105 | + | // (see ports.BrokerRouteForTransport). Stored routes keep it verbatim. | |
| 106 | + | const brokerScopeArg = "--production" | |
| 107 | + | ||
| 104 | 108 | // brokerMuxOptions is the parsed hidden mux helper invocation. | |
| 105 | 109 | type brokerMuxOptions struct { | |
| 106 | - | production bool | |
| 107 | - | offlineRoot string | |
| 108 | 110 | // startMode is the daemon-start authorization the broker propagated. An | |
| 109 | 111 | // absent flag is the safe default: the helper observes only what is already | |
| 110 | 112 | // running and never starts a daemon, a broker, or a recursive helper. | |
| 111 | 113 | startMode ports.BrokerDaemonStartMode | |
| 112 | 114 | } | |
| 113 | 115 | ||
| 114 | - | // parseBrokerMuxArgs strictly parses `<hidden> (--offline-root ABS | --production) | |
| 115 | - | // [--daemon-start existing-only|if-needed]`. Unknown or duplicate flags, a | |
| 116 | - | // missing or unknown value, and positionals are refused, exactly like the | |
| 117 | - | // foreground sandbox entry point. An absent --daemon-start defaults to | |
| 116 | + | // parseBrokerMuxArgs strictly parses `<hidden> [--daemon-start | |
| 117 | + | // existing-only|if-needed]`. Unknown or duplicate flags, a missing or unknown | |
| 118 | + | // value, and positionals are refused. An absent --daemon-start defaults to | |
| 118 | 119 | // existing-only, and an unrecognized value is never mapped onto a permissive | |
| 119 | 120 | // authorization. | |
| 120 | 121 | func parseBrokerMuxArgs(name string, kind cmdKind, args []string) (command, error) { | |
| 121 | 122 | options := brokerMuxOptions{startMode: ports.BrokerDaemonExistingOnly} | |
| 122 | - | rootSet, startSet := false, false | |
| 123 | + | scopeSet, startSet := false, false | |
| 123 | 124 | for index := 0; index < len(args); { | |
| 124 | 125 | switch args[index] { | |
| 125 | - | case "--production": | |
| 126 | - | if options.production || rootSet { | |
| 127 | - | return command{}, usagef("`%s` received duplicate or conflicting scope", name) | |
| 126 | + | case brokerScopeArg: | |
| 127 | + | // The scope flag is part of every durable remote route argv, so it | |
| 128 | + | // stays accepted; the helper always serves the per-user scope. | |
| 129 | + | if scopeSet { | |
| 130 | + | return command{}, usagef("`%s` received duplicate %s", name, brokerScopeArg) | |
| 128 | 131 | } | |
| 129 | - | options.production = true | |
| 132 | + | scopeSet = true | |
| 130 | 133 | index++ | |
| 131 | - | case "--offline-root": | |
| 132 | - | if options.production { | |
| 133 | - | return command{}, usagef("`%s` received conflicting scope", name) | |
| 134 | - | } | |
| 135 | - | if rootSet { | |
| 136 | - | return command{}, usagef("`%s` received duplicate --offline-root", name) | |
| 137 | - | } | |
| 138 | - | if index+1 >= len(args) || args[index+1] == "" { | |
| 139 | - | return command{}, usagef("`--offline-root` requires a path") | |
| 140 | - | } | |
| 141 | - | options.offlineRoot = args[index+1] | |
| 142 | - | rootSet = true | |
| 143 | - | index += 2 | |
| 144 | 134 | case brokerDaemonStartArg: | |
| 145 | 135 | if startSet { | |
| 146 | 136 | return command{}, usagef("`%s` received duplicate %s", name, brokerDaemonStartArg) | |
| @@ -162,48 +152,9 @@ func parseBrokerMuxArgs(name string, kind cmdKind, args []string) (command, erro | |||
| 162 | 152 | return command{}, usagef("`%s` does not accept positional arguments", name) | |
| 163 | 153 | } | |
| 164 | 154 | } | |
| 165 | - | if !rootSet && !options.production { | |
| 166 | - | return command{}, usagef("`%s` requires --offline-root or --production", name) | |
| 167 | - | } | |
| 168 | 155 | return command{kind: kind, brokerMux: options}, nil | |
| 169 | 156 | } | |
| 170 | 157 | ||
| 171 | - | // loadMuxHelperConfig validates one offline root and loads its strict sandbox | |
| 172 | - | // configuration. A helper never creates sandbox directories: the root and the | |
| 173 | - | // config marker must already exist, so a helper can never provision state. | |
| 174 | - | func loadMuxHelperConfig(offlineRoot string) (*brokerconfig.Config, error) { | |
| 175 | - | layout, err := offlineLayout(offlineRoot) | |
| 176 | - | if err != nil { | |
| 177 | - | return nil, err | |
| 178 | - | } | |
| 179 | - | return brokerconfig.Load(layout) | |
| 180 | - | } | |
| 181 | - | ||
| 182 | - | // brokerMuxConnector builds the transport-selecting daemonmux endpoint | |
| 183 | - | // connector over one immutable configuration. The dial function receives the | |
| 184 | - | // whole resolved target the pool will authenticate against and refuses any | |
| 185 | - | // address the configuration did not produce, so a request can never select a | |
| 186 | - | // transport, an address, or a start authorization of its own. It re-checks that | |
| 187 | - | // the route's provisioned authority still matches the resolved policy and fence | |
| 188 | - | // before dialing, so the transport is only ever reached under the authority the | |
| 189 | - | // resolver published. | |
| 190 | - | func brokerMuxConnector(config *brokerconfig.Config, log *slog.Logger) (*daemonmux.EndpointConnector, error) { | |
| 191 | - | if config == nil { | |
| 192 | - | return nil, errors.New("vev: broker mux connector requires a configuration") | |
| 193 | - | } | |
| 194 | - | dial := func(ctx context.Context, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) { | |
| 195 | - | route, ok := config.RouteByAddress(target.Address) | |
| 196 | - | if !ok { | |
| 197 | - | return nil, fmt.Errorf("vev: broker mux: unknown route address") | |
| 198 | - | } | |
| 199 | - | if err := brokerRouteAuthorityMatches(config, target); err != nil { | |
| 200 | - | return nil, err | |
| 201 | - | } | |
| 202 | - | return dialBrokerRoute(ctx, route, target, log) | |
| 203 | - | } | |
| 204 | - | return daemonmux.NewEndpointConnector(dial, daemonmux.DefaultMuxCeilings()) | |
| 205 | - | } | |
| 206 | - | ||
| 207 | 158 | // brokerDynamicMuxConnector keeps the configured local carriage, but remote | |
| 208 | 159 | // targets must still match committed registry authority at dial time. | |
| 209 | 160 | func brokerDynamicMuxConnector(config *brokerconfig.Config, routes *brokerRoutes, log *slog.Logger) (*daemonmux.EndpointConnector, error) { | |
| @@ -530,13 +481,6 @@ func runBrokerMuxStdioCommand(ctx context.Context, options brokerMuxOptions) err | |||
| 530 | 481 | // proxy owns the carriage lifetime in its own session and survives this short- | |
| 531 | 482 | // lived bootstrap, exactly like the ordinary QUIC bootstrap. | |
| 532 | 483 | func runBrokerMuxQUICBootstrapCommand(ctx context.Context, options brokerMuxOptions) error { | |
| 533 | - | // Validate the sandbox before spawning anything, so a bad root or config | |
| 534 | - | // fails without a readiness line and without a child. | |
| 535 | - | if !options.production { | |
| 536 | - | if _, err := loadMuxHelperConfig(options.offlineRoot); err != nil { | |
| 537 | - | return err | |
| 538 | - | } | |
| 539 | - | } | |
| 540 | 484 | exe, err := os.Executable() | |
| 541 | 485 | if err != nil { | |
| 542 | 486 | return err | |
| @@ -553,12 +497,7 @@ func runBrokerMuxQUICBootstrapCommand(ctx context.Context, options brokerMuxOpti | |||
| 553 | 497 | } | |
| 554 | 498 | defer func() { _ = devNull.Close() }() | |
| 555 | 499 | ||
| 556 | - | args := []string{brokerMuxQUICProxyCommand} | |
| 557 | - | if options.production { | |
| 558 | - | args = append(args, "--production") | |
| 559 | - | } else { | |
| 560 | - | args = append(args, "--offline-root", options.offlineRoot) | |
| 561 | - | } | |
| 500 | + | args := []string{brokerMuxQUICProxyCommand, brokerScopeArg} | |
| 562 | 501 | flag, err := brokerDaemonStartArgvFlag(options.startMode) | |
| 563 | 502 | if err != nil { | |
| 564 | 503 | _ = writer.Close() | |
| @@ -652,13 +591,6 @@ func runBrokerMuxQUICProxyCommand(ctx context.Context, options brokerMuxOptions) | |||
| 652 | 591 | // dialBrokerMuxHelper is a remote-side broker carriage helper, not a client | |
| 653 | 592 | // daemon dial. Production authority is derived locally, never from SSH argv. | |
| 654 | 593 | func dialBrokerMuxHelper(ctx context.Context, options brokerMuxOptions) (daemonmux.RawFramedTransport, error) { | |
| 655 | - | if !options.production { | |
| 656 | - | config, err := loadMuxHelperConfig(options.offlineRoot) | |
| 657 | - | if err != nil { | |
| 658 | - | return nil, err | |
| 659 | - | } | |
| 660 | - | return dialMuxHelperCarriage(ctx, config, options.startMode) | |
| 661 | - | } | |
| 662 | 594 | route, err := brokerconfig.RouteFromSpec(ports.BrokerRouteSpec{Kind: ports.BrokerRouteUnix, Path: daemonmux.SocketPath(ipc.SocketDir())}) | |
| 663 | 595 | if err != nil { | |
| 664 | 596 | return nil, err | |
| @@ -101,46 +101,36 @@ const brokerMuxWaitDelay = 2 * time.Second | |||||
| 101 | // brokerDaemonStartArgvFlag's exact spelling, so the two sides can never drift. | 101 | // brokerDaemonStartArgvFlag's exact spelling, so the two sides can never drift. | ||
| 102 | const brokerDaemonStartArg = "--daemon-start" | 102 | const brokerDaemonStartArg = "--daemon-start" | ||
| 103 | 103 | ||||
| 104 | + | // brokerScopeArg is the scope flag carried by every durable remote route argv | |||
| 105 | + | // (see ports.BrokerRouteForTransport). Stored routes keep it verbatim. | |||
| 106 | + | const brokerScopeArg = "--production" | |||
| 107 | + | ||||
| 104 | // brokerMuxOptions is the parsed hidden mux helper invocation. | 108 | // brokerMuxOptions is the parsed hidden mux helper invocation. | ||
| 105 | type brokerMuxOptions struct { | 109 | type brokerMuxOptions struct { | ||
| 106 | - | production bool | |||
| 107 | - | offlineRoot string | |||
| 108 | // startMode is the daemon-start authorization the broker propagated. An | 110 | // startMode is the daemon-start authorization the broker propagated. An | ||
| 109 | // absent flag is the safe default: the helper observes only what is already | 111 | // absent flag is the safe default: the helper observes only what is already | ||
| 110 | // running and never starts a daemon, a broker, or a recursive helper. | 112 | // running and never starts a daemon, a broker, or a recursive helper. | ||
| 111 | startMode ports.BrokerDaemonStartMode | 113 | startMode ports.BrokerDaemonStartMode | ||
| 112 | } | 114 | } | ||
| 113 | 115 | ||||
| 114 | - | // parseBrokerMuxArgs strictly parses `<hidden> (--offline-root ABS | --production) | 116 | + | // parseBrokerMuxArgs strictly parses `<hidden> [--daemon-start |
| 115 | - | // [--daemon-start existing-only|if-needed]`. Unknown or duplicate flags, a | 117 | + | // existing-only|if-needed]`. Unknown or duplicate flags, a missing or unknown |
| 116 | - | // missing or unknown value, and positionals are refused, exactly like the | 118 | + | // value, and positionals are refused. An absent --daemon-start defaults to |
| 117 | - | // foreground sandbox entry point. An absent --daemon-start defaults to | |||
| 118 | // existing-only, and an unrecognized value is never mapped onto a permissive | 119 | // existing-only, and an unrecognized value is never mapped onto a permissive | ||
| 119 | // authorization. | 120 | // authorization. | ||
| 120 | func parseBrokerMuxArgs(name string, kind cmdKind, args []string) (command, error) { | 121 | func parseBrokerMuxArgs(name string, kind cmdKind, args []string) (command, error) { | ||
| 121 | options := brokerMuxOptions{startMode: ports.BrokerDaemonExistingOnly} | 122 | options := brokerMuxOptions{startMode: ports.BrokerDaemonExistingOnly} | ||
| 122 | - | rootSet, startSet := false, false | 123 | + | scopeSet, startSet := false, false |
| 123 | for index := 0; index < len(args); { | 124 | for index := 0; index < len(args); { | ||
| 124 | switch args[index] { | 125 | switch args[index] { | ||
| 125 | - | case "--production": | 126 | + | case brokerScopeArg: |
| 126 | - | if options.production || rootSet { | 127 | + | // The scope flag is part of every durable remote route argv, so it |
| 127 | - | return command{}, usagef("`%s` received duplicate or conflicting scope", name) | 128 | + | // stays accepted; the helper always serves the per-user scope. |
| 129 | + | if scopeSet { | |||
| 130 | + | return command{}, usagef("`%s` received duplicate %s", name, brokerScopeArg) | |||
| 128 | } | 131 | } | ||
| 129 | - | options.production = true | 132 | + | scopeSet = true |
| 130 | index++ | 133 | index++ | ||
| 131 | - | case "--offline-root": | |||
| 132 | - | if options.production { | |||
| 133 | - | return command{}, usagef("`%s` received conflicting scope", name) | |||
| 134 | - | } | |||
| 135 | - | if rootSet { | |||
| 136 | - | return command{}, usagef("`%s` received duplicate --offline-root", name) | |||
| 137 | - | } | |||
| 138 | - | if index+1 >= len(args) || args[index+1] == "" { | |||
| 139 | - | return command{}, usagef("`--offline-root` requires a path") | |||
| 140 | - | } | |||
| 141 | - | options.offlineRoot = args[index+1] | |||
| 142 | - | rootSet = true | |||
| 143 | - | index += 2 | |||
| 144 | case brokerDaemonStartArg: | 134 | case brokerDaemonStartArg: | ||
| 145 | if startSet { | 135 | if startSet { | ||
| 146 | return command{}, usagef("`%s` received duplicate %s", name, brokerDaemonStartArg) | 136 | return command{}, usagef("`%s` received duplicate %s", name, brokerDaemonStartArg) | ||
| @@ -162,48 +152,9 @@ func parseBrokerMuxArgs(name string, kind cmdKind, args []string) (command, erro | |||||
| 162 | return command{}, usagef("`%s` does not accept positional arguments", name) | 152 | return command{}, usagef("`%s` does not accept positional arguments", name) | ||
| 163 | } | 153 | } | ||
| 164 | } | 154 | } | ||
| 165 | - | if !rootSet && !options.production { | |||
| 166 | - | return command{}, usagef("`%s` requires --offline-root or --production", name) | |||
| 167 | - | } | |||
| 168 | return command{kind: kind, brokerMux: options}, nil | 155 | return command{kind: kind, brokerMux: options}, nil | ||
| 169 | } | 156 | } | ||
| 170 | 157 | ||||
| 171 | - | // loadMuxHelperConfig validates one offline root and loads its strict sandbox | |||
| 172 | - | // configuration. A helper never creates sandbox directories: the root and the | |||
| 173 | - | // config marker must already exist, so a helper can never provision state. | |||
| 174 | - | func loadMuxHelperConfig(offlineRoot string) (*brokerconfig.Config, error) { | |||
| 175 | - | layout, err := offlineLayout(offlineRoot) | |||
| 176 | - | if err != nil { | |||
| 177 | - | return nil, err | |||
| 178 | - | } | |||
| 179 | - | return brokerconfig.Load(layout) | |||
| 180 | - | } | |||
| 181 | - | ||||
| 182 | - | // brokerMuxConnector builds the transport-selecting daemonmux endpoint | |||
| 183 | - | // connector over one immutable configuration. The dial function receives the | |||
| 184 | - | // whole resolved target the pool will authenticate against and refuses any | |||
| 185 | - | // address the configuration did not produce, so a request can never select a | |||
| 186 | - | // transport, an address, or a start authorization of its own. It re-checks that | |||
| 187 | - | // the route's provisioned authority still matches the resolved policy and fence | |||
| 188 | - | // before dialing, so the transport is only ever reached under the authority the | |||
| 189 | - | // resolver published. | |||
| 190 | - | func brokerMuxConnector(config *brokerconfig.Config, log *slog.Logger) (*daemonmux.EndpointConnector, error) { | |||
| 191 | - | if config == nil { | |||
| 192 | - | return nil, errors.New("vev: broker mux connector requires a configuration") | |||
| 193 | - | } | |||
| 194 | - | dial := func(ctx context.Context, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) { | |||
| 195 | - | route, ok := config.RouteByAddress(target.Address) | |||
| 196 | - | if !ok { | |||
| 197 | - | return nil, fmt.Errorf("vev: broker mux: unknown route address") | |||
| 198 | - | } | |||
| 199 | - | if err := brokerRouteAuthorityMatches(config, target); err != nil { | |||
| 200 | - | return nil, err | |||
| 201 | - | } | |||
| 202 | - | return dialBrokerRoute(ctx, route, target, log) | |||
| 203 | - | } | |||
| 204 | - | return daemonmux.NewEndpointConnector(dial, daemonmux.DefaultMuxCeilings()) | |||
| 205 | - | } | |||
| 206 | - | ||||
| 207 | // brokerDynamicMuxConnector keeps the configured local carriage, but remote | 158 | // brokerDynamicMuxConnector keeps the configured local carriage, but remote | ||
| 208 | // targets must still match committed registry authority at dial time. | 159 | // targets must still match committed registry authority at dial time. | ||
| 209 | func brokerDynamicMuxConnector(config *brokerconfig.Config, routes *brokerRoutes, log *slog.Logger) (*daemonmux.EndpointConnector, error) { | 160 | func brokerDynamicMuxConnector(config *brokerconfig.Config, routes *brokerRoutes, log *slog.Logger) (*daemonmux.EndpointConnector, error) { | ||
| @@ -530,13 +481,6 @@ func runBrokerMuxStdioCommand(ctx context.Context, options brokerMuxOptions) err | |||||
| 530 | // proxy owns the carriage lifetime in its own session and survives this short- | 481 | // proxy owns the carriage lifetime in its own session and survives this short- | ||
| 531 | // lived bootstrap, exactly like the ordinary QUIC bootstrap. | 482 | // lived bootstrap, exactly like the ordinary QUIC bootstrap. | ||
| 532 | func runBrokerMuxQUICBootstrapCommand(ctx context.Context, options brokerMuxOptions) error { | 483 | func runBrokerMuxQUICBootstrapCommand(ctx context.Context, options brokerMuxOptions) error { | ||
| 533 | - | // Validate the sandbox before spawning anything, so a bad root or config | |||
| 534 | - | // fails without a readiness line and without a child. | |||
| 535 | - | if !options.production { | |||
| 536 | - | if _, err := loadMuxHelperConfig(options.offlineRoot); err != nil { | |||
| 537 | - | return err | |||
| 538 | - | } | |||
| 539 | - | } | |||
| 540 | exe, err := os.Executable() | 484 | exe, err := os.Executable() | ||
| 541 | if err != nil { | 485 | if err != nil { | ||
| 542 | return err | 486 | return err | ||
| @@ -553,12 +497,7 @@ func runBrokerMuxQUICBootstrapCommand(ctx context.Context, options brokerMuxOpti | |||||
| 553 | } | 497 | } | ||
| 554 | defer func() { _ = devNull.Close() }() | 498 | defer func() { _ = devNull.Close() }() | ||
| 555 | 499 | ||||
| 556 | - | args := []string{brokerMuxQUICProxyCommand} | 500 | + | args := []string{brokerMuxQUICProxyCommand, brokerScopeArg} |
| 557 | - | if options.production { | |||
| 558 | - | args = append(args, "--production") | |||
| 559 | - | } else { | |||
| 560 | - | args = append(args, "--offline-root", options.offlineRoot) | |||
| 561 | - | } | |||
| 562 | flag, err := brokerDaemonStartArgvFlag(options.startMode) | 501 | flag, err := brokerDaemonStartArgvFlag(options.startMode) | ||
| 563 | if err != nil { | 502 | if err != nil { | ||
| 564 | _ = writer.Close() | 503 | _ = writer.Close() | ||
| @@ -652,13 +591,6 @@ func runBrokerMuxQUICProxyCommand(ctx context.Context, options brokerMuxOptions) | |||||
| 652 | // dialBrokerMuxHelper is a remote-side broker carriage helper, not a client | 591 | // dialBrokerMuxHelper is a remote-side broker carriage helper, not a client | ||
| 653 | // daemon dial. Production authority is derived locally, never from SSH argv. | 592 | // daemon dial. Production authority is derived locally, never from SSH argv. | ||
| 654 | func dialBrokerMuxHelper(ctx context.Context, options brokerMuxOptions) (daemonmux.RawFramedTransport, error) { | 593 | func dialBrokerMuxHelper(ctx context.Context, options brokerMuxOptions) (daemonmux.RawFramedTransport, error) { | ||
| 655 | - | if !options.production { | |||
| 656 | - | config, err := loadMuxHelperConfig(options.offlineRoot) | |||
| 657 | - | if err != nil { | |||
| 658 | - | return nil, err | |||
| 659 | - | } | |||
| 660 | - | return dialMuxHelperCarriage(ctx, config, options.startMode) | |||
| 661 | - | } | |||
| 662 | route, err := brokerconfig.RouteFromSpec(ports.BrokerRouteSpec{Kind: ports.BrokerRouteUnix, Path: daemonmux.SocketPath(ipc.SocketDir())}) | 594 | route, err := brokerconfig.RouteFromSpec(ports.BrokerRouteSpec{Kind: ports.BrokerRouteUnix, Path: daemonmux.SocketPath(ipc.SocketDir())}) | ||
| 663 | if err != nil { | 595 | if err != nil { | ||
| 664 | return nil, err | 596 | return nil, err | ||
M internal/app/broker_offline_mux_test.go +4 -22
| @@ -249,9 +249,9 @@ func TestParseBrokerMuxArgs(t *testing.T) { | |||
| 249 | 249 | wantErr string | |
| 250 | 250 | want brokerMuxOptions | |
| 251 | 251 | }{ | |
| 252 | - | {name: "valid", args: []string{"--production"}, want: brokerMuxOptions{production: true, startMode: ports.BrokerDaemonExistingOnly}}, | |
| 253 | - | {name: "explicit existing-only", args: []string{"--production", brokerDaemonStartArg, "existing-only"}, want: brokerMuxOptions{production: true, startMode: ports.BrokerDaemonExistingOnly}}, | |
| 254 | - | {name: "explicit if-needed", args: []string{"--production", brokerDaemonStartArg, "if-needed"}, want: brokerMuxOptions{production: true, startMode: ports.BrokerDaemonStartIfNeeded}}, | |
| 252 | + | {name: "valid", args: []string{"--production"}, want: brokerMuxOptions{startMode: ports.BrokerDaemonExistingOnly}}, | |
| 253 | + | {name: "explicit existing-only", args: []string{"--production", brokerDaemonStartArg, "existing-only"}, want: brokerMuxOptions{startMode: ports.BrokerDaemonExistingOnly}}, | |
| 254 | + | {name: "explicit if-needed", args: []string{"--production", brokerDaemonStartArg, "if-needed"}, want: brokerMuxOptions{startMode: ports.BrokerDaemonStartIfNeeded}}, | |
| 255 | 255 | {name: "unknown flag", args: []string{"--production", "--idle-grace", "1m"}, wantErr: "unknown flag"}, | |
| 256 | 256 | {name: "positional", args: []string{"--production", "extra"}, wantErr: "positional"}, | |
| 257 | 257 | {name: "missing daemon-start value", args: []string{"--production", brokerDaemonStartArg}, wantErr: "requires a value"}, | |
| @@ -287,7 +287,6 @@ func TestBrokerMuxHelpersHiddenFromPublicHelp(t *testing.T) { | |||
| 287 | 287 | command, err := parseArgs([]string{tt.name, "--production"}) | |
| 288 | 288 | require.NoError(t, err) | |
| 289 | 289 | require.Equal(t, tt.kind, command.kind) | |
| 290 | - | require.True(t, command.brokerMux.production) | |
| 291 | 290 | } | |
| 292 | 291 | // Ordinary commands are untouched by the new hidden entries. | |
| 293 | 292 | attach, err := parseArgs(nil) | |
| @@ -412,23 +411,6 @@ func TestBrokerMuxHelperReturnsTypedNoDaemonExit(t *testing.T) { | |||
| 412 | 411 | require.Equal(t, sshstdio.MuxExitNoDaemon, coded.code) | |
| 413 | 412 | } | |
| 414 | 413 | ||
| 415 | - | // TestBrokerMuxConnectorRejectsUnknownAddress proves the transport selector | |
| 416 | - | // refuses any address the immutable configuration did not produce. | |
| 417 | - | func TestBrokerMuxConnectorRejectsUnknownAddress(t *testing.T) { | |
| 418 | - | emptyProductionBrokerLayout(t, "") | |
| 419 | - | layout := writeMuxRoot(t, map[string]any{"kind": "unix", "path": "/tmp/unused-mux.sock"}) | |
| 420 | - | config, err := brokerconfig.LoadProduction(layout, productionBrokerConfigPath(), "", localDaemonPolicy(), daemonmux.SocketPath(ipc.SocketDir())) | |
| 421 | - | require.NoError(t, err) | |
| 422 | - | connector, err := brokerMuxConnector(config, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 423 | - | require.NoError(t, err) | |
| 424 | - | ||
| 425 | - | _, err = connector.Connect(context.Background(), ports.BrokerDialTarget{Fence: ports.BrokerEndpointFence{Local: true}, Policy: brokerTestPolicy(), Address: "offline-route-00000000000000000000000000000000", StartMode: ports.BrokerDaemonStartIfNeeded, ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: brokerTestIdentity, Bound: true}}) | |
| 426 | - | require.Error(t, err) | |
| 427 | - | var typed ports.BrokerError | |
| 428 | - | require.ErrorAs(t, err, &typed) | |
| 429 | - | require.Equal(t, ports.BrokerErrorUnavailable, typed.Code) | |
| 430 | - | } | |
| 431 | - | ||
| 432 | 414 | // TestBrokerServeSSHStdioRouteRoundTrip drives the full broker path over an SSH | |
| 433 | 415 | // stdio route: the broker dials the fake ssh child, the hidden helper bridges | |
| 434 | 416 | // its stdio to a real unix daemonmux fixture, and a typed Ping returns a Pong. | |
| @@ -659,7 +641,7 @@ func startMuxBrokerServe(t *testing.T) (string, func()) { | |||
| 659 | 641 | t.Helper() | |
| 660 | 642 | deps, ready := testBrokerServeDeps(newSandboxClock()) | |
| 661 | 643 | ctx, cancel := context.WithCancel(context.Background()) | |
| 662 | - | done := runSandbox(ctx, brokerServeOptions{production: true}, deps) | |
| 644 | + | done := runSandbox(ctx, brokerServeOptions{}, deps) | |
| 663 | 645 | socketPath := awaitSandboxReady(t, ready, done) | |
| 664 | 646 | var once bool | |
| 665 | 647 | stop := func() { | |
| @@ -249,9 +249,9 @@ func TestParseBrokerMuxArgs(t *testing.T) { | |||||
| 249 | wantErr string | 249 | wantErr string | ||
| 250 | want brokerMuxOptions | 250 | want brokerMuxOptions | ||
| 251 | }{ | 251 | }{ | ||
| 252 | - | {name: "valid", args: []string{"--production"}, want: brokerMuxOptions{production: true, startMode: ports.BrokerDaemonExistingOnly}}, | 252 | + | {name: "valid", args: []string{"--production"}, want: brokerMuxOptions{startMode: ports.BrokerDaemonExistingOnly}}, |
| 253 | - | {name: "explicit existing-only", args: []string{"--production", brokerDaemonStartArg, "existing-only"}, want: brokerMuxOptions{production: true, startMode: ports.BrokerDaemonExistingOnly}}, | 253 | + | {name: "explicit existing-only", args: []string{"--production", brokerDaemonStartArg, "existing-only"}, want: brokerMuxOptions{startMode: ports.BrokerDaemonExistingOnly}}, |
| 254 | - | {name: "explicit if-needed", args: []string{"--production", brokerDaemonStartArg, "if-needed"}, want: brokerMuxOptions{production: true, startMode: ports.BrokerDaemonStartIfNeeded}}, | 254 | + | {name: "explicit if-needed", args: []string{"--production", brokerDaemonStartArg, "if-needed"}, want: brokerMuxOptions{startMode: ports.BrokerDaemonStartIfNeeded}}, |
| 255 | {name: "unknown flag", args: []string{"--production", "--idle-grace", "1m"}, wantErr: "unknown flag"}, | 255 | {name: "unknown flag", args: []string{"--production", "--idle-grace", "1m"}, wantErr: "unknown flag"}, | ||
| 256 | {name: "positional", args: []string{"--production", "extra"}, wantErr: "positional"}, | 256 | {name: "positional", args: []string{"--production", "extra"}, wantErr: "positional"}, | ||
| 257 | {name: "missing daemon-start value", args: []string{"--production", brokerDaemonStartArg}, wantErr: "requires a value"}, | 257 | {name: "missing daemon-start value", args: []string{"--production", brokerDaemonStartArg}, wantErr: "requires a value"}, | ||
| @@ -287,7 +287,6 @@ func TestBrokerMuxHelpersHiddenFromPublicHelp(t *testing.T) { | |||||
| 287 | command, err := parseArgs([]string{tt.name, "--production"}) | 287 | command, err := parseArgs([]string{tt.name, "--production"}) | ||
| 288 | require.NoError(t, err) | 288 | require.NoError(t, err) | ||
| 289 | require.Equal(t, tt.kind, command.kind) | 289 | require.Equal(t, tt.kind, command.kind) | ||
| 290 | - | require.True(t, command.brokerMux.production) | |||
| 291 | } | 290 | } | ||
| 292 | // Ordinary commands are untouched by the new hidden entries. | 291 | // Ordinary commands are untouched by the new hidden entries. | ||
| 293 | attach, err := parseArgs(nil) | 292 | attach, err := parseArgs(nil) | ||
| @@ -412,23 +411,6 @@ func TestBrokerMuxHelperReturnsTypedNoDaemonExit(t *testing.T) { | |||||
| 412 | require.Equal(t, sshstdio.MuxExitNoDaemon, coded.code) | 411 | require.Equal(t, sshstdio.MuxExitNoDaemon, coded.code) | ||
| 413 | } | 412 | } | ||
| 414 | 413 | ||||
| 415 | - | // TestBrokerMuxConnectorRejectsUnknownAddress proves the transport selector | |||
| 416 | - | // refuses any address the immutable configuration did not produce. | |||
| 417 | - | func TestBrokerMuxConnectorRejectsUnknownAddress(t *testing.T) { | |||
| 418 | - | emptyProductionBrokerLayout(t, "") | |||
| 419 | - | layout := writeMuxRoot(t, map[string]any{"kind": "unix", "path": "/tmp/unused-mux.sock"}) | |||
| 420 | - | config, err := brokerconfig.LoadProduction(layout, productionBrokerConfigPath(), "", localDaemonPolicy(), daemonmux.SocketPath(ipc.SocketDir())) | |||
| 421 | - | require.NoError(t, err) | |||
| 422 | - | connector, err := brokerMuxConnector(config, slog.New(slog.NewTextHandler(io.Discard, nil))) | |||
| 423 | - | require.NoError(t, err) | |||
| 424 | - | ||||
| 425 | - | _, err = connector.Connect(context.Background(), ports.BrokerDialTarget{Fence: ports.BrokerEndpointFence{Local: true}, Policy: brokerTestPolicy(), Address: "offline-route-00000000000000000000000000000000", StartMode: ports.BrokerDaemonStartIfNeeded, ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: brokerTestIdentity, Bound: true}}) | |||
| 426 | - | require.Error(t, err) | |||
| 427 | - | var typed ports.BrokerError | |||
| 428 | - | require.ErrorAs(t, err, &typed) | |||
| 429 | - | require.Equal(t, ports.BrokerErrorUnavailable, typed.Code) | |||
| 430 | - | } | |||
| 431 | - | ||||
| 432 | // TestBrokerServeSSHStdioRouteRoundTrip drives the full broker path over an SSH | 414 | // TestBrokerServeSSHStdioRouteRoundTrip drives the full broker path over an SSH | ||
| 433 | // stdio route: the broker dials the fake ssh child, the hidden helper bridges | 415 | // stdio route: the broker dials the fake ssh child, the hidden helper bridges | ||
| 434 | // its stdio to a real unix daemonmux fixture, and a typed Ping returns a Pong. | 416 | // its stdio to a real unix daemonmux fixture, and a typed Ping returns a Pong. | ||
| @@ -659,7 +641,7 @@ func startMuxBrokerServe(t *testing.T) (string, func()) { | |||||
| 659 | t.Helper() | 641 | t.Helper() | ||
| 660 | deps, ready := testBrokerServeDeps(newSandboxClock()) | 642 | deps, ready := testBrokerServeDeps(newSandboxClock()) | ||
| 661 | ctx, cancel := context.WithCancel(context.Background()) | 643 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 662 | - | done := runSandbox(ctx, brokerServeOptions{production: true}, deps) | 644 | + | done := runSandbox(ctx, brokerServeOptions{}, deps) |
| 663 | socketPath := awaitSandboxReady(t, ready, done) | 645 | socketPath := awaitSandboxReady(t, ready, done) | ||
| 664 | var once bool | 646 | var once bool | ||
| 665 | stop := func() { | 647 | stop := func() { | ||
M internal/app/broker_offline_status.go +8 -264
| @@ -3,7 +3,6 @@ package app | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | - | "encoding/json" | |
| 7 | 6 | "errors" | |
| 8 | 7 | "fmt" | |
| 9 | 8 | "io" | |
| @@ -11,9 +10,7 @@ import ( | |||
| 11 | 10 | "net" | |
| 12 | 11 | "os" | |
| 13 | 12 | "os/exec" | |
| 14 | - | "os/signal" | |
| 15 | 13 | "path/filepath" | |
| 16 | - | "strings" | |
| 17 | 14 | "syscall" | |
| 18 | 15 | "time" | |
| 19 | 16 | ||
| @@ -57,14 +54,9 @@ import ( | |||
| 57 | 54 | // absolute overall deadline expires. A live-but-incompatible endpoint and a | |
| 58 | 55 | // stalled handshake are diagnosed without spawning. | |
| 59 | 56 | ||
| 60 | - | const ( | |
| 61 | - | // brokerLauncherCommand is the hidden intermediate that starts one detached | |
| 62 | - | // `_broker-serve` and exits. | |
| 63 | - | brokerLauncherCommand = "_broker-launcher" | |
| 64 | - | productionBrokerLauncherCommand = "_broker-production-launcher" | |
| 65 | - | // brokerStatusCommand is the hidden broker status probe. | |
| 66 | - | brokerStatusCommand = "_broker-status" | |
| 67 | - | ) | |
| 57 | + | // productionBrokerLauncherCommand is the hidden intermediate that starts one | |
| 58 | + | // detached broker serve process and exits. | |
| 59 | + | const productionBrokerLauncherCommand = "_broker-production-launcher" | |
| 68 | 60 | ||
| 69 | 61 | // Bounds for the detached connect-or-spawn path. Every wait is bounded by the | |
| 70 | 62 | // one absolute overall deadline, and the first dial is bounded on its own so a | |
| @@ -74,7 +66,6 @@ const ( | |||
| 74 | 66 | brokerStatusProbeBudget = 2 * time.Second | |
| 75 | 67 | brokerStatusInitialBackoff = 5 * time.Millisecond | |
| 76 | 68 | brokerStatusMaxBackoff = 50 * time.Millisecond | |
| 77 | - | brokerStatusMaxReportBytes = 4 << 10 | |
| 78 | 69 | ) | |
| 79 | 70 | ||
| 80 | 71 | var ( | |
| @@ -92,19 +83,6 @@ var ( | |||
| 92 | 83 | errBrokerNotReady = errors.New("vev: broker did not become ready") | |
| 93 | 84 | ) | |
| 94 | 85 | ||
| 95 | - | // brokerLauncherOptions is the parsed `_broker-launcher` invocation. | |
| 96 | - | type brokerLauncherOptions struct { | |
| 97 | - | offlineRoot string | |
| 98 | - | idleGrace time.Duration | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | // brokerStatusOptions is the parsed `_broker-status` invocation. | |
| 102 | - | type brokerStatusOptions struct { | |
| 103 | - | offlineRoot string | |
| 104 | - | ensure bool | |
| 105 | - | timeout time.Duration | |
| 106 | - | } | |
| 107 | - | ||
| 108 | 86 | // brokerStatusReport is the bounded JSON status document. It carries exactly | |
| 109 | 87 | // the ready/offline state, the broker endpoint, and the broker's epoch, | |
| 110 | 88 | // revision, and host count. Epoch, revision, and host count are zero when the | |
| @@ -154,14 +132,14 @@ func defaultBrokerStatusDeps() brokerStatusDeps { | |||
| 154 | 132 | } | |
| 155 | 133 | return false, owner.Release() | |
| 156 | 134 | }, | |
| 157 | - | spawn: spawnBrokerLauncher, | |
| 135 | + | spawn: func(ctx context.Context, _ string, _ time.Duration, _ bool) error { | |
| 136 | + | return spawnProductionBrokerLauncher(ctx) | |
| 137 | + | }, | |
| 158 | 138 | stdout: os.Stdout, | |
| 159 | 139 | } | |
| 160 | 140 | } | |
| 161 | 141 | ||
| 162 | - | // parseBrokerLauncherArgs strictly parses | |
| 163 | - | // `_broker-launcher --offline-root ABS [--idle-grace DURATION]`. Unknown flags, | |
| 164 | - | // duplicate flags, positionals, and non-positive durations are refused. | |
| 142 | + | // parseProductionBrokerLauncherArgs parses the argument-free hidden launcher entry. | |
| 165 | 143 | func parseProductionBrokerLauncherArgs(args []string) (command, error) { | |
| 166 | 144 | if len(args) != 0 { | |
| 167 | 145 | return command{}, usagef("`%s` does not accept arguments", productionBrokerLauncherCommand) | |
| @@ -169,141 +147,11 @@ func parseProductionBrokerLauncherArgs(args []string) (command, error) { | |||
| 169 | 147 | return command{kind: kindProductionBrokerLauncher}, nil | |
| 170 | 148 | } | |
| 171 | 149 | ||
| 172 | - | func parseBrokerLauncherArgs(args []string) (command, error) { | |
| 173 | - | var options brokerLauncherOptions | |
| 174 | - | var seenRoot, seenGrace bool | |
| 175 | - | for index := 0; index < len(args); index++ { | |
| 176 | - | switch args[index] { | |
| 177 | - | case "--offline-root": | |
| 178 | - | if seenRoot { | |
| 179 | - | return command{}, usagef("`%s` received duplicate --offline-root", brokerLauncherCommand) | |
| 180 | - | } | |
| 181 | - | if index+1 >= len(args) || args[index+1] == "" { | |
| 182 | - | return command{}, usagef("`--offline-root` requires a path") | |
| 183 | - | } | |
| 184 | - | options.offlineRoot = args[index+1] | |
| 185 | - | seenRoot = true | |
| 186 | - | index++ | |
| 187 | - | case "--idle-grace": | |
| 188 | - | if seenGrace { | |
| 189 | - | return command{}, usagef("`%s` received duplicate --idle-grace", brokerLauncherCommand) | |
| 190 | - | } | |
| 191 | - | if index+1 >= len(args) || args[index+1] == "" { | |
| 192 | - | return command{}, usagef("`--idle-grace` requires a duration") | |
| 193 | - | } | |
| 194 | - | grace, err := time.ParseDuration(args[index+1]) | |
| 195 | - | if err != nil { | |
| 196 | - | return command{}, usagef("`--idle-grace` %q is not a duration", args[index+1]) | |
| 197 | - | } | |
| 198 | - | if grace <= 0 { | |
| 199 | - | return command{}, usagef("`--idle-grace` must be positive") | |
| 200 | - | } | |
| 201 | - | options.idleGrace = grace | |
| 202 | - | seenGrace = true | |
| 203 | - | index++ | |
| 204 | - | default: | |
| 205 | - | if strings.HasPrefix(args[index], "-") { | |
| 206 | - | return command{}, usagef("unknown flag %q for `%s`", args[index], brokerLauncherCommand) | |
| 207 | - | } | |
| 208 | - | return command{}, usagef("`%s` does not accept positional arguments", brokerLauncherCommand) | |
| 209 | - | } | |
| 210 | - | } | |
| 211 | - | if options.offlineRoot == "" { | |
| 212 | - | return command{}, usagef("`%s` requires --offline-root", brokerLauncherCommand) | |
| 213 | - | } | |
| 214 | - | return command{kind: kindBrokerLauncher, brokerLauncher: options}, nil | |
| 215 | - | } | |
| 216 | - | ||
| 217 | - | // parseBrokerStatusArgs strictly parses | |
| 218 | - | // `_broker-status --offline-root ABS [--ensure] [--timeout DURATION]`. Unknown | |
| 219 | - | // flags, duplicate flags, positionals, and non-positive timeouts are refused. | |
| 220 | - | func parseBrokerStatusArgs(args []string) (command, error) { | |
| 221 | - | options := brokerStatusOptions{timeout: brokerStatusDefaultTimeout} | |
| 222 | - | var seenRoot, seenEnsure, seenTimeout bool | |
| 223 | - | for index := 0; index < len(args); index++ { | |
| 224 | - | switch args[index] { | |
| 225 | - | case "--offline-root": | |
| 226 | - | if seenRoot { | |
| 227 | - | return command{}, usagef("`%s` received duplicate --offline-root", brokerStatusCommand) | |
| 228 | - | } | |
| 229 | - | if index+1 >= len(args) || args[index+1] == "" { | |
| 230 | - | return command{}, usagef("`--offline-root` requires a path") | |
| 231 | - | } | |
| 232 | - | options.offlineRoot = args[index+1] | |
| 233 | - | seenRoot = true | |
| 234 | - | index++ | |
| 235 | - | case "--ensure": | |
| 236 | - | if seenEnsure { | |
| 237 | - | return command{}, usagef("`%s` received duplicate --ensure", brokerStatusCommand) | |
| 238 | - | } | |
| 239 | - | options.ensure = true | |
| 240 | - | seenEnsure = true | |
| 241 | - | case "--timeout": | |
| 242 | - | if seenTimeout { | |
| 243 | - | return command{}, usagef("`%s` received duplicate --timeout", brokerStatusCommand) | |
| 244 | - | } | |
| 245 | - | if index+1 >= len(args) || args[index+1] == "" { | |
| 246 | - | return command{}, usagef("`--timeout` requires a duration") | |
| 247 | - | } | |
| 248 | - | timeout, err := time.ParseDuration(args[index+1]) | |
| 249 | - | if err != nil { | |
| 250 | - | return command{}, usagef("`--timeout` %q is not a duration", args[index+1]) | |
| 251 | - | } | |
| 252 | - | if timeout <= 0 { | |
| 253 | - | return command{}, usagef("`--timeout` must be positive") | |
| 254 | - | } | |
| 255 | - | options.timeout = timeout | |
| 256 | - | seenTimeout = true | |
| 257 | - | index++ | |
| 258 | - | default: | |
| 259 | - | if strings.HasPrefix(args[index], "-") { | |
| 260 | - | return command{}, usagef("unknown flag %q for `%s`", args[index], brokerStatusCommand) | |
| 261 | - | } | |
| 262 | - | return command{}, usagef("`%s` does not accept positional arguments", brokerStatusCommand) | |
| 263 | - | } | |
| 264 | - | } | |
| 265 | - | if options.offlineRoot == "" { | |
| 266 | - | return command{}, usagef("`%s` requires --offline-root", brokerStatusCommand) | |
| 267 | - | } | |
| 268 | - | return command{kind: kindBrokerStatus, brokerStatus: options}, nil | |
| 269 | - | } | |
| 270 | - | ||
| 271 | - | // runBrokerLauncherCommand runs the hidden launcher. It changes no ordinary | |
| 272 | - | // command and never runs in production flows. | |
| 150 | + | // runProductionBrokerLauncherCommand starts one detached broker serve process. | |
| 273 | 151 | func runProductionBrokerLauncherCommand(context.Context) error { | |
| 274 | 152 | return startDetachedBrokerServe([]string{productionBrokerServeCommand}, filepath.Join(productionBrokerLayout().Log, "vev-broker-crash.log")) | |
| 275 | 153 | } | |
| 276 | 154 | ||
| 277 | - | func runBrokerLauncherCommand(_ context.Context, options brokerLauncherOptions) error { | |
| 278 | - | return runBrokerLauncher(options) | |
| 279 | - | } | |
| 280 | - | ||
| 281 | - | // runBrokerLauncher validates the sandbox configuration, diagnoses an explicit | |
| 282 | - | // idle grace that conflicts with the provisioned one, then starts one detached | |
| 283 | - | // `_broker-serve` in a new session and returns. | |
| 284 | - | func runBrokerLauncher(options brokerLauncherOptions) error { | |
| 285 | - | layout, err := offlineLayout(options.offlineRoot) | |
| 286 | - | if err != nil { | |
| 287 | - | return err | |
| 288 | - | } | |
| 289 | - | if err := safedir.EnsurePrivate(layout.Root); err != nil { | |
| 290 | - | return fmt.Errorf("vev: secure broker sandbox root: %w", err) | |
| 291 | - | } | |
| 292 | - | config, err := brokerconfig.Load(layout) | |
| 293 | - | if err != nil { | |
| 294 | - | return err | |
| 295 | - | } | |
| 296 | - | grace, err := effectiveIdleGrace(config, options.idleGrace) | |
| 297 | - | if err != nil { | |
| 298 | - | return err | |
| 299 | - | } | |
| 300 | - | args := []string{brokerServeCommand, "--offline-root", options.offlineRoot} | |
| 301 | - | if grace > 0 { | |
| 302 | - | args = append(args, "--idle-grace", grace.String()) | |
| 303 | - | } | |
| 304 | - | return startDetachedBrokerServe(args, filepath.Join(layout.Log, "vev-broker-crash.log")) | |
| 305 | - | } | |
| 306 | - | ||
| 307 | 155 | // startDetachedBrokerServe starts one `_broker-serve` in a new session and | |
| 308 | 156 | // releases it, so the broker outlives the short-lived launcher. It mirrors the | |
| 309 | 157 | // daemon launcher's double-fork boundary: no ambient variable is dropped beyond | |
| @@ -358,14 +206,6 @@ func spawnProductionBrokerLauncher(ctx context.Context) error { | |||
| 358 | 206 | return runBrokerLauncherProcess(ctx, []string{productionBrokerLauncherCommand}) | |
| 359 | 207 | } | |
| 360 | 208 | ||
| 361 | - | func spawnBrokerLauncher(ctx context.Context, root string, grace time.Duration, graceSet bool) error { | |
| 362 | - | args := []string{brokerLauncherCommand, "--offline-root", root} | |
| 363 | - | if graceSet { | |
| 364 | - | args = append(args, "--idle-grace", grace.String()) | |
| 365 | - | } | |
| 366 | - | return runBrokerLauncherProcess(ctx, args) | |
| 367 | - | } | |
| 368 | - | ||
| 369 | 209 | func runBrokerLauncherProcess(ctx context.Context, args []string) error { | |
| 370 | 210 | exePath, err := selfExePath() | |
| 371 | 211 | if err != nil { | |
| @@ -393,87 +233,6 @@ func runBrokerLauncherProcess(ctx context.Context, args []string) error { | |||
| 393 | 233 | return nil | |
| 394 | 234 | } | |
| 395 | 235 | ||
| 396 | - | // runBrokerStatusCommand runs the hidden status probe, bounded by a signal-aware | |
| 397 | - | // parent context. | |
| 398 | - | func runBrokerStatusCommand(ctx context.Context, options brokerStatusOptions) error { | |
| 399 | - | ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | |
| 400 | - | defer stop() | |
| 401 | - | return runBrokerStatus(ctx, options, defaultBrokerStatusDeps()) | |
| 402 | - | } | |
| 403 | - | ||
| 404 | - | // runBrokerStatus renders the broker status as one bounded JSON document. | |
| 405 | - | // | |
| 406 | - | // Without --ensure it is dial-only: it performs one probe bounded by | |
| 407 | - | // --timeout and always succeeds, reporting ready or offline, so the JSON is the | |
| 408 | - | // sole result and a caller never has to interpret an exit code to read a state. | |
| 409 | - | // The timeout is the single absolute probe bound; it is never silently replaced | |
| 410 | - | // by the ensure probe budget, so a caller can tighten it (or rely on the | |
| 411 | - | // default) and observe the same bound. With --ensure it uses one absolute | |
| 412 | - | // overall deadline; on success it prints the ready report and | |
| 413 | - | // returns nil, and on any layout, configuration, or startup failure it still | |
| 414 | - | // prints the offline report but returns a code-3 error so the caller always | |
| 415 | - | // observes one offline JSON document and a distinct exit code. | |
| 416 | - | func runBrokerStatus(ctx context.Context, options brokerStatusOptions, deps brokerStatusDeps) error { | |
| 417 | - | layout, err := offlineLayout(options.offlineRoot) | |
| 418 | - | if err != nil { | |
| 419 | - | if options.ensure { | |
| 420 | - | return ensureOfflineFailure(deps.stdout, brokerStatusReport{Status: "offline"}, err) | |
| 421 | - | } | |
| 422 | - | return err | |
| 423 | - | } | |
| 424 | - | socketPath := brokeripc.SocketPath(layout.Runtime) | |
| 425 | - | offline := brokerStatusReport{Status: "offline", Endpoint: socketPath} | |
| 426 | - | ||
| 427 | - | if !options.ensure { | |
| 428 | - | probeCtx, cancel := context.WithTimeout(ctx, options.timeout) | |
| 429 | - | defer cancel() | |
| 430 | - | report, err := deps.probe(probeCtx, socketPath) | |
| 431 | - | if err != nil { | |
| 432 | - | // Dial-only renders the observable state: absence and an | |
| 433 | - | // incompatible live endpoint both read as offline. | |
| 434 | - | return writeBrokerStatusReport(deps.stdout, offline) | |
| 435 | - | } | |
| 436 | - | return writeBrokerStatusReport(deps.stdout, report) | |
| 437 | - | } | |
| 438 | - | ||
| 439 | - | // Validate and secure the sandbox root exactly as the launcher does before | |
| 440 | - | // the spawn directory is created or any lock is taken, so status --ensure can | |
| 441 | - | // never operate on paths the launcher would refuse. | |
| 442 | - | if err := safedir.EnsurePrivate(layout.Root); err != nil { | |
| 443 | - | return ensureOfflineFailure(deps.stdout, offline, fmt.Errorf("vev: secure broker sandbox root: %w", err)) | |
| 444 | - | } | |
| 445 | - | config, err := brokerconfig.Load(layout) | |
| 446 | - | if err != nil { | |
| 447 | - | return ensureOfflineFailure(deps.stdout, offline, err) | |
| 448 | - | } | |
| 449 | - | grace, graceSet := config.IdleGrace() | |
| 450 | - | ||
| 451 | - | deadline := deps.now().Add(options.timeout) | |
| 452 | - | report, err := ensureBrokerReady(ctx, brokerStatusRequest{ | |
| 453 | - | layout: layout, | |
| 454 | - | socketPath: socketPath, | |
| 455 | - | deadline: deadline, | |
| 456 | - | timeout: options.timeout, | |
| 457 | - | root: options.offlineRoot, | |
| 458 | - | grace: grace, | |
| 459 | - | graceSet: graceSet, | |
| 460 | - | }, deps) | |
| 461 | - | if err != nil { | |
| 462 | - | return ensureOfflineFailure(deps.stdout, offline, err) | |
| 463 | - | } | |
| 464 | - | return writeBrokerStatusReport(deps.stdout, report) | |
| 465 | - | } | |
| 466 | - | ||
| 467 | - | // ensureOfflineFailure renders one offline report and returns the ensure failure | |
| 468 | - | // wrapped in exit code 3, so a layout, configuration, or startup failure always | |
| 469 | - | // leaves the caller with the offline JSON document and a distinct exit code. | |
| 470 | - | func ensureOfflineFailure(stdout io.Writer, offline brokerStatusReport, err error) error { | |
| 471 | - | if writeErr := writeBrokerStatusReport(stdout, offline); writeErr != nil { | |
| 472 | - | return errors.Join(err, writeErr) | |
| 473 | - | } | |
| 474 | - | return &exitCoded{code: 3, err: err} | |
| 475 | - | } | |
| 476 | - | ||
| 477 | 236 | // brokerStatusRequest carries one ensure run's fixed inputs. | |
| 478 | 237 | type brokerStatusRequest struct { | |
| 479 | 238 | layout brokerconfig.Layout | |
| @@ -710,18 +469,3 @@ func absentError(socketPath string) error { | |||
| 710 | 469 | func incompatibleError(socketPath string, cause error) error { | |
| 711 | 470 | return fmt.Errorf("%w: %s: %w", errBrokerIncompatible, socketPath, cause) | |
| 712 | 471 | } | |
| 713 | - | ||
| 714 | - | // writeBrokerStatusReport writes exactly one bounded JSON document. | |
| 715 | - | func writeBrokerStatusReport(w io.Writer, report brokerStatusReport) error { | |
| 716 | - | data, err := json.Marshal(report) | |
| 717 | - | if err != nil { | |
| 718 | - | return fmt.Errorf("vev: encode broker status: %w", err) | |
| 719 | - | } | |
| 720 | - | if len(data) > brokerStatusMaxReportBytes { | |
| 721 | - | return errors.New("vev: broker status report exceeds the bound") | |
| 722 | - | } | |
| 723 | - | if _, err := w.Write(append(data, '\n')); err != nil { | |
| 724 | - | return fmt.Errorf("vev: write broker status: %w", err) | |
| 725 | - | } | |
| 726 | - | return nil | |
| 727 | - | } | |
| @@ -3,7 +3,6 @@ package app | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | - | "encoding/json" | |||
| 7 | "errors" | 6 | "errors" | ||
| 8 | "fmt" | 7 | "fmt" | ||
| 9 | "io" | 8 | "io" | ||
| @@ -11,9 +10,7 @@ import ( | |||||
| 11 | "net" | 10 | "net" | ||
| 12 | "os" | 11 | "os" | ||
| 13 | "os/exec" | 12 | "os/exec" | ||
| 14 | - | "os/signal" | |||
| 15 | "path/filepath" | 13 | "path/filepath" | ||
| 16 | - | "strings" | |||
| 17 | "syscall" | 14 | "syscall" | ||
| 18 | "time" | 15 | "time" | ||
| 19 | 16 | ||||
| @@ -57,14 +54,9 @@ import ( | |||||
| 57 | // absolute overall deadline expires. A live-but-incompatible endpoint and a | 54 | // absolute overall deadline expires. A live-but-incompatible endpoint and a | ||
| 58 | // stalled handshake are diagnosed without spawning. | 55 | // stalled handshake are diagnosed without spawning. | ||
| 59 | 56 | ||||
| 60 | - | const ( | 57 | + | // productionBrokerLauncherCommand is the hidden intermediate that starts one |
| 61 | - | // brokerLauncherCommand is the hidden intermediate that starts one detached | 58 | + | // detached broker serve process and exits. |
| 62 | - | // `_broker-serve` and exits. | 59 | + | const productionBrokerLauncherCommand = "_broker-production-launcher" |
| 63 | - | brokerLauncherCommand = "_broker-launcher" | |||
| 64 | - | productionBrokerLauncherCommand = "_broker-production-launcher" | |||
| 65 | - | // brokerStatusCommand is the hidden broker status probe. | |||
| 66 | - | brokerStatusCommand = "_broker-status" | |||
| 67 | - | ) | |||
| 68 | 60 | ||||
| 69 | // Bounds for the detached connect-or-spawn path. Every wait is bounded by the | 61 | // Bounds for the detached connect-or-spawn path. Every wait is bounded by the | ||
| 70 | // one absolute overall deadline, and the first dial is bounded on its own so a | 62 | // one absolute overall deadline, and the first dial is bounded on its own so a | ||
| @@ -74,7 +66,6 @@ const ( | |||||
| 74 | brokerStatusProbeBudget = 2 * time.Second | 66 | brokerStatusProbeBudget = 2 * time.Second | ||
| 75 | brokerStatusInitialBackoff = 5 * time.Millisecond | 67 | brokerStatusInitialBackoff = 5 * time.Millisecond | ||
| 76 | brokerStatusMaxBackoff = 50 * time.Millisecond | 68 | brokerStatusMaxBackoff = 50 * time.Millisecond | ||
| 77 | - | brokerStatusMaxReportBytes = 4 << 10 | |||
| 78 | ) | 69 | ) | ||
| 79 | 70 | ||||
| 80 | var ( | 71 | var ( | ||
| @@ -92,19 +83,6 @@ var ( | |||||
| 92 | errBrokerNotReady = errors.New("vev: broker did not become ready") | 83 | errBrokerNotReady = errors.New("vev: broker did not become ready") | ||
| 93 | ) | 84 | ) | ||
| 94 | 85 | ||||
| 95 | - | // brokerLauncherOptions is the parsed `_broker-launcher` invocation. | |||
| 96 | - | type brokerLauncherOptions struct { | |||
| 97 | - | offlineRoot string | |||
| 98 | - | idleGrace time.Duration | |||
| 99 | - | } | |||
| 100 | - | ||||
| 101 | - | // brokerStatusOptions is the parsed `_broker-status` invocation. | |||
| 102 | - | type brokerStatusOptions struct { | |||
| 103 | - | offlineRoot string | |||
| 104 | - | ensure bool | |||
| 105 | - | timeout time.Duration | |||
| 106 | - | } | |||
| 107 | - | ||||
| 108 | // brokerStatusReport is the bounded JSON status document. It carries exactly | 86 | // brokerStatusReport is the bounded JSON status document. It carries exactly | ||
| 109 | // the ready/offline state, the broker endpoint, and the broker's epoch, | 87 | // the ready/offline state, the broker endpoint, and the broker's epoch, | ||
| 110 | // revision, and host count. Epoch, revision, and host count are zero when the | 88 | // revision, and host count. Epoch, revision, and host count are zero when the | ||
| @@ -154,14 +132,14 @@ func defaultBrokerStatusDeps() brokerStatusDeps { | |||||
| 154 | } | 132 | } | ||
| 155 | return false, owner.Release() | 133 | return false, owner.Release() | ||
| 156 | }, | 134 | }, | ||
| 157 | - | spawn: spawnBrokerLauncher, | 135 | + | spawn: func(ctx context.Context, _ string, _ time.Duration, _ bool) error { |
| 136 | + | return spawnProductionBrokerLauncher(ctx) | |||
| 137 | + | }, | |||
| 158 | stdout: os.Stdout, | 138 | stdout: os.Stdout, | ||
| 159 | } | 139 | } | ||
| 160 | } | 140 | } | ||
| 161 | 141 | ||||
| 162 | - | // parseBrokerLauncherArgs strictly parses | 142 | + | // parseProductionBrokerLauncherArgs parses the argument-free hidden launcher entry. |
| 163 | - | // `_broker-launcher --offline-root ABS [--idle-grace DURATION]`. Unknown flags, | |||
| 164 | - | // duplicate flags, positionals, and non-positive durations are refused. | |||
| 165 | func parseProductionBrokerLauncherArgs(args []string) (command, error) { | 143 | func parseProductionBrokerLauncherArgs(args []string) (command, error) { | ||
| 166 | if len(args) != 0 { | 144 | if len(args) != 0 { | ||
| 167 | return command{}, usagef("`%s` does not accept arguments", productionBrokerLauncherCommand) | 145 | return command{}, usagef("`%s` does not accept arguments", productionBrokerLauncherCommand) | ||
| @@ -169,141 +147,11 @@ func parseProductionBrokerLauncherArgs(args []string) (command, error) { | |||||
| 169 | return command{kind: kindProductionBrokerLauncher}, nil | 147 | return command{kind: kindProductionBrokerLauncher}, nil | ||
| 170 | } | 148 | } | ||
| 171 | 149 | ||||
| 172 | - | func parseBrokerLauncherArgs(args []string) (command, error) { | 150 | + | // runProductionBrokerLauncherCommand starts one detached broker serve process. |
| 173 | - | var options brokerLauncherOptions | |||
| 174 | - | var seenRoot, seenGrace bool | |||
| 175 | - | for index := 0; index < len(args); index++ { | |||
| 176 | - | switch args[index] { | |||
| 177 | - | case "--offline-root": | |||
| 178 | - | if seenRoot { | |||
| 179 | - | return command{}, usagef("`%s` received duplicate --offline-root", brokerLauncherCommand) | |||
| 180 | - | } | |||
| 181 | - | if index+1 >= len(args) || args[index+1] == "" { | |||
| 182 | - | return command{}, usagef("`--offline-root` requires a path") | |||
| 183 | - | } | |||
| 184 | - | options.offlineRoot = args[index+1] | |||
| 185 | - | seenRoot = true | |||
| 186 | - | index++ | |||
| 187 | - | case "--idle-grace": | |||
| 188 | - | if seenGrace { | |||
| 189 | - | return command{}, usagef("`%s` received duplicate --idle-grace", brokerLauncherCommand) | |||
| 190 | - | } | |||
| 191 | - | if index+1 >= len(args) || args[index+1] == "" { | |||
| 192 | - | return command{}, usagef("`--idle-grace` requires a duration") | |||
| 193 | - | } | |||
| 194 | - | grace, err := time.ParseDuration(args[index+1]) | |||
| 195 | - | if err != nil { | |||
| 196 | - | return command{}, usagef("`--idle-grace` %q is not a duration", args[index+1]) | |||
| 197 | - | } | |||
| 198 | - | if grace <= 0 { | |||
| 199 | - | return command{}, usagef("`--idle-grace` must be positive") | |||
| 200 | - | } | |||
| 201 | - | options.idleGrace = grace | |||
| 202 | - | seenGrace = true | |||
| 203 | - | index++ | |||
| 204 | - | default: | |||
| 205 | - | if strings.HasPrefix(args[index], "-") { | |||
| 206 | - | return command{}, usagef("unknown flag %q for `%s`", args[index], brokerLauncherCommand) | |||
| 207 | - | } | |||
| 208 | - | return command{}, usagef("`%s` does not accept positional arguments", brokerLauncherCommand) | |||
| 209 | - | } | |||
| 210 | - | } | |||
| 211 | - | if options.offlineRoot == "" { | |||
| 212 | - | return command{}, usagef("`%s` requires --offline-root", brokerLauncherCommand) | |||
| 213 | - | } | |||
| 214 | - | return command{kind: kindBrokerLauncher, brokerLauncher: options}, nil | |||
| 215 | - | } | |||
| 216 | - | ||||
| 217 | - | // parseBrokerStatusArgs strictly parses | |||
| 218 | - | // `_broker-status --offline-root ABS [--ensure] [--timeout DURATION]`. Unknown | |||
| 219 | - | // flags, duplicate flags, positionals, and non-positive timeouts are refused. | |||
| 220 | - | func parseBrokerStatusArgs(args []string) (command, error) { | |||
| 221 | - | options := brokerStatusOptions{timeout: brokerStatusDefaultTimeout} | |||
| 222 | - | var seenRoot, seenEnsure, seenTimeout bool | |||
| 223 | - | for index := 0; index < len(args); index++ { | |||
| 224 | - | switch args[index] { | |||
| 225 | - | case "--offline-root": | |||
| 226 | - | if seenRoot { | |||
| 227 | - | return command{}, usagef("`%s` received duplicate --offline-root", brokerStatusCommand) | |||
| 228 | - | } | |||
| 229 | - | if index+1 >= len(args) || args[index+1] == "" { | |||
| 230 | - | return command{}, usagef("`--offline-root` requires a path") | |||
| 231 | - | } | |||
| 232 | - | options.offlineRoot = args[index+1] | |||
| 233 | - | seenRoot = true | |||
| 234 | - | index++ | |||
| 235 | - | case "--ensure": | |||
| 236 | - | if seenEnsure { | |||
| 237 | - | return command{}, usagef("`%s` received duplicate --ensure", brokerStatusCommand) | |||
| 238 | - | } | |||
| 239 | - | options.ensure = true | |||
| 240 | - | seenEnsure = true | |||
| 241 | - | case "--timeout": | |||
| 242 | - | if seenTimeout { | |||
| 243 | - | return command{}, usagef("`%s` received duplicate --timeout", brokerStatusCommand) | |||
| 244 | - | } | |||
| 245 | - | if index+1 >= len(args) || args[index+1] == "" { | |||
| 246 | - | return command{}, usagef("`--timeout` requires a duration") | |||
| 247 | - | } | |||
| 248 | - | timeout, err := time.ParseDuration(args[index+1]) | |||
| 249 | - | if err != nil { | |||
| 250 | - | return command{}, usagef("`--timeout` %q is not a duration", args[index+1]) | |||
| 251 | - | } | |||
| 252 | - | if timeout <= 0 { | |||
| 253 | - | return command{}, usagef("`--timeout` must be positive") | |||
| 254 | - | } | |||
| 255 | - | options.timeout = timeout | |||
| 256 | - | seenTimeout = true | |||
| 257 | - | index++ | |||
| 258 | - | default: | |||
| 259 | - | if strings.HasPrefix(args[index], "-") { | |||
| 260 | - | return command{}, usagef("unknown flag %q for `%s`", args[index], brokerStatusCommand) | |||
| 261 | - | } | |||
| 262 | - | return command{}, usagef("`%s` does not accept positional arguments", brokerStatusCommand) | |||
| 263 | - | } | |||
| 264 | - | } | |||
| 265 | - | if options.offlineRoot == "" { | |||
| 266 | - | return command{}, usagef("`%s` requires --offline-root", brokerStatusCommand) | |||
| 267 | - | } | |||
| 268 | - | return command{kind: kindBrokerStatus, brokerStatus: options}, nil | |||
| 269 | - | } | |||
| 270 | - | ||||
| 271 | - | // runBrokerLauncherCommand runs the hidden launcher. It changes no ordinary | |||
| 272 | - | // command and never runs in production flows. | |||
| 273 | func runProductionBrokerLauncherCommand(context.Context) error { | 151 | func runProductionBrokerLauncherCommand(context.Context) error { | ||
| 274 | return startDetachedBrokerServe([]string{productionBrokerServeCommand}, filepath.Join(productionBrokerLayout().Log, "vev-broker-crash.log")) | 152 | return startDetachedBrokerServe([]string{productionBrokerServeCommand}, filepath.Join(productionBrokerLayout().Log, "vev-broker-crash.log")) | ||
| 275 | } | 153 | } | ||
| 276 | 154 | ||||
| 277 | - | func runBrokerLauncherCommand(_ context.Context, options brokerLauncherOptions) error { | |||
| 278 | - | return runBrokerLauncher(options) | |||
| 279 | - | } | |||
| 280 | - | ||||
| 281 | - | // runBrokerLauncher validates the sandbox configuration, diagnoses an explicit | |||
| 282 | - | // idle grace that conflicts with the provisioned one, then starts one detached | |||
| 283 | - | // `_broker-serve` in a new session and returns. | |||
| 284 | - | func runBrokerLauncher(options brokerLauncherOptions) error { | |||
| 285 | - | layout, err := offlineLayout(options.offlineRoot) | |||
| 286 | - | if err != nil { | |||
| 287 | - | return err | |||
| 288 | - | } | |||
| 289 | - | if err := safedir.EnsurePrivate(layout.Root); err != nil { | |||
| 290 | - | return fmt.Errorf("vev: secure broker sandbox root: %w", err) | |||
| 291 | - | } | |||
| 292 | - | config, err := brokerconfig.Load(layout) | |||
| 293 | - | if err != nil { | |||
| 294 | - | return err | |||
| 295 | - | } | |||
| 296 | - | grace, err := effectiveIdleGrace(config, options.idleGrace) | |||
| 297 | - | if err != nil { | |||
| 298 | - | return err | |||
| 299 | - | } | |||
| 300 | - | args := []string{brokerServeCommand, "--offline-root", options.offlineRoot} | |||
| 301 | - | if grace > 0 { | |||
| 302 | - | args = append(args, "--idle-grace", grace.String()) | |||
| 303 | - | } | |||
| 304 | - | return startDetachedBrokerServe(args, filepath.Join(layout.Log, "vev-broker-crash.log")) | |||
| 305 | - | } | |||
| 306 | - | ||||
| 307 | // startDetachedBrokerServe starts one `_broker-serve` in a new session and | 155 | // startDetachedBrokerServe starts one `_broker-serve` in a new session and | ||
| 308 | // releases it, so the broker outlives the short-lived launcher. It mirrors the | 156 | // releases it, so the broker outlives the short-lived launcher. It mirrors the | ||
| 309 | // daemon launcher's double-fork boundary: no ambient variable is dropped beyond | 157 | // daemon launcher's double-fork boundary: no ambient variable is dropped beyond | ||
| @@ -358,14 +206,6 @@ func spawnProductionBrokerLauncher(ctx context.Context) error { | |||||
| 358 | return runBrokerLauncherProcess(ctx, []string{productionBrokerLauncherCommand}) | 206 | return runBrokerLauncherProcess(ctx, []string{productionBrokerLauncherCommand}) | ||
| 359 | } | 207 | } | ||
| 360 | 208 | ||||
| 361 | - | func spawnBrokerLauncher(ctx context.Context, root string, grace time.Duration, graceSet bool) error { | |||
| 362 | - | args := []string{brokerLauncherCommand, "--offline-root", root} | |||
| 363 | - | if graceSet { | |||
| 364 | - | args = append(args, "--idle-grace", grace.String()) | |||
| 365 | - | } | |||
| 366 | - | return runBrokerLauncherProcess(ctx, args) | |||
| 367 | - | } | |||
| 368 | - | ||||
| 369 | func runBrokerLauncherProcess(ctx context.Context, args []string) error { | 209 | func runBrokerLauncherProcess(ctx context.Context, args []string) error { | ||
| 370 | exePath, err := selfExePath() | 210 | exePath, err := selfExePath() | ||
| 371 | if err != nil { | 211 | if err != nil { | ||
| @@ -393,87 +233,6 @@ func runBrokerLauncherProcess(ctx context.Context, args []string) error { | |||||
| 393 | return nil | 233 | return nil | ||
| 394 | } | 234 | } | ||
| 395 | 235 | ||||
| 396 | - | // runBrokerStatusCommand runs the hidden status probe, bounded by a signal-aware | |||
| 397 | - | // parent context. | |||
| 398 | - | func runBrokerStatusCommand(ctx context.Context, options brokerStatusOptions) error { | |||
| 399 | - | ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | |||
| 400 | - | defer stop() | |||
| 401 | - | return runBrokerStatus(ctx, options, defaultBrokerStatusDeps()) | |||
| 402 | - | } | |||
| 403 | - | ||||
| 404 | - | // runBrokerStatus renders the broker status as one bounded JSON document. | |||
| 405 | - | // | |||
| 406 | - | // Without --ensure it is dial-only: it performs one probe bounded by | |||
| 407 | - | // --timeout and always succeeds, reporting ready or offline, so the JSON is the | |||
| 408 | - | // sole result and a caller never has to interpret an exit code to read a state. | |||
| 409 | - | // The timeout is the single absolute probe bound; it is never silently replaced | |||
| 410 | - | // by the ensure probe budget, so a caller can tighten it (or rely on the | |||
| 411 | - | // default) and observe the same bound. With --ensure it uses one absolute | |||
| 412 | - | // overall deadline; on success it prints the ready report and | |||
| 413 | - | // returns nil, and on any layout, configuration, or startup failure it still | |||
| 414 | - | // prints the offline report but returns a code-3 error so the caller always | |||
| 415 | - | // observes one offline JSON document and a distinct exit code. | |||
| 416 | - | func runBrokerStatus(ctx context.Context, options brokerStatusOptions, deps brokerStatusDeps) error { | |||
| 417 | - | layout, err := offlineLayout(options.offlineRoot) | |||
| 418 | - | if err != nil { | |||
| 419 | - | if options.ensure { | |||
| 420 | - | return ensureOfflineFailure(deps.stdout, brokerStatusReport{Status: "offline"}, err) | |||
| 421 | - | } | |||
| 422 | - | return err | |||
| 423 | - | } | |||
| 424 | - | socketPath := brokeripc.SocketPath(layout.Runtime) | |||
| 425 | - | offline := brokerStatusReport{Status: "offline", Endpoint: socketPath} | |||
| 426 | - | ||||
| 427 | - | if !options.ensure { | |||
| 428 | - | probeCtx, cancel := context.WithTimeout(ctx, options.timeout) | |||
| 429 | - | defer cancel() | |||
| 430 | - | report, err := deps.probe(probeCtx, socketPath) | |||
| 431 | - | if err != nil { | |||
| 432 | - | // Dial-only renders the observable state: absence and an | |||
| 433 | - | // incompatible live endpoint both read as offline. | |||
| 434 | - | return writeBrokerStatusReport(deps.stdout, offline) | |||
| 435 | - | } | |||
| 436 | - | return writeBrokerStatusReport(deps.stdout, report) | |||
| 437 | - | } | |||
| 438 | - | ||||
| 439 | - | // Validate and secure the sandbox root exactly as the launcher does before | |||
| 440 | - | // the spawn directory is created or any lock is taken, so status --ensure can | |||
| 441 | - | // never operate on paths the launcher would refuse. | |||
| 442 | - | if err := safedir.EnsurePrivate(layout.Root); err != nil { | |||
| 443 | - | return ensureOfflineFailure(deps.stdout, offline, fmt.Errorf("vev: secure broker sandbox root: %w", err)) | |||
| 444 | - | } | |||
| 445 | - | config, err := brokerconfig.Load(layout) | |||
| 446 | - | if err != nil { | |||
| 447 | - | return ensureOfflineFailure(deps.stdout, offline, err) | |||
| 448 | - | } | |||
| 449 | - | grace, graceSet := config.IdleGrace() | |||
| 450 | - | ||||
| 451 | - | deadline := deps.now().Add(options.timeout) | |||
| 452 | - | report, err := ensureBrokerReady(ctx, brokerStatusRequest{ | |||
| 453 | - | layout: layout, | |||
| 454 | - | socketPath: socketPath, | |||
| 455 | - | deadline: deadline, | |||
| 456 | - | timeout: options.timeout, | |||
| 457 | - | root: options.offlineRoot, | |||
| 458 | - | grace: grace, | |||
| 459 | - | graceSet: graceSet, | |||
| 460 | - | }, deps) | |||
| 461 | - | if err != nil { | |||
| 462 | - | return ensureOfflineFailure(deps.stdout, offline, err) | |||
| 463 | - | } | |||
| 464 | - | return writeBrokerStatusReport(deps.stdout, report) | |||
| 465 | - | } | |||
| 466 | - | ||||
| 467 | - | // ensureOfflineFailure renders one offline report and returns the ensure failure | |||
| 468 | - | // wrapped in exit code 3, so a layout, configuration, or startup failure always | |||
| 469 | - | // leaves the caller with the offline JSON document and a distinct exit code. | |||
| 470 | - | func ensureOfflineFailure(stdout io.Writer, offline brokerStatusReport, err error) error { | |||
| 471 | - | if writeErr := writeBrokerStatusReport(stdout, offline); writeErr != nil { | |||
| 472 | - | return errors.Join(err, writeErr) | |||
| 473 | - | } | |||
| 474 | - | return &exitCoded{code: 3, err: err} | |||
| 475 | - | } | |||
| 476 | - | ||||
| 477 | // brokerStatusRequest carries one ensure run's fixed inputs. | 236 | // brokerStatusRequest carries one ensure run's fixed inputs. | ||
| 478 | type brokerStatusRequest struct { | 237 | type brokerStatusRequest struct { | ||
| 479 | layout brokerconfig.Layout | 238 | layout brokerconfig.Layout | ||
| @@ -710,18 +469,3 @@ func absentError(socketPath string) error { | |||||
| 710 | func incompatibleError(socketPath string, cause error) error { | 469 | func incompatibleError(socketPath string, cause error) error { | ||
| 711 | return fmt.Errorf("%w: %s: %w", errBrokerIncompatible, socketPath, cause) | 470 | return fmt.Errorf("%w: %s: %w", errBrokerIncompatible, socketPath, cause) | ||
| 712 | } | 471 | } | ||
| 713 | - | ||||
| 714 | - | // writeBrokerStatusReport writes exactly one bounded JSON document. | |||
| 715 | - | func writeBrokerStatusReport(w io.Writer, report brokerStatusReport) error { | |||
| 716 | - | data, err := json.Marshal(report) | |||
| 717 | - | if err != nil { | |||
| 718 | - | return fmt.Errorf("vev: encode broker status: %w", err) | |||
| 719 | - | } | |||
| 720 | - | if len(data) > brokerStatusMaxReportBytes { | |||
| 721 | - | return errors.New("vev: broker status report exceeds the bound") | |||
| 722 | - | } | |||
| 723 | - | if _, err := w.Write(append(data, '\n')); err != nil { | |||
| 724 | - | return fmt.Errorf("vev: write broker status: %w", err) | |||
| 725 | - | } | |||
| 726 | - | return nil | |||
| 727 | - | } | |||
M internal/app/broker_offline_status_test.go +2 -14
| @@ -7,7 +7,6 @@ import ( | |||
| 7 | 7 | "net" | |
| 8 | 8 | "os" | |
| 9 | 9 | "path/filepath" | |
| 10 | - | "strings" | |
| 11 | 10 | "sync/atomic" | |
| 12 | 11 | "testing" | |
| 13 | 12 | "time" | |
| @@ -119,17 +118,6 @@ func (r *statusRecorder) setProbe(deps *brokerStatusDeps, probe func(attempt int | |||
| 119 | 118 | } | |
| 120 | 119 | } | |
| 121 | 120 | ||
| 122 | - | // decodeOneReport remains shared with the subprocess broker tests until those | |
| 123 | - | // are converted to the production launcher. | |
| 124 | - | func decodeOneReport(t *testing.T, raw string) brokerStatusReport { | |
| 125 | - | t.Helper() | |
| 126 | - | trimmed := strings.TrimSpace(raw) | |
| 127 | - | require.Equal(t, 1, strings.Count(trimmed, "\n")+1) | |
| 128 | - | var report brokerStatusReport | |
| 129 | - | require.NoError(t, json.Unmarshal([]byte(trimmed), &report)) | |
| 130 | - | return report | |
| 131 | - | } | |
| 132 | - | ||
| 133 | 121 | func TestEffectiveIdleGrace(t *testing.T) { | |
| 134 | 122 | layout := emptyProductionBrokerLayout(t, "90s") | |
| 135 | 123 | config, err := brokerconfig.LoadProduction(layout, productionBrokerConfigPath(), "", localDaemonPolicy(), daemonmux.SocketPath(ipc.SocketDir())) | |
| @@ -170,7 +158,7 @@ func TestBrokerServeRefusesConflictingIdleGrace(t *testing.T) { | |||
| 170 | 158 | ctx, cancel := context.WithCancel(context.Background()) | |
| 171 | 159 | defer cancel() | |
| 172 | 160 | deps, _ := testBrokerServeDeps(newSandboxClock()) | |
| 173 | - | err := runBrokerServe(ctx, brokerServeOptions{production: true, idleGrace: time.Minute}, deps) | |
| 161 | + | err := runBrokerServe(ctx, brokerServeOptions{idleGrace: time.Minute}, deps) | |
| 174 | 162 | require.Error(t, err) | |
| 175 | 163 | require.Contains(t, err.Error(), "conflicts with the provisioned idle grace") | |
| 176 | 164 | require.NoFileExists(t, filepath.Join(layout.Runtime, "lifecycle.lock"), "a refused serve must not take the lifetime lock") | |
| @@ -310,7 +298,7 @@ func TestBrokerEnsureProductionConfigFailure(t *testing.T) { | |||
| 310 | 298 | layout := emptyProductionBrokerLayout(t, "") | |
| 311 | 299 | require.NoError(t, os.WriteFile(productionBrokerConfigPath(), []byte("not json"), 0o600)) | |
| 312 | 300 | deps, _ := testBrokerServeDeps(newSandboxClock()) | |
| 313 | - | err := runBrokerServe(context.Background(), brokerServeOptions{production: true}, deps) | |
| 301 | + | err := runBrokerServe(context.Background(), brokerServeOptions{}, deps) | |
| 314 | 302 | require.Error(t, err) | |
| 315 | 303 | require.NoFileExists(t, filepath.Join(layout.Runtime, "lifecycle.lock")) | |
| 316 | 304 | } | |
| @@ -7,7 +7,6 @@ import ( | |||||
| 7 | "net" | 7 | "net" | ||
| 8 | "os" | 8 | "os" | ||
| 9 | "path/filepath" | 9 | "path/filepath" | ||
| 10 | - | "strings" | |||
| 11 | "sync/atomic" | 10 | "sync/atomic" | ||
| 12 | "testing" | 11 | "testing" | ||
| 13 | "time" | 12 | "time" | ||
| @@ -119,17 +118,6 @@ func (r *statusRecorder) setProbe(deps *brokerStatusDeps, probe func(attempt int | |||||
| 119 | } | 118 | } | ||
| 120 | } | 119 | } | ||
| 121 | 120 | ||||
| 122 | - | // decodeOneReport remains shared with the subprocess broker tests until those | |||
| 123 | - | // are converted to the production launcher. | |||
| 124 | - | func decodeOneReport(t *testing.T, raw string) brokerStatusReport { | |||
| 125 | - | t.Helper() | |||
| 126 | - | trimmed := strings.TrimSpace(raw) | |||
| 127 | - | require.Equal(t, 1, strings.Count(trimmed, "\n")+1) | |||
| 128 | - | var report brokerStatusReport | |||
| 129 | - | require.NoError(t, json.Unmarshal([]byte(trimmed), &report)) | |||
| 130 | - | return report | |||
| 131 | - | } | |||
| 132 | - | ||||
| 133 | func TestEffectiveIdleGrace(t *testing.T) { | 121 | func TestEffectiveIdleGrace(t *testing.T) { | ||
| 134 | layout := emptyProductionBrokerLayout(t, "90s") | 122 | layout := emptyProductionBrokerLayout(t, "90s") | ||
| 135 | config, err := brokerconfig.LoadProduction(layout, productionBrokerConfigPath(), "", localDaemonPolicy(), daemonmux.SocketPath(ipc.SocketDir())) | 123 | config, err := brokerconfig.LoadProduction(layout, productionBrokerConfigPath(), "", localDaemonPolicy(), daemonmux.SocketPath(ipc.SocketDir())) | ||
| @@ -170,7 +158,7 @@ func TestBrokerServeRefusesConflictingIdleGrace(t *testing.T) { | |||||
| 170 | ctx, cancel := context.WithCancel(context.Background()) | 158 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 171 | defer cancel() | 159 | defer cancel() | ||
| 172 | deps, _ := testBrokerServeDeps(newSandboxClock()) | 160 | deps, _ := testBrokerServeDeps(newSandboxClock()) | ||
| 173 | - | err := runBrokerServe(ctx, brokerServeOptions{production: true, idleGrace: time.Minute}, deps) | 161 | + | err := runBrokerServe(ctx, brokerServeOptions{idleGrace: time.Minute}, deps) |
| 174 | require.Error(t, err) | 162 | require.Error(t, err) | ||
| 175 | require.Contains(t, err.Error(), "conflicts with the provisioned idle grace") | 163 | require.Contains(t, err.Error(), "conflicts with the provisioned idle grace") | ||
| 176 | require.NoFileExists(t, filepath.Join(layout.Runtime, "lifecycle.lock"), "a refused serve must not take the lifetime lock") | 164 | require.NoFileExists(t, filepath.Join(layout.Runtime, "lifecycle.lock"), "a refused serve must not take the lifetime lock") | ||
| @@ -310,7 +298,7 @@ func TestBrokerEnsureProductionConfigFailure(t *testing.T) { | |||||
| 310 | layout := emptyProductionBrokerLayout(t, "") | 298 | layout := emptyProductionBrokerLayout(t, "") | ||
| 311 | require.NoError(t, os.WriteFile(productionBrokerConfigPath(), []byte("not json"), 0o600)) | 299 | require.NoError(t, os.WriteFile(productionBrokerConfigPath(), []byte("not json"), 0o600)) | ||
| 312 | deps, _ := testBrokerServeDeps(newSandboxClock()) | 300 | deps, _ := testBrokerServeDeps(newSandboxClock()) | ||
| 313 | - | err := runBrokerServe(context.Background(), brokerServeOptions{production: true}, deps) | 301 | + | err := runBrokerServe(context.Background(), brokerServeOptions{}, deps) |
| 314 | require.Error(t, err) | 302 | require.Error(t, err) | ||
| 315 | require.NoFileExists(t, filepath.Join(layout.Runtime, "lifecycle.lock")) | 303 | require.NoFileExists(t, filepath.Join(layout.Runtime, "lifecycle.lock")) | ||
| 316 | } | 304 | } | ||
M internal/app/broker_offline_test.go +7 -26
| @@ -86,14 +86,6 @@ func sandboxRegistrationDocument(route any) map[string]any { | |||
| 86 | 86 | } | |
| 87 | 87 | } | |
| 88 | 88 | ||
| 89 | - | // writeSandboxConfig marshals one document into root/config.json owner-only. | |
| 90 | - | func writeSandboxConfig(t *testing.T, root string, document any) { | |
| 91 | - | t.Helper() | |
| 92 | - | raw, err := json.Marshal(document) | |
| 93 | - | require.NoError(t, err) | |
| 94 | - | require.NoError(t, os.WriteFile(filepath.Join(root, brokerconfig.ConfigFileName), raw, 0o600)) | |
| 95 | - | } | |
| 96 | - | ||
| 97 | 89 | // isolateSandboxEnv redirects production XDG roots into private temporary | |
| 98 | 90 | // directories and returns one fresh offline root beside them, so the sandbox | |
| 99 | 91 | // can never touch the real runtime or state paths. | |
| @@ -109,17 +101,6 @@ func isolateSandboxEnv(t *testing.T) (root, prodRuntime, prodState string) { | |||
| 109 | 101 | return root, prodRuntime, prodState | |
| 110 | 102 | } | |
| 111 | 103 | ||
| 112 | - | // requireProductionUntouched asserts no production runtime or state entry was | |
| 113 | - | // created. | |
| 114 | - | func requireProductionUntouched(t *testing.T, prodRuntime, prodState string) { | |
| 115 | - | t.Helper() | |
| 116 | - | for _, dir := range []string{prodRuntime, prodState} { | |
| 117 | - | entries, err := os.ReadDir(dir) | |
| 118 | - | require.NoError(t, err) | |
| 119 | - | require.Empty(t, entries, "production directory %s was touched", dir) | |
| 120 | - | } | |
| 121 | - | } | |
| 122 | - | ||
| 123 | 104 | // testBrokerServeDeps builds sandbox deps with a deterministic clock and a | |
| 124 | 105 | // discard logger, and reports readiness on the returned channel. | |
| 125 | 106 | func testBrokerServeDeps(clk ports.Clock) (brokerServeDeps, <-chan string) { | |
| @@ -219,7 +200,7 @@ func TestProductionBrokerServeRejectsUnsafeRoot(t *testing.T) { | |||
| 219 | 200 | layout := emptyProductionBrokerLayout(t, "") | |
| 220 | 201 | require.NoError(t, os.MkdirAll(layout.Root, 0o755)) | |
| 221 | 202 | deps, _ := testBrokerServeDeps(newSandboxClock()) | |
| 222 | - | err := runBrokerServe(context.Background(), brokerServeOptions{production: true}, deps) | |
| 203 | + | err := runBrokerServe(context.Background(), brokerServeOptions{}, deps) | |
| 223 | 204 | require.ErrorContains(t, err, "0755") | |
| 224 | 205 | require.NoFileExists(t, filepath.Join(layout.Runtime, "lifecycle.lock")) | |
| 225 | 206 | } | |
| @@ -234,7 +215,7 @@ func TestBrokerServeRoundTrip(t *testing.T) { | |||
| 234 | 215 | deps, ready := testBrokerServeDeps(clk) | |
| 235 | 216 | ctx, cancel := context.WithCancel(context.Background()) | |
| 236 | 217 | defer cancel() | |
| 237 | - | done := runSandbox(ctx, brokerServeOptions{production: true}, deps) | |
| 218 | + | done := runSandbox(ctx, brokerServeOptions{}, deps) | |
| 238 | 219 | ||
| 239 | 220 | socketPath := awaitSandboxReady(t, ready, done) | |
| 240 | 221 | ||
| @@ -340,7 +321,7 @@ func TestBrokerServeIdleShutdownOnEmptyConfig(t *testing.T) { | |||
| 340 | 321 | deps, ready := testBrokerServeDeps(clk) | |
| 341 | 322 | ctx, cancel := context.WithCancel(context.Background()) | |
| 342 | 323 | defer cancel() | |
| 343 | - | done := runSandbox(ctx, brokerServeOptions{production: true}, deps) | |
| 324 | + | done := runSandbox(ctx, brokerServeOptions{}, deps) | |
| 344 | 325 | ||
| 345 | 326 | socketPath := awaitSandboxReady(t, ready, done) | |
| 346 | 327 | waitForSandboxShutdown(t, clk, done) | |
| @@ -365,7 +346,7 @@ func TestBrokerServeCancelCleanup(t *testing.T) { | |||
| 365 | 346 | deps, ready := testBrokerServeDeps(clk) | |
| 366 | 347 | ctx, cancel := context.WithCancel(context.Background()) | |
| 367 | 348 | defer cancel() | |
| 368 | - | done := runSandbox(ctx, brokerServeOptions{production: true}, deps) | |
| 349 | + | done := runSandbox(ctx, brokerServeOptions{}, deps) | |
| 369 | 350 | ||
| 370 | 351 | socketPath := awaitSandboxReady(t, ready, done) | |
| 371 | 352 | awaitSandboxCancel(t, cancel, done) | |
| @@ -407,7 +388,7 @@ func TestBrokerServeShutsDownOnUnexpectedAcceptFailure(t *testing.T) { | |||
| 407 | 388 | } | |
| 408 | 389 | ctx, cancel := context.WithCancel(context.Background()) | |
| 409 | 390 | defer cancel() | |
| 410 | - | done := runSandbox(ctx, brokerServeOptions{production: true}, deps) | |
| 391 | + | done := runSandbox(ctx, brokerServeOptions{}, deps) | |
| 411 | 392 | ||
| 412 | 393 | awaitSandboxReady(t, ready, done) | |
| 413 | 394 | select { | |
| @@ -428,12 +409,12 @@ func TestBrokerServeRefusesDuplicateOwnership(t *testing.T) { | |||
| 428 | 409 | deps, ready := testBrokerServeDeps(clk) | |
| 429 | 410 | ctx, cancel := context.WithCancel(context.Background()) | |
| 430 | 411 | defer cancel() | |
| 431 | - | done := runSandbox(ctx, brokerServeOptions{production: true}, deps) | |
| 412 | + | done := runSandbox(ctx, brokerServeOptions{}, deps) | |
| 432 | 413 | socketPath := awaitSandboxReady(t, ready, done) | |
| 433 | 414 | ||
| 434 | 415 | // A second foreground owner fails fast on the lifetime lock. | |
| 435 | 416 | secondDeps, _ := testBrokerServeDeps(newSandboxClock()) | |
| 436 | - | err := runBrokerServe(context.Background(), brokerServeOptions{production: true}, secondDeps) | |
| 417 | + | err := runBrokerServe(context.Background(), brokerServeOptions{}, secondDeps) | |
| 437 | 418 | require.ErrorIs(t, err, lifecycle.ErrBusy) | |
| 438 | 419 | ||
| 439 | 420 | // A duplicate listener on the live socket is refused as well. | |
| @@ -86,14 +86,6 @@ func sandboxRegistrationDocument(route any) map[string]any { | |||||
| 86 | } | 86 | } | ||
| 87 | } | 87 | } | ||
| 88 | 88 | ||||
| 89 | - | // writeSandboxConfig marshals one document into root/config.json owner-only. | |||
| 90 | - | func writeSandboxConfig(t *testing.T, root string, document any) { | |||
| 91 | - | t.Helper() | |||
| 92 | - | raw, err := json.Marshal(document) | |||
| 93 | - | require.NoError(t, err) | |||
| 94 | - | require.NoError(t, os.WriteFile(filepath.Join(root, brokerconfig.ConfigFileName), raw, 0o600)) | |||
| 95 | - | } | |||
| 96 | - | ||||
| 97 | // isolateSandboxEnv redirects production XDG roots into private temporary | 89 | // isolateSandboxEnv redirects production XDG roots into private temporary | ||
| 98 | // directories and returns one fresh offline root beside them, so the sandbox | 90 | // directories and returns one fresh offline root beside them, so the sandbox | ||
| 99 | // can never touch the real runtime or state paths. | 91 | // can never touch the real runtime or state paths. | ||
| @@ -109,17 +101,6 @@ func isolateSandboxEnv(t *testing.T) (root, prodRuntime, prodState string) { | |||||
| 109 | return root, prodRuntime, prodState | 101 | return root, prodRuntime, prodState | ||
| 110 | } | 102 | } | ||
| 111 | 103 | ||||
| 112 | - | // requireProductionUntouched asserts no production runtime or state entry was | |||
| 113 | - | // created. | |||
| 114 | - | func requireProductionUntouched(t *testing.T, prodRuntime, prodState string) { | |||
| 115 | - | t.Helper() | |||
| 116 | - | for _, dir := range []string{prodRuntime, prodState} { | |||
| 117 | - | entries, err := os.ReadDir(dir) | |||
| 118 | - | require.NoError(t, err) | |||
| 119 | - | require.Empty(t, entries, "production directory %s was touched", dir) | |||
| 120 | - | } | |||
| 121 | - | } | |||
| 122 | - | ||||
| 123 | // testBrokerServeDeps builds sandbox deps with a deterministic clock and a | 104 | // testBrokerServeDeps builds sandbox deps with a deterministic clock and a | ||
| 124 | // discard logger, and reports readiness on the returned channel. | 105 | // discard logger, and reports readiness on the returned channel. | ||
| 125 | func testBrokerServeDeps(clk ports.Clock) (brokerServeDeps, <-chan string) { | 106 | func testBrokerServeDeps(clk ports.Clock) (brokerServeDeps, <-chan string) { | ||
| @@ -219,7 +200,7 @@ func TestProductionBrokerServeRejectsUnsafeRoot(t *testing.T) { | |||||
| 219 | layout := emptyProductionBrokerLayout(t, "") | 200 | layout := emptyProductionBrokerLayout(t, "") | ||
| 220 | require.NoError(t, os.MkdirAll(layout.Root, 0o755)) | 201 | require.NoError(t, os.MkdirAll(layout.Root, 0o755)) | ||
| 221 | deps, _ := testBrokerServeDeps(newSandboxClock()) | 202 | deps, _ := testBrokerServeDeps(newSandboxClock()) | ||
| 222 | - | err := runBrokerServe(context.Background(), brokerServeOptions{production: true}, deps) | 203 | + | err := runBrokerServe(context.Background(), brokerServeOptions{}, deps) |
| 223 | require.ErrorContains(t, err, "0755") | 204 | require.ErrorContains(t, err, "0755") | ||
| 224 | require.NoFileExists(t, filepath.Join(layout.Runtime, "lifecycle.lock")) | 205 | require.NoFileExists(t, filepath.Join(layout.Runtime, "lifecycle.lock")) | ||
| 225 | } | 206 | } | ||
| @@ -234,7 +215,7 @@ func TestBrokerServeRoundTrip(t *testing.T) { | |||||
| 234 | deps, ready := testBrokerServeDeps(clk) | 215 | deps, ready := testBrokerServeDeps(clk) | ||
| 235 | ctx, cancel := context.WithCancel(context.Background()) | 216 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 236 | defer cancel() | 217 | defer cancel() | ||
| 237 | - | done := runSandbox(ctx, brokerServeOptions{production: true}, deps) | 218 | + | done := runSandbox(ctx, brokerServeOptions{}, deps) |
| 238 | 219 | ||||
| 239 | socketPath := awaitSandboxReady(t, ready, done) | 220 | socketPath := awaitSandboxReady(t, ready, done) | ||
| 240 | 221 | ||||
| @@ -340,7 +321,7 @@ func TestBrokerServeIdleShutdownOnEmptyConfig(t *testing.T) { | |||||
| 340 | deps, ready := testBrokerServeDeps(clk) | 321 | deps, ready := testBrokerServeDeps(clk) | ||
| 341 | ctx, cancel := context.WithCancel(context.Background()) | 322 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 342 | defer cancel() | 323 | defer cancel() | ||
| 343 | - | done := runSandbox(ctx, brokerServeOptions{production: true}, deps) | 324 | + | done := runSandbox(ctx, brokerServeOptions{}, deps) |
| 344 | 325 | ||||
| 345 | socketPath := awaitSandboxReady(t, ready, done) | 326 | socketPath := awaitSandboxReady(t, ready, done) | ||
| 346 | waitForSandboxShutdown(t, clk, done) | 327 | waitForSandboxShutdown(t, clk, done) | ||
| @@ -365,7 +346,7 @@ func TestBrokerServeCancelCleanup(t *testing.T) { | |||||
| 365 | deps, ready := testBrokerServeDeps(clk) | 346 | deps, ready := testBrokerServeDeps(clk) | ||
| 366 | ctx, cancel := context.WithCancel(context.Background()) | 347 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 367 | defer cancel() | 348 | defer cancel() | ||
| 368 | - | done := runSandbox(ctx, brokerServeOptions{production: true}, deps) | 349 | + | done := runSandbox(ctx, brokerServeOptions{}, deps) |
| 369 | 350 | ||||
| 370 | socketPath := awaitSandboxReady(t, ready, done) | 351 | socketPath := awaitSandboxReady(t, ready, done) | ||
| 371 | awaitSandboxCancel(t, cancel, done) | 352 | awaitSandboxCancel(t, cancel, done) | ||
| @@ -407,7 +388,7 @@ func TestBrokerServeShutsDownOnUnexpectedAcceptFailure(t *testing.T) { | |||||
| 407 | } | 388 | } | ||
| 408 | ctx, cancel := context.WithCancel(context.Background()) | 389 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 409 | defer cancel() | 390 | defer cancel() | ||
| 410 | - | done := runSandbox(ctx, brokerServeOptions{production: true}, deps) | 391 | + | done := runSandbox(ctx, brokerServeOptions{}, deps) |
| 411 | 392 | ||||
| 412 | awaitSandboxReady(t, ready, done) | 393 | awaitSandboxReady(t, ready, done) | ||
| 413 | select { | 394 | select { | ||
| @@ -428,12 +409,12 @@ func TestBrokerServeRefusesDuplicateOwnership(t *testing.T) { | |||||
| 428 | deps, ready := testBrokerServeDeps(clk) | 409 | deps, ready := testBrokerServeDeps(clk) | ||
| 429 | ctx, cancel := context.WithCancel(context.Background()) | 410 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 430 | defer cancel() | 411 | defer cancel() | ||
| 431 | - | done := runSandbox(ctx, brokerServeOptions{production: true}, deps) | 412 | + | done := runSandbox(ctx, brokerServeOptions{}, deps) |
| 432 | socketPath := awaitSandboxReady(t, ready, done) | 413 | socketPath := awaitSandboxReady(t, ready, done) | ||
| 433 | 414 | ||||
| 434 | // A second foreground owner fails fast on the lifetime lock. | 415 | // A second foreground owner fails fast on the lifetime lock. | ||
| 435 | secondDeps, _ := testBrokerServeDeps(newSandboxClock()) | 416 | secondDeps, _ := testBrokerServeDeps(newSandboxClock()) | ||
| 436 | - | err := runBrokerServe(context.Background(), brokerServeOptions{production: true}, secondDeps) | 417 | + | err := runBrokerServe(context.Background(), brokerServeOptions{}, secondDeps) |
| 437 | require.ErrorIs(t, err, lifecycle.ErrBusy) | 418 | require.ErrorIs(t, err, lifecycle.ErrBusy) | ||
| 438 | 419 | ||||
| 439 | // A duplicate listener on the live socket is refused as well. | 420 | // A duplicate listener on the live socket is refused as well. | ||
M internal/app/broker_ready.go +3 -18
| @@ -9,11 +9,9 @@ import ( | |||
| 9 | 9 | "io" | |
| 10 | 10 | "io/fs" | |
| 11 | 11 | "os" | |
| 12 | - | "path/filepath" | |
| 13 | 12 | "strconv" | |
| 14 | 13 | "time" | |
| 15 | 14 | ||
| 16 | - | "github.com/bnema/vev/internal/adapters/brokerconfig" | |
| 17 | 15 | "github.com/bnema/vev/internal/adapters/brokeripc" | |
| 18 | 16 | "github.com/bnema/vev/internal/adapters/ipc" | |
| 19 | 17 | "github.com/bnema/vev/internal/domain" | |
| @@ -24,10 +22,9 @@ import ( | |||
| 24 | 22 | const brokerReadyCommand = "_broker-ready" | |
| 25 | 23 | ||
| 26 | 24 | type brokerReadyOptions struct { | |
| 27 | - | offlineRoot string | |
| 28 | - | require string | |
| 29 | - | timeout time.Duration | |
| 30 | - | maxAge time.Duration | |
| 25 | + | require string | |
| 26 | + | timeout time.Duration | |
| 27 | + | maxAge time.Duration | |
| 31 | 28 | } | |
| 32 | 29 | ||
| 33 | 30 | type brokerReadyResult struct { | |
| @@ -62,8 +59,6 @@ func parseBrokerReadyArgs(args []string) (brokerReadyOptions, error) { | |||
| 62 | 59 | } | |
| 63 | 60 | seen[name] = true | |
| 64 | 61 | switch name { | |
| 65 | - | case "--offline-root": | |
| 66 | - | o.offlineRoot = value | |
| 67 | 62 | case "--require": | |
| 68 | 63 | o.require = value | |
| 69 | 64 | case "--timeout": | |
| @@ -88,9 +83,6 @@ func parseBrokerReadyArgs(args []string) (brokerReadyOptions, error) { | |||
| 88 | 83 | if o.require != "local-catalogue" && seen["--max-observation-age"] { | |
| 89 | 84 | return o, usagef("`--max-observation-age` is only valid for local-catalogue") | |
| 90 | 85 | } | |
| 91 | - | if o.offlineRoot != "" && (!filepath.IsAbs(o.offlineRoot) || filepath.Clean(o.offlineRoot) != o.offlineRoot) { | |
| 92 | - | return o, usagef("`--offline-root` must be an absolute clean path") | |
| 93 | - | } | |
| 94 | 86 | return o, nil | |
| 95 | 87 | } | |
| 96 | 88 | ||
| @@ -102,13 +94,6 @@ func runBrokerReady(ctx context.Context, o brokerReadyOptions, out io.Writer) er | |||
| 102 | 94 | deadline, cancel := context.WithTimeout(ctx, o.timeout) | |
| 103 | 95 | defer cancel() | |
| 104 | 96 | path := brokeripc.SocketPath(productionBrokerLayout().Runtime) | |
| 105 | - | if o.offlineRoot != "" { | |
| 106 | - | layout, err := brokerconfig.ResolveLayout(o.offlineRoot, []string{productionBrokerLayout().Runtime, productionBrokerLayout().State}) | |
| 107 | - | if err != nil { | |
| 108 | - | return writeReady(out, o, "terminal", "config", nil, 4) | |
| 109 | - | } | |
| 110 | - | path = brokeripc.SocketPath(layout.Runtime) | |
| 111 | - | } | |
| 112 | 97 | backoff := 5 * time.Millisecond | |
| 113 | 98 | for { | |
| 114 | 99 | if err := deadline.Err(); err != nil { | |
| @@ -9,11 +9,9 @@ import ( | |||||
| 9 | "io" | 9 | "io" | ||
| 10 | "io/fs" | 10 | "io/fs" | ||
| 11 | "os" | 11 | "os" | ||
| 12 | - | "path/filepath" | |||
| 13 | "strconv" | 12 | "strconv" | ||
| 14 | "time" | 13 | "time" | ||
| 15 | 14 | ||||
| 16 | - | "github.com/bnema/vev/internal/adapters/brokerconfig" | |||
| 17 | "github.com/bnema/vev/internal/adapters/brokeripc" | 15 | "github.com/bnema/vev/internal/adapters/brokeripc" | ||
| 18 | "github.com/bnema/vev/internal/adapters/ipc" | 16 | "github.com/bnema/vev/internal/adapters/ipc" | ||
| 19 | "github.com/bnema/vev/internal/domain" | 17 | "github.com/bnema/vev/internal/domain" | ||
| @@ -24,10 +22,9 @@ import ( | |||||
| 24 | const brokerReadyCommand = "_broker-ready" | 22 | const brokerReadyCommand = "_broker-ready" | ||
| 25 | 23 | ||||
| 26 | type brokerReadyOptions struct { | 24 | type brokerReadyOptions struct { | ||
| 27 | - | offlineRoot string | 25 | + | require string |
| 28 | - | require string | 26 | + | timeout time.Duration |
| 29 | - | timeout time.Duration | 27 | + | maxAge time.Duration |
| 30 | - | maxAge time.Duration | |||
| 31 | } | 28 | } | ||
| 32 | 29 | ||||
| 33 | type brokerReadyResult struct { | 30 | type brokerReadyResult struct { | ||
| @@ -62,8 +59,6 @@ func parseBrokerReadyArgs(args []string) (brokerReadyOptions, error) { | |||||
| 62 | } | 59 | } | ||
| 63 | seen[name] = true | 60 | seen[name] = true | ||
| 64 | switch name { | 61 | switch name { | ||
| 65 | - | case "--offline-root": | |||
| 66 | - | o.offlineRoot = value | |||
| 67 | case "--require": | 62 | case "--require": | ||
| 68 | o.require = value | 63 | o.require = value | ||
| 69 | case "--timeout": | 64 | case "--timeout": | ||
| @@ -88,9 +83,6 @@ func parseBrokerReadyArgs(args []string) (brokerReadyOptions, error) { | |||||
| 88 | if o.require != "local-catalogue" && seen["--max-observation-age"] { | 83 | if o.require != "local-catalogue" && seen["--max-observation-age"] { | ||
| 89 | return o, usagef("`--max-observation-age` is only valid for local-catalogue") | 84 | return o, usagef("`--max-observation-age` is only valid for local-catalogue") | ||
| 90 | } | 85 | } | ||
| 91 | - | if o.offlineRoot != "" && (!filepath.IsAbs(o.offlineRoot) || filepath.Clean(o.offlineRoot) != o.offlineRoot) { | |||
| 92 | - | return o, usagef("`--offline-root` must be an absolute clean path") | |||
| 93 | - | } | |||
| 94 | return o, nil | 86 | return o, nil | ||
| 95 | } | 87 | } | ||
| 96 | 88 | ||||
| @@ -102,13 +94,6 @@ func runBrokerReady(ctx context.Context, o brokerReadyOptions, out io.Writer) er | |||||
| 102 | deadline, cancel := context.WithTimeout(ctx, o.timeout) | 94 | deadline, cancel := context.WithTimeout(ctx, o.timeout) | ||
| 103 | defer cancel() | 95 | defer cancel() | ||
| 104 | path := brokeripc.SocketPath(productionBrokerLayout().Runtime) | 96 | path := brokeripc.SocketPath(productionBrokerLayout().Runtime) | ||
| 105 | - | if o.offlineRoot != "" { | |||
| 106 | - | layout, err := brokerconfig.ResolveLayout(o.offlineRoot, []string{productionBrokerLayout().Runtime, productionBrokerLayout().State}) | |||
| 107 | - | if err != nil { | |||
| 108 | - | return writeReady(out, o, "terminal", "config", nil, 4) | |||
| 109 | - | } | |||
| 110 | - | path = brokeripc.SocketPath(layout.Runtime) | |||
| 111 | - | } | |||
| 112 | backoff := 5 * time.Millisecond | 97 | backoff := 5 * time.Millisecond | ||
| 113 | for { | 98 | for { | ||
| 114 | if err := deadline.Err(); err != nil { | 99 | if err := deadline.Err(); err != nil { | ||
M internal/app/broker_ready_subprocess_test.go +21 -43
| @@ -6,14 +6,13 @@ package app | |||
| 6 | 6 | // (TestMain intercepts the hidden broker commands when brokerHelperEnv is set), | |
| 7 | 7 | // so argument parsing, endpoint classification, the real broker IPC setup, the | |
| 8 | 8 | // readiness evaluation, the JSON document, and the process exit code are all | |
| 9 | - | // exercised exactly as an operator would observe them. A real `_broker-serve` | |
| 10 | - | // composes the offline broker over one isolated root, and the probe reads it | |
| 9 | + | // exercised exactly as an operator would observe them. A real production broker serve | |
| 10 | + | // composes the broker over isolated XDG directories, and the probe reads it | |
| 11 | 11 | // over a real AF_UNIX carriage. | |
| 12 | 12 | ||
| 13 | 13 | import ( | |
| 14 | 14 | "encoding/json" | |
| 15 | 15 | "errors" | |
| 16 | - | "fmt" | |
| 17 | 16 | "net" | |
| 18 | 17 | "os" | |
| 19 | 18 | "os/exec" | |
| @@ -29,36 +28,25 @@ import ( | |||
| 29 | 28 | "github.com/bnema/vev/internal/adapters/brokeripc" | |
| 30 | 29 | ) | |
| 31 | 30 | ||
| 32 | - | // readySubprocessSandbox isolates one real offline root and records every | |
| 31 | + | // readySubprocessSandbox isolates real production XDG directories and records every | |
| 33 | 32 | // spawned broker helper, exactly like the status subprocess sandbox. | |
| 34 | 33 | type readySubprocessSandbox struct { | |
| 35 | - | t *testing.T | |
| 36 | - | root string | |
| 37 | - | layout brokerconfig.Layout | |
| 38 | - | recordDir string | |
| 39 | - | prodRuntime string | |
| 40 | - | prodState string | |
| 41 | - | broker *exec.Cmd | |
| 34 | + | t *testing.T | |
| 35 | + | layout brokerconfig.Layout | |
| 36 | + | recordDir string | |
| 37 | + | broker *exec.Cmd | |
| 42 | 38 | } | |
| 43 | 39 | ||
| 44 | - | // newReadySubprocessSandbox writes one marker-valid empty sandbox and arranges | |
| 40 | + | // newReadySubprocessSandbox provisions one empty production config and arranges | |
| 45 | 41 | // cleanup of every recorded broker process. | |
| 46 | 42 | func newReadySubprocessSandbox(t *testing.T) *readySubprocessSandbox { | |
| 47 | 43 | t.Helper() | |
| 48 | - | prodRuntime, prodState := t.TempDir(), t.TempDir() | |
| 49 | - | t.Setenv("XDG_RUNTIME_DIR", prodRuntime) | |
| 50 | - | t.Setenv("XDG_STATE_HOME", prodState) | |
| 51 | - | root := filepath.Join(shortTempDir(t, "vevr"), "s") | |
| 52 | - | require.NoError(t, os.MkdirAll(root, 0o700)) | |
| 53 | - | writeSandboxConfig(t, root, sandboxEmptyDocument("3s")) | |
| 44 | + | layout := emptyProductionBrokerLayout(t, "3s") | |
| 54 | 45 | recordDir := filepath.Join(shortTempDir(t, "vevw"), "r") | |
| 55 | 46 | require.NoError(t, os.MkdirAll(recordDir, 0o700)) | |
| 56 | 47 | t.Setenv(brokerHelperEnv, "1") | |
| 57 | 48 | t.Setenv(brokerHelperRecordDirEnv, recordDir) | |
| 58 | - | layout, err := offlineLayout(root) | |
| 59 | - | require.NoError(t, err) | |
| 60 | - | ||
| 61 | - | sandbox := &readySubprocessSandbox{t: t, root: root, layout: layout, recordDir: recordDir, prodRuntime: prodRuntime, prodState: prodState} | |
| 49 | + | sandbox := &readySubprocessSandbox{t: t, layout: layout, recordDir: recordDir} | |
| 62 | 50 | t.Cleanup(sandbox.cleanup) | |
| 63 | 51 | return sandbox | |
| 64 | 52 | } | |
| @@ -72,14 +60,14 @@ func (s *readySubprocessSandbox) socketPath() string { | |||
| 72 | 60 | // returns its stdout, stderr, and exit code. | |
| 73 | 61 | func (s *readySubprocessSandbox) ready(timeout string, extra ...string) (string, string, int) { | |
| 74 | 62 | s.t.Helper() | |
| 75 | - | args := append([]string{brokerReadyCommand, "--offline-root", s.root, "--timeout", timeout}, extra...) | |
| 63 | + | args := append([]string{brokerReadyCommand, "--timeout", timeout}, extra...) | |
| 76 | 64 | return runBrokerCommand(s.t, args...) | |
| 77 | 65 | } | |
| 78 | 66 | ||
| 79 | 67 | // startBroker runs one foreground `_broker-serve` helper in the background. | |
| 80 | 68 | func (s *readySubprocessSandbox) startBroker() { | |
| 81 | 69 | s.t.Helper() | |
| 82 | - | command := exec.Command(os.Args[0], brokerServeCommand, "--offline-root", s.root) | |
| 70 | + | command := exec.Command(os.Args[0], productionBrokerServeCommand) | |
| 83 | 71 | command.Env = os.Environ() | |
| 84 | 72 | command.Stdout = os.Stdout | |
| 85 | 73 | command.Stderr = os.Stderr | |
| @@ -114,7 +102,7 @@ func (s *readySubprocessSandbox) cleanup() { | |||
| 114 | 102 | <-done | |
| 115 | 103 | } | |
| 116 | 104 | } | |
| 117 | - | for _, pid := range recordedPIDsIn(s.t, s.recordDir, brokerServeCommand) { | |
| 105 | + | for _, pid := range recordedPIDsIn(s.t, s.recordDir, productionBrokerServeCommand) { | |
| 118 | 106 | if err := syscall.Kill(pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) { | |
| 119 | 107 | continue | |
| 120 | 108 | } | |
| @@ -150,15 +138,9 @@ func decodeReadyReport(t *testing.T, raw string) readyDoc { | |||
| 150 | 138 | // provisioned local route is absent, so it is never falsely reported ready. | |
| 151 | 139 | func TestBrokerReadySubprocessReadsARealBrokerAuthority(t *testing.T) { | |
| 152 | 140 | sandbox := newReadySubprocessSandbox(t) | |
| 153 | - | // One provisioned local binding whose route nothing listens on: the broker | |
| 154 | - | // publishes its local entry as configured authority, so `local-authority` is | |
| 141 | + | // One provisioned local binding whose daemon is not started: the broker | |
| 142 | + | // publishes its local entry as derived authority, so `local-authority` is | |
| 155 | 143 | // observable while the catalogue is not. | |
| 156 | - | require.NoError(t, os.WriteFile(filepath.Join(sandbox.root, brokerconfig.ConfigFileName), | |
| 157 | - | []byte(fmt.Sprintf(`{"marker":%q,"registrations":[],"local":{"identity":"subprocess-local","displayOrigin":"local","route":%q,"policy":{"protocolVersion":%d,"catalogSchemaVersion":3,"environmentPolicy":"client-owned","transport":"unix-mux","trust":"same-user","launch":"explicit","isolation":"per-user"}}}`, | |
| 158 | - | brokerconfig.Marker, filepath.Join(sandbox.root, "absent-mux.sock"), readyTestPolicy().ProtocolVersion)), 0o600)) | |
| 159 | - | layout, err := offlineLayout(sandbox.root) | |
| 160 | - | require.NoError(t, err) | |
| 161 | - | sandbox.layout = layout | |
| 162 | 144 | ||
| 163 | 145 | sandbox.startBroker() | |
| 164 | 146 | require.True(t, sandbox.waitForBrokerSocket(15*time.Second), "the broker must bind its endpoint") | |
| @@ -180,7 +162,6 @@ func TestBrokerReadySubprocessReadsARealBrokerAuthority(t *testing.T) { | |||
| 180 | 162 | require.Equal(t, []string{"local-catalogue"}, catalogue.Pending) | |
| 181 | 163 | require.NotEqual(t, "ready", catalogue.Status, "an unobserved catalogue is never reported ready") | |
| 182 | 164 | ||
| 183 | - | requireProductionUntouched(t, sandbox.prodRuntime, sandbox.prodState) | |
| 184 | 165 | } | |
| 185 | 166 | ||
| 186 | 167 | // TestBrokerReadySubprocessStaleSocketIsNeverReady proves a socket file left by | |
| @@ -204,8 +185,7 @@ func TestBrokerReadySubprocessStaleSocketIsNeverReady(t *testing.T) { | |||
| 204 | 185 | info, err := os.Lstat(sandbox.socketPath()) | |
| 205 | 186 | require.NoError(t, err) | |
| 206 | 187 | require.NotZero(t, info.Mode()&os.ModeSocket) | |
| 207 | - | require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, brokerServeCommand)) | |
| 208 | - | requireProductionUntouched(t, sandbox.prodRuntime, sandbox.prodState) | |
| 188 | + | require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, productionBrokerServeCommand)) | |
| 209 | 189 | } | |
| 210 | 190 | ||
| 211 | 191 | // TestBrokerReadySubprocessBlockedHandshakeIsNeverReady proves a live endpoint | |
| @@ -226,8 +206,7 @@ func TestBrokerReadySubprocessBlockedHandshakeIsNeverReady(t *testing.T) { | |||
| 226 | 206 | require.Less(t, time.Since(start), 10*time.Second) | |
| 227 | 207 | ||
| 228 | 208 | // A stalled-but-live endpoint is never respawned over. | |
| 229 | - | require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, brokerServeCommand)) | |
| 230 | - | requireProductionUntouched(t, sandbox.prodRuntime, sandbox.prodState) | |
| 209 | + | require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, productionBrokerServeCommand)) | |
| 231 | 210 | } | |
| 232 | 211 | ||
| 233 | 212 | // TestBrokerReadySubprocessNonSocketPathIsTerminal proves a path that exists but | |
| @@ -262,7 +241,7 @@ func TestBrokerReadySubprocessNonSocketPathIsTerminal(t *testing.T) { | |||
| 262 | 241 | report := decodeReadyReport(t, stdout) | |
| 263 | 242 | require.Equal(t, "terminal", report.Status) | |
| 264 | 243 | require.Equal(t, "security", report.Reason) | |
| 265 | - | require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, brokerServeCommand)) | |
| 244 | + | require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, productionBrokerServeCommand)) | |
| 266 | 245 | }) | |
| 267 | 246 | } | |
| 268 | 247 | } | |
| @@ -284,7 +263,7 @@ func TestBrokerReadySubprocessPermissionRefusalIsTerminal(t *testing.T) { | |||
| 284 | 263 | report := decodeReadyReport(t, stdout) | |
| 285 | 264 | require.Equal(t, "terminal", report.Status) | |
| 286 | 265 | require.Equal(t, "security", report.Reason) | |
| 287 | - | require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, brokerServeCommand)) | |
| 266 | + | require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, productionBrokerServeCommand)) | |
| 288 | 267 | } | |
| 289 | 268 | ||
| 290 | 269 | // TestBrokerReadySubprocessAbsentSocketTimesOut proves a missing endpoint is | |
| @@ -299,7 +278,7 @@ func TestBrokerReadySubprocessAbsentSocketTimesOut(t *testing.T) { | |||
| 299 | 278 | report := decodeReadyReport(t, stdout) | |
| 300 | 279 | require.Equal(t, "timeout", report.Status) | |
| 301 | 280 | require.Equal(t, "deadline", report.Reason) | |
| 302 | - | require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, brokerServeCommand), "the readiness probe never ensures or spawns") | |
| 281 | + | require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, productionBrokerServeCommand), "the readiness probe never ensures or spawns") | |
| 303 | 282 | } | |
| 304 | 283 | ||
| 305 | 284 | // TestBrokerReadySubprocessNonBrokerEndpointIsTerminal proves a live endpoint | |
| @@ -317,8 +296,7 @@ func TestBrokerReadySubprocessNonBrokerEndpointIsTerminal(t *testing.T) { | |||
| 317 | 296 | report := decodeReadyReport(t, stdout) | |
| 318 | 297 | require.Equal(t, "terminal", report.Status) | |
| 319 | 298 | require.Equal(t, "protocol", report.Reason) | |
| 320 | - | require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, brokerServeCommand)) | |
| 321 | - | requireProductionUntouched(t, sandbox.prodRuntime, sandbox.prodState) | |
| 299 | + | require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, productionBrokerServeCommand)) | |
| 322 | 300 | } | |
| 323 | 301 | ||
| 324 | 302 | // answerWithForeignBytes serves one connection per accept with a bounded | |
| @@ -6,14 +6,13 @@ package app | |||||
| 6 | // (TestMain intercepts the hidden broker commands when brokerHelperEnv is set), | 6 | // (TestMain intercepts the hidden broker commands when brokerHelperEnv is set), | ||
| 7 | // so argument parsing, endpoint classification, the real broker IPC setup, the | 7 | // so argument parsing, endpoint classification, the real broker IPC setup, the | ||
| 8 | // readiness evaluation, the JSON document, and the process exit code are all | 8 | // readiness evaluation, the JSON document, and the process exit code are all | ||
| 9 | - | // exercised exactly as an operator would observe them. A real `_broker-serve` | 9 | + | // exercised exactly as an operator would observe them. A real production broker serve |
| 10 | - | // composes the offline broker over one isolated root, and the probe reads it | 10 | + | // composes the broker over isolated XDG directories, and the probe reads it |
| 11 | // over a real AF_UNIX carriage. | 11 | // over a real AF_UNIX carriage. | ||
| 12 | 12 | ||||
| 13 | import ( | 13 | import ( | ||
| 14 | "encoding/json" | 14 | "encoding/json" | ||
| 15 | "errors" | 15 | "errors" | ||
| 16 | - | "fmt" | |||
| 17 | "net" | 16 | "net" | ||
| 18 | "os" | 17 | "os" | ||
| 19 | "os/exec" | 18 | "os/exec" | ||
| @@ -29,36 +28,25 @@ import ( | |||||
| 29 | "github.com/bnema/vev/internal/adapters/brokeripc" | 28 | "github.com/bnema/vev/internal/adapters/brokeripc" | ||
| 30 | ) | 29 | ) | ||
| 31 | 30 | ||||
| 32 | - | // readySubprocessSandbox isolates one real offline root and records every | 31 | + | // readySubprocessSandbox isolates real production XDG directories and records every |
| 33 | // spawned broker helper, exactly like the status subprocess sandbox. | 32 | // spawned broker helper, exactly like the status subprocess sandbox. | ||
| 34 | type readySubprocessSandbox struct { | 33 | type readySubprocessSandbox struct { | ||
| 35 | - | t *testing.T | 34 | + | t *testing.T |
| 36 | - | root string | 35 | + | layout brokerconfig.Layout |
| 37 | - | layout brokerconfig.Layout | 36 | + | recordDir string |
| 38 | - | recordDir string | 37 | + | broker *exec.Cmd |
| 39 | - | prodRuntime string | |||
| 40 | - | prodState string | |||
| 41 | - | broker *exec.Cmd | |||
| 42 | } | 38 | } | ||
| 43 | 39 | ||||
| 44 | - | // newReadySubprocessSandbox writes one marker-valid empty sandbox and arranges | 40 | + | // newReadySubprocessSandbox provisions one empty production config and arranges |
| 45 | // cleanup of every recorded broker process. | 41 | // cleanup of every recorded broker process. | ||
| 46 | func newReadySubprocessSandbox(t *testing.T) *readySubprocessSandbox { | 42 | func newReadySubprocessSandbox(t *testing.T) *readySubprocessSandbox { | ||
| 47 | t.Helper() | 43 | t.Helper() | ||
| 48 | - | prodRuntime, prodState := t.TempDir(), t.TempDir() | 44 | + | layout := emptyProductionBrokerLayout(t, "3s") |
| 49 | - | t.Setenv("XDG_RUNTIME_DIR", prodRuntime) | |||
| 50 | - | t.Setenv("XDG_STATE_HOME", prodState) | |||
| 51 | - | root := filepath.Join(shortTempDir(t, "vevr"), "s") | |||
| 52 | - | require.NoError(t, os.MkdirAll(root, 0o700)) | |||
| 53 | - | writeSandboxConfig(t, root, sandboxEmptyDocument("3s")) | |||
| 54 | recordDir := filepath.Join(shortTempDir(t, "vevw"), "r") | 45 | recordDir := filepath.Join(shortTempDir(t, "vevw"), "r") | ||
| 55 | require.NoError(t, os.MkdirAll(recordDir, 0o700)) | 46 | require.NoError(t, os.MkdirAll(recordDir, 0o700)) | ||
| 56 | t.Setenv(brokerHelperEnv, "1") | 47 | t.Setenv(brokerHelperEnv, "1") | ||
| 57 | t.Setenv(brokerHelperRecordDirEnv, recordDir) | 48 | t.Setenv(brokerHelperRecordDirEnv, recordDir) | ||
| 58 | - | layout, err := offlineLayout(root) | 49 | + | sandbox := &readySubprocessSandbox{t: t, layout: layout, recordDir: recordDir} |
| 59 | - | require.NoError(t, err) | |||
| 60 | - | ||||
| 61 | - | sandbox := &readySubprocessSandbox{t: t, root: root, layout: layout, recordDir: recordDir, prodRuntime: prodRuntime, prodState: prodState} | |||
| 62 | t.Cleanup(sandbox.cleanup) | 50 | t.Cleanup(sandbox.cleanup) | ||
| 63 | return sandbox | 51 | return sandbox | ||
| 64 | } | 52 | } | ||
| @@ -72,14 +60,14 @@ func (s *readySubprocessSandbox) socketPath() string { | |||||
| 72 | // returns its stdout, stderr, and exit code. | 60 | // returns its stdout, stderr, and exit code. | ||
| 73 | func (s *readySubprocessSandbox) ready(timeout string, extra ...string) (string, string, int) { | 61 | func (s *readySubprocessSandbox) ready(timeout string, extra ...string) (string, string, int) { | ||
| 74 | s.t.Helper() | 62 | s.t.Helper() | ||
| 75 | - | args := append([]string{brokerReadyCommand, "--offline-root", s.root, "--timeout", timeout}, extra...) | 63 | + | args := append([]string{brokerReadyCommand, "--timeout", timeout}, extra...) |
| 76 | return runBrokerCommand(s.t, args...) | 64 | return runBrokerCommand(s.t, args...) | ||
| 77 | } | 65 | } | ||
| 78 | 66 | ||||
| 79 | // startBroker runs one foreground `_broker-serve` helper in the background. | 67 | // startBroker runs one foreground `_broker-serve` helper in the background. | ||
| 80 | func (s *readySubprocessSandbox) startBroker() { | 68 | func (s *readySubprocessSandbox) startBroker() { | ||
| 81 | s.t.Helper() | 69 | s.t.Helper() | ||
| 82 | - | command := exec.Command(os.Args[0], brokerServeCommand, "--offline-root", s.root) | 70 | + | command := exec.Command(os.Args[0], productionBrokerServeCommand) |
| 83 | command.Env = os.Environ() | 71 | command.Env = os.Environ() | ||
| 84 | command.Stdout = os.Stdout | 72 | command.Stdout = os.Stdout | ||
| 85 | command.Stderr = os.Stderr | 73 | command.Stderr = os.Stderr | ||
| @@ -114,7 +102,7 @@ func (s *readySubprocessSandbox) cleanup() { | |||||
| 114 | <-done | 102 | <-done | ||
| 115 | } | 103 | } | ||
| 116 | } | 104 | } | ||
| 117 | - | for _, pid := range recordedPIDsIn(s.t, s.recordDir, brokerServeCommand) { | 105 | + | for _, pid := range recordedPIDsIn(s.t, s.recordDir, productionBrokerServeCommand) { |
| 118 | if err := syscall.Kill(pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) { | 106 | if err := syscall.Kill(pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) { | ||
| 119 | continue | 107 | continue | ||
| 120 | } | 108 | } | ||
| @@ -150,15 +138,9 @@ func decodeReadyReport(t *testing.T, raw string) readyDoc { | |||||
| 150 | // provisioned local route is absent, so it is never falsely reported ready. | 138 | // provisioned local route is absent, so it is never falsely reported ready. | ||
| 151 | func TestBrokerReadySubprocessReadsARealBrokerAuthority(t *testing.T) { | 139 | func TestBrokerReadySubprocessReadsARealBrokerAuthority(t *testing.T) { | ||
| 152 | sandbox := newReadySubprocessSandbox(t) | 140 | sandbox := newReadySubprocessSandbox(t) | ||
| 153 | - | // One provisioned local binding whose route nothing listens on: the broker | 141 | + | // One provisioned local binding whose daemon is not started: the broker |
| 154 | - | // publishes its local entry as configured authority, so `local-authority` is | 142 | + | // publishes its local entry as derived authority, so `local-authority` is |
| 155 | // observable while the catalogue is not. | 143 | // observable while the catalogue is not. | ||
| 156 | - | require.NoError(t, os.WriteFile(filepath.Join(sandbox.root, brokerconfig.ConfigFileName), | |||
| 157 | - | []byte(fmt.Sprintf(`{"marker":%q,"registrations":[],"local":{"identity":"subprocess-local","displayOrigin":"local","route":%q,"policy":{"protocolVersion":%d,"catalogSchemaVersion":3,"environmentPolicy":"client-owned","transport":"unix-mux","trust":"same-user","launch":"explicit","isolation":"per-user"}}}`, | |||
| 158 | - | brokerconfig.Marker, filepath.Join(sandbox.root, "absent-mux.sock"), readyTestPolicy().ProtocolVersion)), 0o600)) | |||
| 159 | - | layout, err := offlineLayout(sandbox.root) | |||
| 160 | - | require.NoError(t, err) | |||
| 161 | - | sandbox.layout = layout | |||
| 162 | 144 | ||||
| 163 | sandbox.startBroker() | 145 | sandbox.startBroker() | ||
| 164 | require.True(t, sandbox.waitForBrokerSocket(15*time.Second), "the broker must bind its endpoint") | 146 | require.True(t, sandbox.waitForBrokerSocket(15*time.Second), "the broker must bind its endpoint") | ||
| @@ -180,7 +162,6 @@ func TestBrokerReadySubprocessReadsARealBrokerAuthority(t *testing.T) { | |||||
| 180 | require.Equal(t, []string{"local-catalogue"}, catalogue.Pending) | 162 | require.Equal(t, []string{"local-catalogue"}, catalogue.Pending) | ||
| 181 | require.NotEqual(t, "ready", catalogue.Status, "an unobserved catalogue is never reported ready") | 163 | require.NotEqual(t, "ready", catalogue.Status, "an unobserved catalogue is never reported ready") | ||
| 182 | 164 | ||||
| 183 | - | requireProductionUntouched(t, sandbox.prodRuntime, sandbox.prodState) | |||
| 184 | } | 165 | } | ||
| 185 | 166 | ||||
| 186 | // TestBrokerReadySubprocessStaleSocketIsNeverReady proves a socket file left by | 167 | // TestBrokerReadySubprocessStaleSocketIsNeverReady proves a socket file left by | ||
| @@ -204,8 +185,7 @@ func TestBrokerReadySubprocessStaleSocketIsNeverReady(t *testing.T) { | |||||
| 204 | info, err := os.Lstat(sandbox.socketPath()) | 185 | info, err := os.Lstat(sandbox.socketPath()) | ||
| 205 | require.NoError(t, err) | 186 | require.NoError(t, err) | ||
| 206 | require.NotZero(t, info.Mode()&os.ModeSocket) | 187 | require.NotZero(t, info.Mode()&os.ModeSocket) | ||
| 207 | - | require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, brokerServeCommand)) | 188 | + | require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, productionBrokerServeCommand)) |
| 208 | - | requireProductionUntouched(t, sandbox.prodRuntime, sandbox.prodState) | |||
| 209 | } | 189 | } | ||
| 210 | 190 | ||||
| 211 | // TestBrokerReadySubprocessBlockedHandshakeIsNeverReady proves a live endpoint | 191 | // TestBrokerReadySubprocessBlockedHandshakeIsNeverReady proves a live endpoint | ||
| @@ -226,8 +206,7 @@ func TestBrokerReadySubprocessBlockedHandshakeIsNeverReady(t *testing.T) { | |||||
| 226 | require.Less(t, time.Since(start), 10*time.Second) | 206 | require.Less(t, time.Since(start), 10*time.Second) | ||
| 227 | 207 | ||||
| 228 | // A stalled-but-live endpoint is never respawned over. | 208 | // A stalled-but-live endpoint is never respawned over. | ||
| 229 | - | require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, brokerServeCommand)) | 209 | + | require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, productionBrokerServeCommand)) |
| 230 | - | requireProductionUntouched(t, sandbox.prodRuntime, sandbox.prodState) | |||
| 231 | } | 210 | } | ||
| 232 | 211 | ||||
| 233 | // TestBrokerReadySubprocessNonSocketPathIsTerminal proves a path that exists but | 212 | // TestBrokerReadySubprocessNonSocketPathIsTerminal proves a path that exists but | ||
| @@ -262,7 +241,7 @@ func TestBrokerReadySubprocessNonSocketPathIsTerminal(t *testing.T) { | |||||
| 262 | report := decodeReadyReport(t, stdout) | 241 | report := decodeReadyReport(t, stdout) | ||
| 263 | require.Equal(t, "terminal", report.Status) | 242 | require.Equal(t, "terminal", report.Status) | ||
| 264 | require.Equal(t, "security", report.Reason) | 243 | require.Equal(t, "security", report.Reason) | ||
| 265 | - | require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, brokerServeCommand)) | 244 | + | require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, productionBrokerServeCommand)) |
| 266 | }) | 245 | }) | ||
| 267 | } | 246 | } | ||
| 268 | } | 247 | } | ||
| @@ -284,7 +263,7 @@ func TestBrokerReadySubprocessPermissionRefusalIsTerminal(t *testing.T) { | |||||
| 284 | report := decodeReadyReport(t, stdout) | 263 | report := decodeReadyReport(t, stdout) | ||
| 285 | require.Equal(t, "terminal", report.Status) | 264 | require.Equal(t, "terminal", report.Status) | ||
| 286 | require.Equal(t, "security", report.Reason) | 265 | require.Equal(t, "security", report.Reason) | ||
| 287 | - | require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, brokerServeCommand)) | 266 | + | require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, productionBrokerServeCommand)) |
| 288 | } | 267 | } | ||
| 289 | 268 | ||||
| 290 | // TestBrokerReadySubprocessAbsentSocketTimesOut proves a missing endpoint is | 269 | // TestBrokerReadySubprocessAbsentSocketTimesOut proves a missing endpoint is | ||
| @@ -299,7 +278,7 @@ func TestBrokerReadySubprocessAbsentSocketTimesOut(t *testing.T) { | |||||
| 299 | report := decodeReadyReport(t, stdout) | 278 | report := decodeReadyReport(t, stdout) | ||
| 300 | require.Equal(t, "timeout", report.Status) | 279 | require.Equal(t, "timeout", report.Status) | ||
| 301 | require.Equal(t, "deadline", report.Reason) | 280 | require.Equal(t, "deadline", report.Reason) | ||
| 302 | - | require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, brokerServeCommand), "the readiness probe never ensures or spawns") | 281 | + | require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, productionBrokerServeCommand), "the readiness probe never ensures or spawns") |
| 303 | } | 282 | } | ||
| 304 | 283 | ||||
| 305 | // TestBrokerReadySubprocessNonBrokerEndpointIsTerminal proves a live endpoint | 284 | // TestBrokerReadySubprocessNonBrokerEndpointIsTerminal proves a live endpoint | ||
| @@ -317,8 +296,7 @@ func TestBrokerReadySubprocessNonBrokerEndpointIsTerminal(t *testing.T) { | |||||
| 317 | report := decodeReadyReport(t, stdout) | 296 | report := decodeReadyReport(t, stdout) | ||
| 318 | require.Equal(t, "terminal", report.Status) | 297 | require.Equal(t, "terminal", report.Status) | ||
| 319 | require.Equal(t, "protocol", report.Reason) | 298 | require.Equal(t, "protocol", report.Reason) | ||
| 320 | - | require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, brokerServeCommand)) | 299 | + | require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, productionBrokerServeCommand)) |
| 321 | - | requireProductionUntouched(t, sandbox.prodRuntime, sandbox.prodState) | |||
| 322 | } | 300 | } | ||
| 323 | 301 | ||||
| 324 | // answerWithForeignBytes serves one connection per accept with a bounded | 302 | // answerWithForeignBytes serves one connection per accept with a bounded | ||
M internal/app/broker_ready_test.go +4 -8
| @@ -277,10 +277,8 @@ func (s *readyDialScript) createdServices() []*readyScriptedService { | |||
| 277 | 277 | // offline root, so no production path is ever inspected. | |
| 278 | 278 | func readyTestOptions(t *testing.T, requirement string, timeout time.Duration) brokerReadyOptions { | |
| 279 | 279 | t.Helper() | |
| 280 | - | root, _, _ := isolateSandboxEnv(t) | |
| 281 | - | require.NoError(t, os.MkdirAll(root, 0o700)) | |
| 282 | - | writeSandboxConfig(t, root, sandboxEmptyDocument("")) | |
| 283 | - | options, err := parseBrokerReadyArgs([]string{"--offline-root", root, "--require", requirement, "--timeout", timeout.String()}) | |
| 280 | + | emptyProductionBrokerLayout(t, "") | |
| 281 | + | options, err := parseBrokerReadyArgs([]string{"--require", requirement, "--timeout", timeout.String()}) | |
| 284 | 282 | require.NoError(t, err) | |
| 285 | 283 | return options | |
| 286 | 284 | } | |
| @@ -326,8 +324,8 @@ func TestParseBrokerReadyArgs(t *testing.T) { | |||
| 326 | 324 | }, | |
| 327 | 325 | { | |
| 328 | 326 | name: "catalogue with an explicit observation age", | |
| 329 | - | args: []string{"--require", "local-catalogue", "--max-observation-age", "2s", "--timeout", "3s", "--offline-root", "/srv/broker"}, | |
| 330 | - | want: brokerReadyOptions{offlineRoot: "/srv/broker", require: "local-catalogue", timeout: 3 * time.Second, maxAge: 2 * time.Second}, | |
| 327 | + | args: []string{"--require", "local-catalogue", "--max-observation-age", "2s", "--timeout", "3s"}, | |
| 328 | + | want: brokerReadyOptions{require: "local-catalogue", timeout: 3 * time.Second, maxAge: 2 * time.Second}, | |
| 331 | 329 | }, | |
| 332 | 330 | {name: "missing require", args: nil, wantErr: "`--require` must be local-authority or local-catalogue"}, | |
| 333 | 331 | {name: "unknown require", args: []string{"--require", "remote-catalogue"}, wantErr: "`--require` must be local-authority or local-catalogue"}, | |
| @@ -344,8 +342,6 @@ func TestParseBrokerReadyArgs(t *testing.T) { | |||
| 344 | 342 | args: []string{"--require", "local-authority", "--max-observation-age", "2s"}, | |
| 345 | 343 | wantErr: "`--max-observation-age` is only valid for local-catalogue", | |
| 346 | 344 | }, | |
| 347 | - | {name: "relative offline root", args: []string{"--require", "local-authority", "--offline-root", "relative/root"}, wantErr: "`--offline-root` must be an absolute clean path"}, | |
| 348 | - | {name: "unclean offline root", args: []string{"--require", "local-authority", "--offline-root", "/srv/../srv/broker"}, wantErr: "`--offline-root` must be an absolute clean path"}, | |
| 349 | 345 | } | |
| 350 | 346 | for _, tt := range tests { | |
| 351 | 347 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -277,10 +277,8 @@ func (s *readyDialScript) createdServices() []*readyScriptedService { | |||||
| 277 | // offline root, so no production path is ever inspected. | 277 | // offline root, so no production path is ever inspected. | ||
| 278 | func readyTestOptions(t *testing.T, requirement string, timeout time.Duration) brokerReadyOptions { | 278 | func readyTestOptions(t *testing.T, requirement string, timeout time.Duration) brokerReadyOptions { | ||
| 279 | t.Helper() | 279 | t.Helper() | ||
| 280 | - | root, _, _ := isolateSandboxEnv(t) | 280 | + | emptyProductionBrokerLayout(t, "") |
| 281 | - | require.NoError(t, os.MkdirAll(root, 0o700)) | 281 | + | options, err := parseBrokerReadyArgs([]string{"--require", requirement, "--timeout", timeout.String()}) |
| 282 | - | writeSandboxConfig(t, root, sandboxEmptyDocument("")) | |||
| 283 | - | options, err := parseBrokerReadyArgs([]string{"--offline-root", root, "--require", requirement, "--timeout", timeout.String()}) | |||
| 284 | require.NoError(t, err) | 282 | require.NoError(t, err) | ||
| 285 | return options | 283 | return options | ||
| 286 | } | 284 | } | ||
| @@ -326,8 +324,8 @@ func TestParseBrokerReadyArgs(t *testing.T) { | |||||
| 326 | }, | 324 | }, | ||
| 327 | { | 325 | { | ||
| 328 | name: "catalogue with an explicit observation age", | 326 | name: "catalogue with an explicit observation age", | ||
| 329 | - | args: []string{"--require", "local-catalogue", "--max-observation-age", "2s", "--timeout", "3s", "--offline-root", "/srv/broker"}, | 327 | + | args: []string{"--require", "local-catalogue", "--max-observation-age", "2s", "--timeout", "3s"}, |
| 330 | - | want: brokerReadyOptions{offlineRoot: "/srv/broker", require: "local-catalogue", timeout: 3 * time.Second, maxAge: 2 * time.Second}, | 328 | + | want: brokerReadyOptions{require: "local-catalogue", timeout: 3 * time.Second, maxAge: 2 * time.Second}, |
| 331 | }, | 329 | }, | ||
| 332 | {name: "missing require", args: nil, wantErr: "`--require` must be local-authority or local-catalogue"}, | 330 | {name: "missing require", args: nil, wantErr: "`--require` must be local-authority or local-catalogue"}, | ||
| 333 | {name: "unknown require", args: []string{"--require", "remote-catalogue"}, wantErr: "`--require` must be local-authority or local-catalogue"}, | 331 | {name: "unknown require", args: []string{"--require", "remote-catalogue"}, wantErr: "`--require` must be local-authority or local-catalogue"}, | ||
| @@ -344,8 +342,6 @@ func TestParseBrokerReadyArgs(t *testing.T) { | |||||
| 344 | args: []string{"--require", "local-authority", "--max-observation-age", "2s"}, | 342 | args: []string{"--require", "local-authority", "--max-observation-age", "2s"}, | ||
| 345 | wantErr: "`--max-observation-age` is only valid for local-catalogue", | 343 | wantErr: "`--max-observation-age` is only valid for local-catalogue", | ||
| 346 | }, | 344 | }, | ||
| 347 | - | {name: "relative offline root", args: []string{"--require", "local-authority", "--offline-root", "relative/root"}, wantErr: "`--offline-root` must be an absolute clean path"}, | |||
| 348 | - | {name: "unclean offline root", args: []string{"--require", "local-authority", "--offline-root", "/srv/../srv/broker"}, wantErr: "`--offline-root` must be an absolute clean path"}, | |||
| 349 | } | 345 | } | ||
| 350 | for _, tt := range tests { | 346 | for _, tt := range tests { | ||
| 351 | t.Run(tt.name, func(t *testing.T) { | 347 | t.Run(tt.name, func(t *testing.T) { | ||
M internal/app/broker_routes_test.go +2 -3
| @@ -116,7 +116,6 @@ func TestCanonicalBrokerRoutesSelectProductionHelpers(t *testing.T) { | |||
| 116 | 116 | require.NoError(t, err) | |
| 117 | 117 | cmd, err := parseArgs(spec.Argv[1:]) | |
| 118 | 118 | require.NoError(t, err) | |
| 119 | - | require.True(t, cmd.brokerMux.production) | |
| 120 | 119 | require.Equal(t, ports.BrokerDaemonExistingOnly, cmd.brokerMux.startMode) | |
| 121 | 120 | for _, args := range [][]string{{"--production", "--production"}, {"--production", "--offline-root", "/tmp/root"}, {"--offline-root", "/tmp/root", "--production"}} { | |
| 122 | 121 | _, err = parseBrokerMuxArgs(spec.Argv[1], cmd.kind, args) | |
| @@ -140,7 +139,7 @@ func TestProductionBrokerFirstHostAdd(t *testing.T) { | |||
| 140 | 139 | for i := 0; i < 2; i++ { | |
| 141 | 140 | deps, ready := testBrokerServeDeps(newSandboxClock()) | |
| 142 | 141 | ctx, cancel := context.WithCancel(context.Background()) | |
| 143 | - | done := runSandbox(ctx, brokerServeOptions{production: true}, deps) | |
| 142 | + | done := runSandbox(ctx, brokerServeOptions{}, deps) | |
| 144 | 143 | socket := awaitSandboxReady(t, ready, done) | |
| 145 | 144 | service, err := brokeripc.NewConnector(socket, brokeripc.Config{}).Connect(ctx) | |
| 146 | 145 | require.NoError(t, err) | |
| @@ -192,7 +191,7 @@ func TestProductionMuxHelperExistingOnlyNeverSpawns(t *testing.T) { | |||
| 192 | 191 | }, | |
| 193 | 192 | spawn: func() error { spawns++; return nil }, | |
| 194 | 193 | } | |
| 195 | - | _, err := dialBrokerMuxHelper(context.Background(), brokerMuxOptions{production: true, startMode: ports.BrokerDaemonExistingOnly}) | |
| 194 | + | _, err := dialBrokerMuxHelper(context.Background(), brokerMuxOptions{startMode: ports.BrokerDaemonExistingOnly}) | |
| 196 | 195 | require.ErrorIs(t, err, os.ErrNotExist) | |
| 197 | 196 | require.Equal(t, 1, dials) | |
| 198 | 197 | require.Zero(t, spawns) | |
| @@ -116,7 +116,6 @@ func TestCanonicalBrokerRoutesSelectProductionHelpers(t *testing.T) { | |||||
| 116 | require.NoError(t, err) | 116 | require.NoError(t, err) | ||
| 117 | cmd, err := parseArgs(spec.Argv[1:]) | 117 | cmd, err := parseArgs(spec.Argv[1:]) | ||
| 118 | require.NoError(t, err) | 118 | require.NoError(t, err) | ||
| 119 | - | require.True(t, cmd.brokerMux.production) | |||
| 120 | require.Equal(t, ports.BrokerDaemonExistingOnly, cmd.brokerMux.startMode) | 119 | require.Equal(t, ports.BrokerDaemonExistingOnly, cmd.brokerMux.startMode) | ||
| 121 | for _, args := range [][]string{{"--production", "--production"}, {"--production", "--offline-root", "/tmp/root"}, {"--offline-root", "/tmp/root", "--production"}} { | 120 | for _, args := range [][]string{{"--production", "--production"}, {"--production", "--offline-root", "/tmp/root"}, {"--offline-root", "/tmp/root", "--production"}} { | ||
| 122 | _, err = parseBrokerMuxArgs(spec.Argv[1], cmd.kind, args) | 121 | _, err = parseBrokerMuxArgs(spec.Argv[1], cmd.kind, args) | ||
| @@ -140,7 +139,7 @@ func TestProductionBrokerFirstHostAdd(t *testing.T) { | |||||
| 140 | for i := 0; i < 2; i++ { | 139 | for i := 0; i < 2; i++ { | ||
| 141 | deps, ready := testBrokerServeDeps(newSandboxClock()) | 140 | deps, ready := testBrokerServeDeps(newSandboxClock()) | ||
| 142 | ctx, cancel := context.WithCancel(context.Background()) | 141 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 143 | - | done := runSandbox(ctx, brokerServeOptions{production: true}, deps) | 142 | + | done := runSandbox(ctx, brokerServeOptions{}, deps) |
| 144 | socket := awaitSandboxReady(t, ready, done) | 143 | socket := awaitSandboxReady(t, ready, done) | ||
| 145 | service, err := brokeripc.NewConnector(socket, brokeripc.Config{}).Connect(ctx) | 144 | service, err := brokeripc.NewConnector(socket, brokeripc.Config{}).Connect(ctx) | ||
| 146 | require.NoError(t, err) | 145 | require.NoError(t, err) | ||
| @@ -192,7 +191,7 @@ func TestProductionMuxHelperExistingOnlyNeverSpawns(t *testing.T) { | |||||
| 192 | }, | 191 | }, | ||
| 193 | spawn: func() error { spawns++; return nil }, | 192 | spawn: func() error { spawns++; return nil }, | ||
| 194 | } | 193 | } | ||
| 195 | - | _, err := dialBrokerMuxHelper(context.Background(), brokerMuxOptions{production: true, startMode: ports.BrokerDaemonExistingOnly}) | 194 | + | _, err := dialBrokerMuxHelper(context.Background(), brokerMuxOptions{startMode: ports.BrokerDaemonExistingOnly}) |
| 196 | require.ErrorIs(t, err, os.ErrNotExist) | 195 | require.ErrorIs(t, err, os.ErrNotExist) | ||
| 197 | require.Equal(t, 1, dials) | 196 | require.Equal(t, 1, dials) | ||
| 198 | require.Zero(t, spawns) | 197 | require.Zero(t, spawns) | ||
D internal/app/connectivity_matrix.go +0 -246
| @@ -1,246 +0,0 @@ | |||
| 1 | - | package app | |
| 2 | - | ||
| 3 | - | // Connectivity migration matrix. | |
| 4 | - | // | |
| 5 | - | // Every CLI entry point has exactly one owner. Ordinary user-facing | |
| 6 | - | // operations reach daemons through the per-user | |
| 7 | - | // connection broker; narrowly scoped daemon-facing transport helpers stay | |
| 8 | - | // infrastructure and never become alternate client façades. | |
| 9 | - | // | |
| 10 | - | // Ownership rules pinned here: | |
| 11 | - | // - Broker-only entries must not keep a direct daemon dialer or a second | |
| 12 | - | // host-store writer after cutover. Their CurrentDebt lists today's | |
| 13 | - | // direct-connectivity symbols to remove in the same change that | |
| 14 | - | // activates the broker owner. | |
| 15 | - | // - Broker never owns session persistence. Offline session mutation/list | |
| 16 | - | // paths (PersistMutation) become daemon-owned operations reached through | |
| 17 | - | // the broker; the persist/snapshot packages stay daemon-side. | |
| 18 | - | // - Observation never starts a broker, never undoes an explicit | |
| 19 | - | // daemon-stop, and never creates a session attachment. | |
| 20 | - | // - Transport helpers terminate transport only. They never own host | |
| 21 | - | // configuration, navigation, sessions, or picker state. | |
| 22 | - | // | |
| 23 | - | // The accompanying test fails when a command kind lacks a row, when a | |
| 24 | - | // broker-only row declares no migration debt, when an infra row gains store | |
| 25 | - | // or persistence ownership, and when direct-dial, host-store, or offline | |
| 26 | - | // persistence symbols appear in files outside the declared debt set. | |
| 27 | - | ||
| 28 | - | type connectivityOwner uint8 | |
| 29 | - | ||
| 30 | - | const ( | |
| 31 | - | // connectivityBrokerOnly marks ordinary user-facing operations that go | |
| 32 | - | // through the broker façade after cutover. | |
| 33 | - | connectivityBrokerOnly connectivityOwner = iota + 1 | |
| 34 | - | // connectivityTransportInfra marks narrowly scoped daemon-facing | |
| 35 | - | // transport helpers: process mechanics and carriage terminators that | |
| 36 | - | // must never become client façades. | |
| 37 | - | connectivityTransportInfra | |
| 38 | - | // connectivityLocalOnly marks commands with no daemon or session | |
| 39 | - | // connectivity at all. | |
| 40 | - | connectivityLocalOnly | |
| 41 | - | ) | |
| 42 | - | ||
| 43 | - | func (o connectivityOwner) String() string { | |
| 44 | - | switch o { | |
| 45 | - | case connectivityBrokerOnly: | |
| 46 | - | return "broker-only" | |
| 47 | - | case connectivityTransportInfra: | |
| 48 | - | return "transport-infra" | |
| 49 | - | case connectivityLocalOnly: | |
| 50 | - | return "local-only" | |
| 51 | - | default: | |
| 52 | - | return "unknown" | |
| 53 | - | } | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | // connectivityEntry is one row of the migration matrix. Kind is the cmdKind | |
| 57 | - | // identifier from run.go (for example "kindAttach"). | |
| 58 | - | type connectivityEntry struct { | |
| 59 | - | Kind string | |
| 60 | - | // Summary names the user-visible operation. | |
| 61 | - | Summary string | |
| 62 | - | Owner connectivityOwner | |
| 63 | - | // DirectDialDebt lists today's direct-daemon dial symbols that the | |
| 64 | - | // cutover removes for broker-only entries. Empty for infra/local rows and | |
| 65 | - | // for a broker-only row that has already completed its cutover. | |
| 66 | - | DirectDialDebt []string | |
| 67 | - | // Migrated marks a broker-only row whose cutover is complete: it keeps no | |
| 68 | - | // direct dial, no host-store write, and no persist mutation, so its debt | |
| 69 | - | // list is deliberately empty. It exists so a finished row is declared | |
| 70 | - | // explicitly instead of being mistaken for an unreviewed one. | |
| 71 | - | Migrated bool | |
| 72 | - | // HostStoreWrite reports a RemoteHostStore constructor/writer today. | |
| 73 | - | // Only the broker owns the host store after cutover. | |
| 74 | - | HostStoreWrite bool | |
| 75 | - | // PersistMutation reports an offline session persistence read/write | |
| 76 | - | // today. These become daemon-owned operations through the broker. | |
| 77 | - | PersistMutation bool | |
| 78 | - | // Notes records cutover direction and non-obvious boundaries. | |
| 79 | - | Notes string | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | // connectivityMatrix classifies every cmdKind exactly once. | |
| 83 | - | var connectivityMatrix = []connectivityEntry{ | |
| 84 | - | { | |
| 85 | - | Kind: "kindAttach", | |
| 86 | - | Summary: "interactive terminal attach/new/ephemeral, incl. remote attach, detached creation, and the UI-driver/web path", | |
| 87 | - | Owner: connectivityBrokerOnly, | |
| 88 | - | DirectDialDebt: []string{ | |
| 89 | - | "ensureDaemonWithLifecycle detached creation (run.go createDetachedLocalSession)", | |
| 90 | - | }, | |
| 91 | - | Notes: "The ordinary terminal path is already broker-only: runAttach translates the parsed CLI target into client.InitialNavigation/Resolver, connect-or-spawns the per-user broker, and delegates to the shared runBrokerClient (run.go, broker_client.go). The old direct-dialer attach composition and its attach preflight and client host registry are removed; the UI-driver and browser compositions delegate to the shared broker client. The only remaining debt is the nested-session detached creation path, which still dials the local daemon directly and is expressed as broker navigation for ordinary attach.", | |
| 92 | - | }, | |
| 93 | - | { | |
| 94 | - | Kind: "kindList", | |
| 95 | - | Summary: "session list: local, per-host, and --all", | |
| 96 | - | Owner: connectivityBrokerOnly, | |
| 97 | - | Migrated: true, | |
| 98 | - | Notes: "Cutover complete: runList reaches the per-user broker through the private connectBroker seam (run.go) and reads BrokerOperations.List for the local list or runBrokerSnapshotList for per-host/--all. It keeps no persist read, no daemon dialer, and no direct-dial list path. Remote observation never attaches.", | |
| 99 | - | }, | |
| 100 | - | { | |
| 101 | - | Kind: "kindHost", | |
| 102 | - | Summary: "host add/rm/list", | |
| 103 | - | Owner: connectivityBrokerOnly, | |
| 104 | - | DirectDialDebt: []string{"remoteHostDeps.hostStore (remote_hosts.go hostAdd/hostRm)"}, | |
| 105 | - | HostStoreWrite: true, | |
| 106 | - | Notes: "Second host-store writer today; broker is the sole writer after cutover. Host list is store-only (no dial).", | |
| 107 | - | }, | |
| 108 | - | { | |
| 109 | - | Kind: "kindKill", | |
| 110 | - | Summary: "kill one session, kill-all, explicit daemon-stop", | |
| 111 | - | Owner: connectivityBrokerOnly, | |
| 112 | - | Migrated: true, | |
| 113 | - | Notes: "Cutover complete: runKill and requestDaemonStop reach the per-user broker through the private connectBroker seam (run.go) and drive BrokerOperations.Kill/KillAll/StopDaemon. Daemon-stop uses ExistingOnly, so it can never start what it is stopping; kill-all purges sessions and leaves the daemon running. The offline persisted-mutation path and the direct force-stop fallback are removed, so no request is ever bypassed with a direct process signal.", | |
| 114 | - | }, | |
| 115 | - | { | |
| 116 | - | Kind: "kindCmd", | |
| 117 | - | Summary: "scriptable command requests, incl. remote-catalog", | |
| 118 | - | Owner: connectivityBrokerOnly, | |
| 119 | - | Migrated: true, | |
| 120 | - | Notes: "Command requests use broker operations in production; the injected direct dial seam remains for focused legacy tracker tests.", | |
| 121 | - | }, | |
| 122 | - | { | |
| 123 | - | Kind: "kindUIDriver", | |
| 124 | - | Summary: "headless UI-driver attach", | |
| 125 | - | Owner: connectivityBrokerOnly, | |
| 126 | - | Migrated: true, | |
| 127 | - | Notes: "Cutover complete: the driver composes the process' production broker connector and the shared terminal initial-navigation translation through runUIDriverClient. It keeps no launch configuration, no dialer, no endpoint, and no daemon ownership.", | |
| 128 | - | }, | |
| 129 | - | { | |
| 130 | - | Kind: "kindUIRemoteCleanup", | |
| 131 | - | Summary: "UI fixture cleanup through broker control", | |
| 132 | - | Owner: connectivityBrokerOnly, | |
| 133 | - | Migrated: true, | |
| 134 | - | Notes: "Cleanup uses the broker-owned daemon-stop operation and keeps no direct daemon carriage.", | |
| 135 | - | }, | |
| 136 | - | { | |
| 137 | - | Kind: "kindWebDaemon", | |
| 138 | - | Summary: "browser gateway launcher", | |
| 139 | - | Owner: connectivityBrokerOnly, | |
| 140 | - | Migrated: true, | |
| 141 | - | Notes: "Launcher mechanics stay local. Cutover complete: the gateway server composes the shared runBrokerClient over the process' production broker connector (web_daemon.go runWebTerminalClient), so gateway session traffic goes through the broker like any client. The launcher keeps no dialer, no session carriage, and no daemon ownership.", | |
| 142 | - | }, | |
| 143 | - | { | |
| 144 | - | Kind: "kindWebServe", | |
| 145 | - | Summary: "browser gateway server", | |
| 146 | - | Owner: connectivityBrokerOnly, | |
| 147 | - | Migrated: true, | |
| 148 | - | Notes: "Cutover complete: every authenticated WebSocket owns exactly one runBrokerClient over the shared production broker connector, with the virtual webterm.Terminal as that run's terminal, the no-argument local ephemeral creation as its closed initial navigation, and the standard presentation callbacks. It keeps no direct daemon dialer, no sessionwire carriage, no remote factory, and no launch configuration; disconnect cancels only that supervisor's service and logical stream.", | |
| 149 | - | }, | |
| 150 | - | { | |
| 151 | - | Kind: "kindDaemon", | |
| 152 | - | Summary: "daemon process: owns sessions, serves typed connections", | |
| 153 | - | Owner: connectivityTransportInfra, | |
| 154 | - | Notes: "Daemon accepts one typed connection per broker logical stream after cutover. Never aggregates other daemons, never owns the host registry (P7 removal set).", | |
| 155 | - | }, | |
| 156 | - | { | |
| 157 | - | Kind: "kindDaemonLauncher", | |
| 158 | - | Summary: "short-lived daemon spawn launcher", | |
| 159 | - | Owner: connectivityTransportInfra, | |
| 160 | - | Notes: "Process mechanics only; never a connectivity façade.", | |
| 161 | - | }, | |
| 162 | - | { | |
| 163 | - | Kind: "kindWebRenew", | |
| 164 | - | Summary: "browser token renewal", | |
| 165 | - | Owner: connectivityLocalOnly, | |
| 166 | - | Notes: "Local control only; no daemon or session connectivity.", | |
| 167 | - | }, | |
| 168 | - | { | |
| 169 | - | Kind: "kindBrokerReady", | |
| 170 | - | Summary: "hidden dial-only broker readiness probe", | |
| 171 | - | Owner: connectivityLocalOnly, | |
| 172 | - | Notes: "Dials only the selected broker IPC socket, registers, subscribes, and reads snapshots; it never ensures, spawns, opens a logical stream, reconciles, or mutates.", | |
| 173 | - | }, | |
| 174 | - | { | |
| 175 | - | Kind: "kindHelp", | |
| 176 | - | Summary: "usage text", | |
| 177 | - | Owner: connectivityLocalOnly, | |
| 178 | - | Notes: "No connectivity.", | |
| 179 | - | }, | |
| 180 | - | { | |
| 181 | - | Kind: "kindVersion", | |
| 182 | - | Summary: "version line", | |
| 183 | - | Owner: connectivityLocalOnly, | |
| 184 | - | Notes: "No connectivity.", | |
| 185 | - | }, | |
| 186 | - | { | |
| 187 | - | Kind: "kindBrokerServe", | |
| 188 | - | Summary: "hidden _broker-serve isolated offline broker sandbox", | |
| 189 | - | Owner: connectivityLocalOnly, | |
| 190 | - | Notes: "Foreground sandbox process over its own private root: no production runtime/state, no daemon dial, no RemoteHostStore, and no session persistence. It composes the broker under a temporary offline config and does not become a client façade or an ordinary command.", | |
| 191 | - | }, | |
| 192 | - | { | |
| 193 | - | Kind: "kindProductionBrokerServe", | |
| 194 | - | Summary: "hidden production broker server", | |
| 195 | - | Owner: connectivityLocalOnly, | |
| 196 | - | Notes: "Foreground production broker using only productionBrokerLayout and productionBrokerConfigPath; it accepts no offline root.", | |
| 197 | - | }, | |
| 198 | - | { | |
| 199 | - | Kind: "kindBrokerClient", | |
| 200 | - | Summary: "hidden _broker-client autonomous offline client harness", | |
| 201 | - | Owner: connectivityLocalOnly, | |
| 202 | - | Notes: "Explicit offline-root composition only: real broker IPC and logical streams exercise terminal and UI harnesses without changing any ordinary command or production path.", | |
| 203 | - | }, | |
| 204 | - | { | |
| 205 | - | Kind: "kindBrokerLauncher", | |
| 206 | - | Summary: "hidden _broker-launcher detached offline broker launcher", | |
| 207 | - | Owner: connectivityLocalOnly, | |
| 208 | - | Notes: "Process mechanics only, scoped to one operator-supplied offline root: it starts _broker-serve in a new session and exits, like the daemon launcher, and never dials a daemon, owns the host store, or mutates persistence.", | |
| 209 | - | }, | |
| 210 | - | { | |
| 211 | - | Kind: "kindProductionBrokerLauncher", | |
| 212 | - | Summary: "hidden detached production broker launcher", | |
| 213 | - | Owner: connectivityLocalOnly, | |
| 214 | - | Notes: "Process mechanics for the production broker only; it accepts no offline root and starts the production serve role in a new session.", | |
| 215 | - | }, | |
| 216 | - | { | |
| 217 | - | Kind: "kindBrokerStatus", | |
| 218 | - | Summary: "hidden _broker-status offline broker probe and connect-or-spawn", | |
| 219 | - | Owner: connectivityLocalOnly, | |
| 220 | - | Notes: "Reports the offline broker's local IPC endpoint and, with --ensure, elects one spawner under a descriptor-backed lock. It dials only the operator-supplied broker sandbox socket (never a daemon or RemoteHostStore) and never becomes a client façade or ordinary command.", | |
| 221 | - | }, | |
| 222 | - | { | |
| 223 | - | Kind: "kindBrokerMuxStdio", | |
| 224 | - | Summary: "hidden _broker-mux-stdio remote SSH stdio daemonmux bridge", | |
| 225 | - | Owner: connectivityTransportInfra, | |
| 226 | - | DirectDialDebt: []string{ | |
| 227 | - | "ipc.DialMuxContext local daemonmux carriage (broker_offline_mux.go)", | |
| 228 | - | }, | |
| 229 | - | Notes: "Remote-side helper over one operator-supplied offline root: it bridges its own stdio to the single provisioned private Unix daemonmux carriage. It never starts a broker, observer, or recursive helper, never dials the production daemon socket, and never fabricates a daemon incarnation. It starts the daemon behind that carriage only under the propagated --daemon-start if-needed authorization and only when its own provisioned policy permits launching; otherwise it dials only an existing carriage. Direct-dial debt is recorded here rather than as broker debt because the helper itself stays transport infra.", | |
| 230 | - | }, | |
| 231 | - | { | |
| 232 | - | Kind: "kindBrokerMuxQUICBootstrap", | |
| 233 | - | Summary: "hidden _broker-mux-quic-bootstrap remote SSH-side QUIC bootstrap", | |
| 234 | - | Owner: connectivityTransportInfra, | |
| 235 | - | Notes: "Starts one detached _broker-mux-quic-proxy in a new session, forwards its single readiness line, and exits. Its only child is that proxy: it never starts a broker, observer, or ordinary daemon, and one-time QUIC credentials never reach an address, log, or error.", | |
| 236 | - | }, | |
| 237 | - | { | |
| 238 | - | Kind: "kindBrokerMuxQUICProxy", | |
| 239 | - | Summary: "hidden _broker-mux-quic-proxy detached remote QUIC daemonmux bridge", | |
| 240 | - | Owner: connectivityTransportInfra, | |
| 241 | - | DirectDialDebt: []string{ | |
| 242 | - | "ipc.DialMuxContext local daemonmux carriage (broker_offline_mux.go)", | |
| 243 | - | }, | |
| 244 | - | Notes: "Mints one ephemeral authenticated QUIC server, admits exactly one carriage, and bridges it to the single provisioned private Unix daemonmux carriage. It never dials the production daemon socket, never fabricates a daemon incarnation, and starts the daemon behind that carriage only under the propagated --daemon-start if-needed authorization and its own provisioned launch policy.", | |
| 245 | - | }, | |
| 246 | - | } | |
| @@ -1,246 +0,0 @@ | |||||
| 1 | - | package app | |||
| 2 | - | ||||
| 3 | - | // Connectivity migration matrix. | |||
| 4 | - | // | |||
| 5 | - | // Every CLI entry point has exactly one owner. Ordinary user-facing | |||
| 6 | - | // operations reach daemons through the per-user | |||
| 7 | - | // connection broker; narrowly scoped daemon-facing transport helpers stay | |||
| 8 | - | // infrastructure and never become alternate client façades. | |||
| 9 | - | // | |||
| 10 | - | // Ownership rules pinned here: | |||
| 11 | - | // - Broker-only entries must not keep a direct daemon dialer or a second | |||
| 12 | - | // host-store writer after cutover. Their CurrentDebt lists today's | |||
| 13 | - | // direct-connectivity symbols to remove in the same change that | |||
| 14 | - | // activates the broker owner. | |||
| 15 | - | // - Broker never owns session persistence. Offline session mutation/list | |||
| 16 | - | // paths (PersistMutation) become daemon-owned operations reached through | |||
| 17 | - | // the broker; the persist/snapshot packages stay daemon-side. | |||
| 18 | - | // - Observation never starts a broker, never undoes an explicit | |||
| 19 | - | // daemon-stop, and never creates a session attachment. | |||
| 20 | - | // - Transport helpers terminate transport only. They never own host | |||
| 21 | - | // configuration, navigation, sessions, or picker state. | |||
| 22 | - | // | |||
| 23 | - | // The accompanying test fails when a command kind lacks a row, when a | |||
| 24 | - | // broker-only row declares no migration debt, when an infra row gains store | |||
| 25 | - | // or persistence ownership, and when direct-dial, host-store, or offline | |||
| 26 | - | // persistence symbols appear in files outside the declared debt set. | |||
| 27 | - | ||||
| 28 | - | type connectivityOwner uint8 | |||
| 29 | - | ||||
| 30 | - | const ( | |||
| 31 | - | // connectivityBrokerOnly marks ordinary user-facing operations that go | |||
| 32 | - | // through the broker façade after cutover. | |||
| 33 | - | connectivityBrokerOnly connectivityOwner = iota + 1 | |||
| 34 | - | // connectivityTransportInfra marks narrowly scoped daemon-facing | |||
| 35 | - | // transport helpers: process mechanics and carriage terminators that | |||
| 36 | - | // must never become client façades. | |||
| 37 | - | connectivityTransportInfra | |||
| 38 | - | // connectivityLocalOnly marks commands with no daemon or session | |||
| 39 | - | // connectivity at all. | |||
| 40 | - | connectivityLocalOnly | |||
| 41 | - | ) | |||
| 42 | - | ||||
| 43 | - | func (o connectivityOwner) String() string { | |||
| 44 | - | switch o { | |||
| 45 | - | case connectivityBrokerOnly: | |||
| 46 | - | return "broker-only" | |||
| 47 | - | case connectivityTransportInfra: | |||
| 48 | - | return "transport-infra" | |||
| 49 | - | case connectivityLocalOnly: | |||
| 50 | - | return "local-only" | |||
| 51 | - | default: | |||
| 52 | - | return "unknown" | |||
| 53 | - | } | |||
| 54 | - | } | |||
| 55 | - | ||||
| 56 | - | // connectivityEntry is one row of the migration matrix. Kind is the cmdKind | |||
| 57 | - | // identifier from run.go (for example "kindAttach"). | |||
| 58 | - | type connectivityEntry struct { | |||
| 59 | - | Kind string | |||
| 60 | - | // Summary names the user-visible operation. | |||
| 61 | - | Summary string | |||
| 62 | - | Owner connectivityOwner | |||
| 63 | - | // DirectDialDebt lists today's direct-daemon dial symbols that the | |||
| 64 | - | // cutover removes for broker-only entries. Empty for infra/local rows and | |||
| 65 | - | // for a broker-only row that has already completed its cutover. | |||
| 66 | - | DirectDialDebt []string | |||
| 67 | - | // Migrated marks a broker-only row whose cutover is complete: it keeps no | |||
| 68 | - | // direct dial, no host-store write, and no persist mutation, so its debt | |||
| 69 | - | // list is deliberately empty. It exists so a finished row is declared | |||
| 70 | - | // explicitly instead of being mistaken for an unreviewed one. | |||
| 71 | - | Migrated bool | |||
| 72 | - | // HostStoreWrite reports a RemoteHostStore constructor/writer today. | |||
| 73 | - | // Only the broker owns the host store after cutover. | |||
| 74 | - | HostStoreWrite bool | |||
| 75 | - | // PersistMutation reports an offline session persistence read/write | |||
| 76 | - | // today. These become daemon-owned operations through the broker. | |||
| 77 | - | PersistMutation bool | |||
| 78 | - | // Notes records cutover direction and non-obvious boundaries. | |||
| 79 | - | Notes string | |||
| 80 | - | } | |||
| 81 | - | ||||
| 82 | - | // connectivityMatrix classifies every cmdKind exactly once. | |||
| 83 | - | var connectivityMatrix = []connectivityEntry{ | |||
| 84 | - | { | |||
| 85 | - | Kind: "kindAttach", | |||
| 86 | - | Summary: "interactive terminal attach/new/ephemeral, incl. remote attach, detached creation, and the UI-driver/web path", | |||
| 87 | - | Owner: connectivityBrokerOnly, | |||
| 88 | - | DirectDialDebt: []string{ | |||
| 89 | - | "ensureDaemonWithLifecycle detached creation (run.go createDetachedLocalSession)", | |||
| 90 | - | }, | |||
| 91 | - | Notes: "The ordinary terminal path is already broker-only: runAttach translates the parsed CLI target into client.InitialNavigation/Resolver, connect-or-spawns the per-user broker, and delegates to the shared runBrokerClient (run.go, broker_client.go). The old direct-dialer attach composition and its attach preflight and client host registry are removed; the UI-driver and browser compositions delegate to the shared broker client. The only remaining debt is the nested-session detached creation path, which still dials the local daemon directly and is expressed as broker navigation for ordinary attach.", | |||
| 92 | - | }, | |||
| 93 | - | { | |||
| 94 | - | Kind: "kindList", | |||
| 95 | - | Summary: "session list: local, per-host, and --all", | |||
| 96 | - | Owner: connectivityBrokerOnly, | |||
| 97 | - | Migrated: true, | |||
| 98 | - | Notes: "Cutover complete: runList reaches the per-user broker through the private connectBroker seam (run.go) and reads BrokerOperations.List for the local list or runBrokerSnapshotList for per-host/--all. It keeps no persist read, no daemon dialer, and no direct-dial list path. Remote observation never attaches.", | |||
| 99 | - | }, | |||
| 100 | - | { | |||
| 101 | - | Kind: "kindHost", | |||
| 102 | - | Summary: "host add/rm/list", | |||
| 103 | - | Owner: connectivityBrokerOnly, | |||
| 104 | - | DirectDialDebt: []string{"remoteHostDeps.hostStore (remote_hosts.go hostAdd/hostRm)"}, | |||
| 105 | - | HostStoreWrite: true, | |||
| 106 | - | Notes: "Second host-store writer today; broker is the sole writer after cutover. Host list is store-only (no dial).", | |||
| 107 | - | }, | |||
| 108 | - | { | |||
| 109 | - | Kind: "kindKill", | |||
| 110 | - | Summary: "kill one session, kill-all, explicit daemon-stop", | |||
| 111 | - | Owner: connectivityBrokerOnly, | |||
| 112 | - | Migrated: true, | |||
| 113 | - | Notes: "Cutover complete: runKill and requestDaemonStop reach the per-user broker through the private connectBroker seam (run.go) and drive BrokerOperations.Kill/KillAll/StopDaemon. Daemon-stop uses ExistingOnly, so it can never start what it is stopping; kill-all purges sessions and leaves the daemon running. The offline persisted-mutation path and the direct force-stop fallback are removed, so no request is ever bypassed with a direct process signal.", | |||
| 114 | - | }, | |||
| 115 | - | { | |||
| 116 | - | Kind: "kindCmd", | |||
| 117 | - | Summary: "scriptable command requests, incl. remote-catalog", | |||
| 118 | - | Owner: connectivityBrokerOnly, | |||
| 119 | - | Migrated: true, | |||
| 120 | - | Notes: "Command requests use broker operations in production; the injected direct dial seam remains for focused legacy tracker tests.", | |||
| 121 | - | }, | |||
| 122 | - | { | |||
| 123 | - | Kind: "kindUIDriver", | |||
| 124 | - | Summary: "headless UI-driver attach", | |||
| 125 | - | Owner: connectivityBrokerOnly, | |||
| 126 | - | Migrated: true, | |||
| 127 | - | Notes: "Cutover complete: the driver composes the process' production broker connector and the shared terminal initial-navigation translation through runUIDriverClient. It keeps no launch configuration, no dialer, no endpoint, and no daemon ownership.", | |||
| 128 | - | }, | |||
| 129 | - | { | |||
| 130 | - | Kind: "kindUIRemoteCleanup", | |||
| 131 | - | Summary: "UI fixture cleanup through broker control", | |||
| 132 | - | Owner: connectivityBrokerOnly, | |||
| 133 | - | Migrated: true, | |||
| 134 | - | Notes: "Cleanup uses the broker-owned daemon-stop operation and keeps no direct daemon carriage.", | |||
| 135 | - | }, | |||
| 136 | - | { | |||
| 137 | - | Kind: "kindWebDaemon", | |||
| 138 | - | Summary: "browser gateway launcher", | |||
| 139 | - | Owner: connectivityBrokerOnly, | |||
| 140 | - | Migrated: true, | |||
| 141 | - | Notes: "Launcher mechanics stay local. Cutover complete: the gateway server composes the shared runBrokerClient over the process' production broker connector (web_daemon.go runWebTerminalClient), so gateway session traffic goes through the broker like any client. The launcher keeps no dialer, no session carriage, and no daemon ownership.", | |||
| 142 | - | }, | |||
| 143 | - | { | |||
| 144 | - | Kind: "kindWebServe", | |||
| 145 | - | Summary: "browser gateway server", | |||
| 146 | - | Owner: connectivityBrokerOnly, | |||
| 147 | - | Migrated: true, | |||
| 148 | - | Notes: "Cutover complete: every authenticated WebSocket owns exactly one runBrokerClient over the shared production broker connector, with the virtual webterm.Terminal as that run's terminal, the no-argument local ephemeral creation as its closed initial navigation, and the standard presentation callbacks. It keeps no direct daemon dialer, no sessionwire carriage, no remote factory, and no launch configuration; disconnect cancels only that supervisor's service and logical stream.", | |||
| 149 | - | }, | |||
| 150 | - | { | |||
| 151 | - | Kind: "kindDaemon", | |||
| 152 | - | Summary: "daemon process: owns sessions, serves typed connections", | |||
| 153 | - | Owner: connectivityTransportInfra, | |||
| 154 | - | Notes: "Daemon accepts one typed connection per broker logical stream after cutover. Never aggregates other daemons, never owns the host registry (P7 removal set).", | |||
| 155 | - | }, | |||
| 156 | - | { | |||
| 157 | - | Kind: "kindDaemonLauncher", | |||
| 158 | - | Summary: "short-lived daemon spawn launcher", | |||
| 159 | - | Owner: connectivityTransportInfra, | |||
| 160 | - | Notes: "Process mechanics only; never a connectivity façade.", | |||
| 161 | - | }, | |||
| 162 | - | { | |||
| 163 | - | Kind: "kindWebRenew", | |||
| 164 | - | Summary: "browser token renewal", | |||
| 165 | - | Owner: connectivityLocalOnly, | |||
| 166 | - | Notes: "Local control only; no daemon or session connectivity.", | |||
| 167 | - | }, | |||
| 168 | - | { | |||
| 169 | - | Kind: "kindBrokerReady", | |||
| 170 | - | Summary: "hidden dial-only broker readiness probe", | |||
| 171 | - | Owner: connectivityLocalOnly, | |||
| 172 | - | Notes: "Dials only the selected broker IPC socket, registers, subscribes, and reads snapshots; it never ensures, spawns, opens a logical stream, reconciles, or mutates.", | |||
| 173 | - | }, | |||
| 174 | - | { | |||
| 175 | - | Kind: "kindHelp", | |||
| 176 | - | Summary: "usage text", | |||
| 177 | - | Owner: connectivityLocalOnly, | |||
| 178 | - | Notes: "No connectivity.", | |||
| 179 | - | }, | |||
| 180 | - | { | |||
| 181 | - | Kind: "kindVersion", | |||
| 182 | - | Summary: "version line", | |||
| 183 | - | Owner: connectivityLocalOnly, | |||
| 184 | - | Notes: "No connectivity.", | |||
| 185 | - | }, | |||
| 186 | - | { | |||
| 187 | - | Kind: "kindBrokerServe", | |||
| 188 | - | Summary: "hidden _broker-serve isolated offline broker sandbox", | |||
| 189 | - | Owner: connectivityLocalOnly, | |||
| 190 | - | Notes: "Foreground sandbox process over its own private root: no production runtime/state, no daemon dial, no RemoteHostStore, and no session persistence. It composes the broker under a temporary offline config and does not become a client façade or an ordinary command.", | |||
| 191 | - | }, | |||
| 192 | - | { | |||
| 193 | - | Kind: "kindProductionBrokerServe", | |||
| 194 | - | Summary: "hidden production broker server", | |||
| 195 | - | Owner: connectivityLocalOnly, | |||
| 196 | - | Notes: "Foreground production broker using only productionBrokerLayout and productionBrokerConfigPath; it accepts no offline root.", | |||
| 197 | - | }, | |||
| 198 | - | { | |||
| 199 | - | Kind: "kindBrokerClient", | |||
| 200 | - | Summary: "hidden _broker-client autonomous offline client harness", | |||
| 201 | - | Owner: connectivityLocalOnly, | |||
| 202 | - | Notes: "Explicit offline-root composition only: real broker IPC and logical streams exercise terminal and UI harnesses without changing any ordinary command or production path.", | |||
| 203 | - | }, | |||
| 204 | - | { | |||
| 205 | - | Kind: "kindBrokerLauncher", | |||
| 206 | - | Summary: "hidden _broker-launcher detached offline broker launcher", | |||
| 207 | - | Owner: connectivityLocalOnly, | |||
| 208 | - | Notes: "Process mechanics only, scoped to one operator-supplied offline root: it starts _broker-serve in a new session and exits, like the daemon launcher, and never dials a daemon, owns the host store, or mutates persistence.", | |||
| 209 | - | }, | |||
| 210 | - | { | |||
| 211 | - | Kind: "kindProductionBrokerLauncher", | |||
| 212 | - | Summary: "hidden detached production broker launcher", | |||
| 213 | - | Owner: connectivityLocalOnly, | |||
| 214 | - | Notes: "Process mechanics for the production broker only; it accepts no offline root and starts the production serve role in a new session.", | |||
| 215 | - | }, | |||
| 216 | - | { | |||
| 217 | - | Kind: "kindBrokerStatus", | |||
| 218 | - | Summary: "hidden _broker-status offline broker probe and connect-or-spawn", | |||
| 219 | - | Owner: connectivityLocalOnly, | |||
| 220 | - | Notes: "Reports the offline broker's local IPC endpoint and, with --ensure, elects one spawner under a descriptor-backed lock. It dials only the operator-supplied broker sandbox socket (never a daemon or RemoteHostStore) and never becomes a client façade or ordinary command.", | |||
| 221 | - | }, | |||
| 222 | - | { | |||
| 223 | - | Kind: "kindBrokerMuxStdio", | |||
| 224 | - | Summary: "hidden _broker-mux-stdio remote SSH stdio daemonmux bridge", | |||
| 225 | - | Owner: connectivityTransportInfra, | |||
| 226 | - | DirectDialDebt: []string{ | |||
| 227 | - | "ipc.DialMuxContext local daemonmux carriage (broker_offline_mux.go)", | |||
| 228 | - | }, | |||
| 229 | - | Notes: "Remote-side helper over one operator-supplied offline root: it bridges its own stdio to the single provisioned private Unix daemonmux carriage. It never starts a broker, observer, or recursive helper, never dials the production daemon socket, and never fabricates a daemon incarnation. It starts the daemon behind that carriage only under the propagated --daemon-start if-needed authorization and only when its own provisioned policy permits launching; otherwise it dials only an existing carriage. Direct-dial debt is recorded here rather than as broker debt because the helper itself stays transport infra.", | |||
| 230 | - | }, | |||
| 231 | - | { | |||
| 232 | - | Kind: "kindBrokerMuxQUICBootstrap", | |||
| 233 | - | Summary: "hidden _broker-mux-quic-bootstrap remote SSH-side QUIC bootstrap", | |||
| 234 | - | Owner: connectivityTransportInfra, | |||
| 235 | - | Notes: "Starts one detached _broker-mux-quic-proxy in a new session, forwards its single readiness line, and exits. Its only child is that proxy: it never starts a broker, observer, or ordinary daemon, and one-time QUIC credentials never reach an address, log, or error.", | |||
| 236 | - | }, | |||
| 237 | - | { | |||
| 238 | - | Kind: "kindBrokerMuxQUICProxy", | |||
| 239 | - | Summary: "hidden _broker-mux-quic-proxy detached remote QUIC daemonmux bridge", | |||
| 240 | - | Owner: connectivityTransportInfra, | |||
| 241 | - | DirectDialDebt: []string{ | |||
| 242 | - | "ipc.DialMuxContext local daemonmux carriage (broker_offline_mux.go)", | |||
| 243 | - | }, | |||
| 244 | - | Notes: "Mints one ephemeral authenticated QUIC server, admits exactly one carriage, and bridges it to the single provisioned private Unix daemonmux carriage. It never dials the production daemon socket, never fabricates a daemon incarnation, and starts the daemon behind that carriage only under the propagated --daemon-start if-needed authorization and its own provisioned launch policy.", | |||
| 245 | - | }, | |||
| 246 | - | } | |||
D internal/app/connectivity_matrix_test.go +0 -331
| @@ -1,331 +0,0 @@ | |||
| 1 | - | package app | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "go/ast" | |
| 5 | - | "go/parser" | |
| 6 | - | "go/token" | |
| 7 | - | "os" | |
| 8 | - | "path/filepath" | |
| 9 | - | "regexp" | |
| 10 | - | "sort" | |
| 11 | - | "strings" | |
| 12 | - | "testing" | |
| 13 | - | ||
| 14 | - | "github.com/stretchr/testify/require" | |
| 15 | - | ) | |
| 16 | - | ||
| 17 | - | // testConnectivityOwnerFor looks up the matrix owner for a cmdKind identifier | |
| 18 | - | // directly against connectivityMatrix, the same declarative fixture every | |
| 19 | - | // other pinning test in this file ranges over. | |
| 20 | - | func testConnectivityOwnerFor(kind string) (connectivityOwner, bool) { | |
| 21 | - | for _, entry := range connectivityMatrix { | |
| 22 | - | if entry.Kind == kind { | |
| 23 | - | return entry.Owner, true | |
| 24 | - | } | |
| 25 | - | } | |
| 26 | - | return 0, false | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | // TestConnectivityMatrixCoversEveryCommandKind pins P1.2: every cmdKind | |
| 30 | - | // constant declared in run.go has exactly one matrix row. A new CLI entry | |
| 31 | - | // point without a row fails here before any transport work can ignore it. | |
| 32 | - | func TestConnectivityMatrixCoversEveryCommandKind(t *testing.T) { | |
| 33 | - | t.Parallel() | |
| 34 | - | ||
| 35 | - | kinds := commandKindsFromRunGo(t) | |
| 36 | - | require.NotEmpty(t, kinds, "run.go must declare cmdKind constants") | |
| 37 | - | ||
| 38 | - | seen := make(map[string]int, len(connectivityMatrix)) | |
| 39 | - | for _, entry := range connectivityMatrix { | |
| 40 | - | require.NotEmpty(t, entry.Kind, "matrix row must name a cmdKind") | |
| 41 | - | require.NotEmpty(t, entry.Summary, "matrix row %q must summarize the operation", entry.Kind) | |
| 42 | - | require.NotEmpty(t, entry.Notes, "matrix row %q must record cutover direction", entry.Kind) | |
| 43 | - | seen[entry.Kind]++ | |
| 44 | - | } | |
| 45 | - | for kind, count := range seen { | |
| 46 | - | require.Equal(t, 1, count, "matrix row %q must appear exactly once", kind) | |
| 47 | - | } | |
| 48 | - | var missing, extra []string | |
| 49 | - | for _, kind := range kinds { | |
| 50 | - | if _, ok := seen[kind]; !ok { | |
| 51 | - | missing = append(missing, kind) | |
| 52 | - | } | |
| 53 | - | } | |
| 54 | - | kindSet := make(map[string]struct{}, len(kinds)) | |
| 55 | - | for _, kind := range kinds { | |
| 56 | - | kindSet[kind] = struct{}{} | |
| 57 | - | } | |
| 58 | - | for kind := range seen { | |
| 59 | - | if _, ok := kindSet[kind]; !ok { | |
| 60 | - | extra = append(extra, kind) | |
| 61 | - | } | |
| 62 | - | } | |
| 63 | - | sort.Strings(missing) | |
| 64 | - | sort.Strings(extra) | |
| 65 | - | require.Empty(t, missing, "matrix is missing cmdKind rows") | |
| 66 | - | require.Empty(t, extra, "matrix references unknown cmdKind rows") | |
| 67 | - | ||
| 68 | - | owner, ok := testConnectivityOwnerFor("kindAttach") | |
| 69 | - | require.True(t, ok, "matrix lookup must resolve known kinds") | |
| 70 | - | require.Equal(t, connectivityBrokerOnly, owner) | |
| 71 | - | _, ok = testConnectivityOwnerFor("kindDoesNotExist") | |
| 72 | - | require.False(t, ok, "matrix lookup must reject unknown kinds") | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | // TestConnectivityMatrixOwnershipRules pins the P1.2 ownership invariants: | |
| 76 | - | // broker-only rows must declare migration debt, infra rows must never gain | |
| 77 | - | // store or persistence ownership, and local-only rows carry no debt. | |
| 78 | - | func TestConnectivityMatrixOwnershipRules(t *testing.T) { | |
| 79 | - | t.Parallel() | |
| 80 | - | ||
| 81 | - | tests := make([]struct { | |
| 82 | - | name string | |
| 83 | - | entry connectivityEntry | |
| 84 | - | }, 0, len(connectivityMatrix)) | |
| 85 | - | for _, entry := range connectivityMatrix { | |
| 86 | - | tests = append(tests, struct { | |
| 87 | - | name string | |
| 88 | - | entry connectivityEntry | |
| 89 | - | }{name: entry.Kind, entry: entry}) | |
| 90 | - | } | |
| 91 | - | for _, tt := range tests { | |
| 92 | - | tt := tt | |
| 93 | - | t.Run(tt.name, func(t *testing.T) { | |
| 94 | - | t.Parallel() | |
| 95 | - | entry := tt.entry | |
| 96 | - | switch entry.Owner { | |
| 97 | - | case connectivityBrokerOnly: | |
| 98 | - | hasDebt := len(entry.DirectDialDebt) > 0 || entry.HostStoreWrite || entry.PersistMutation | |
| 99 | - | if entry.Migrated { | |
| 100 | - | require.False(t, hasDebt, "migrated broker-only %q must declare no remaining migration debt", entry.Kind) | |
| 101 | - | break | |
| 102 | - | } | |
| 103 | - | require.True(t, hasDebt, "broker-only %q must declare migration debt (direct dial, host-store write, or persist mutation)", entry.Kind) | |
| 104 | - | case connectivityTransportInfra: | |
| 105 | - | require.False(t, entry.HostStoreWrite, "infra %q must never own the host store", entry.Kind) | |
| 106 | - | require.False(t, entry.PersistMutation, "infra %q must never own session persistence", entry.Kind) | |
| 107 | - | if len(entry.DirectDialDebt) > 0 { | |
| 108 | - | require.NotEmpty(t, entry.Notes, "infra %q with local dial debt must justify why it stays infra", entry.Kind) | |
| 109 | - | } | |
| 110 | - | case connectivityLocalOnly: | |
| 111 | - | require.Empty(t, entry.DirectDialDebt, "local-only %q must carry no dial debt", entry.Kind) | |
| 112 | - | require.False(t, entry.HostStoreWrite, "local-only %q must never own the host store", entry.Kind) | |
| 113 | - | require.False(t, entry.PersistMutation, "local-only %q must never own session persistence", entry.Kind) | |
| 114 | - | default: | |
| 115 | - | t.Fatalf("row %q has unknown owner %v", entry.Kind, entry.Owner) | |
| 116 | - | } | |
| 117 | - | for _, debt := range entry.DirectDialDebt { | |
| 118 | - | require.NotEmpty(t, debt, "row %q has an empty debt entry", entry.Kind) | |
| 119 | - | } | |
| 120 | - | }) | |
| 121 | - | } | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | // TestConnectivityMatrixDebtFilesExist keeps the matrix honest: every file | |
| 125 | - | // named in a debt entry must exist under internal/app. | |
| 126 | - | func TestConnectivityMatrixDebtFilesExist(t *testing.T) { | |
| 127 | - | t.Parallel() | |
| 128 | - | ||
| 129 | - | fileRef := regexp.MustCompile(`([a-z_]+\.go)`) | |
| 130 | - | for _, entry := range connectivityMatrix { | |
| 131 | - | for _, debt := range entry.DirectDialDebt { | |
| 132 | - | for _, match := range fileRef.FindAllStringSubmatch(debt, -1) { | |
| 133 | - | path := filepath.Join("internal", "app", match[1]) | |
| 134 | - | // Tests run with the package dir as CWD, so resolve from there. | |
| 135 | - | if _, err := os.Stat(match[1]); err == nil { | |
| 136 | - | continue | |
| 137 | - | } | |
| 138 | - | if _, err := os.Stat(path); err == nil { | |
| 139 | - | continue | |
| 140 | - | } | |
| 141 | - | t.Errorf("row %q references missing file %q (debt %q)", entry.Kind, match[1], debt) | |
| 142 | - | } | |
| 143 | - | } | |
| 144 | - | } | |
| 145 | - | } | |
| 146 | - | ||
| 147 | - | // TestConnectivityMatrixCurrentDebtPresent pins today's direct-connectivity | |
| 148 | - | // debt so the P7 cutover can find every site it must remove in the same | |
| 149 | - | // change that activates the broker owner. If any of these symbols disappear, | |
| 150 | - | // update the matrix in the same change. | |
| 151 | - | func TestConnectivityMatrixCurrentDebtPresent(t *testing.T) { | |
| 152 | - | t.Parallel() | |
| 153 | - | ||
| 154 | - | sources := loadAppSources(t, false) | |
| 155 | - | ||
| 156 | - | assertContains := func(symbol, file string) { | |
| 157 | - | t.Helper() | |
| 158 | - | body, ok := sources[file] | |
| 159 | - | require.True(t, ok, "expected app source file %q", file) | |
| 160 | - | require.Contains(t, body, symbol, "expected debt symbol %q in %q", symbol, file) | |
| 161 | - | } | |
| 162 | - | ||
| 163 | - | assertContains("func ensureDaemonWithLifecycle", "spawn.go") | |
| 164 | - | assertContains("func realDial", "spawn.go") | |
| 165 | - | assertContains("func realSpawn", "spawn.go") | |
| 166 | - | } | |
| 167 | - | ||
| 168 | - | // TestConnectivityMatrixListKillStopAreBrokerOnly pins the P7.4f cutover for | |
| 169 | - | // the list/kill/stop rows: each reaches the per-user broker through the private | |
| 170 | - | // connectBroker seam and keeps no removed bypass, so runList and runKill/stop | |
| 171 | - | // can never read durable state or signal a process directly. | |
| 172 | - | func TestConnectivityMatrixListKillStopAreBrokerOnly(t *testing.T) { | |
| 173 | - | t.Parallel() | |
| 174 | - | ||
| 175 | - | sources := loadAppSources(t, false) | |
| 176 | - | runBody, ok := sources["run.go"] | |
| 177 | - | require.True(t, ok, "expected app source file run.go") | |
| 178 | - | require.Contains(t, runBody, "var connectBroker = connectProductionBroker", | |
| 179 | - | "run.go must keep the private connectBroker composition seam") | |
| 180 | - | require.Contains(t, runBody, "func runList", "run.go must keep runList") | |
| 181 | - | require.Contains(t, runBody, "func runKill", "run.go must keep runKill") | |
| 182 | - | require.Contains(t, runBody, "func requestDaemonStop", "run.go must keep requestDaemonStop") | |
| 183 | - | ||
| 184 | - | forbiddenFiles := map[string][]string{ | |
| 185 | - | "run.go": { | |
| 186 | - | "runOfflineNamedKill", "persist.LoadReadOnly", "forceStopDaemonFallback", | |
| 187 | - | "forceStopDaemonFn", "waitForDaemonOrLifecycle", "waitForLifecycleAvailability", | |
| 188 | - | }, | |
| 189 | - | } | |
| 190 | - | for file, forbidden := range forbiddenFiles { | |
| 191 | - | body, ok := sources[file] | |
| 192 | - | require.True(t, ok, "expected app source file %q", file) | |
| 193 | - | for _, symbol := range forbidden { | |
| 194 | - | require.NotContains(t, body, symbol, | |
| 195 | - | "%s must not reintroduce the removed bypass %q", file, symbol) | |
| 196 | - | } | |
| 197 | - | } | |
| 198 | - | ||
| 199 | - | // The whole force-stop subsystem is removed, so no production file may | |
| 200 | - | // resurrect an interactive process-signal fallback. | |
| 201 | - | for file, body := range sources { | |
| 202 | - | require.NotContains(t, body, "forceStop", | |
| 203 | - | "the force-stop subsystem must stay removed (%s)", file) | |
| 204 | - | } | |
| 205 | - | } | |
| 206 | - | ||
| 207 | - | // TestConnectivityMatrixSingleHostStoreWriterSet fails when a new | |
| 208 | - | // RemoteHostStore constructor call site appears outside the declared set. | |
| 209 | - | // After cutover the broker is the sole writer; until then the matrix must | |
| 210 | - | // name every writer so none is missed. | |
| 211 | - | func TestConnectivityMatrixSingleHostStoreWriterSet(t *testing.T) { | |
| 212 | - | t.Parallel() | |
| 213 | - | ||
| 214 | - | sources := loadAppSources(t, false) | |
| 215 | - | var writers []string | |
| 216 | - | for file, body := range sources { | |
| 217 | - | if strings.Contains(body, "NewFileHostStore") { | |
| 218 | - | writers = append(writers, file) | |
| 219 | - | } | |
| 220 | - | } | |
| 221 | - | sort.Strings(writers) | |
| 222 | - | require.Empty(t, writers, | |
| 223 | - | "the broker is the sole host-store owner; app must not construct another writer") | |
| 224 | - | } | |
| 225 | - | ||
| 226 | - | // TestConnectivityMatrixReadyProbeIsLocalDialOnly pins the kindBrokerReady row | |
| 227 | - | // and its production source: the hidden readiness probe is local-only, reaches | |
| 228 | - | // no daemon or store, and its production body dials, subscribes, and reads only. | |
| 229 | - | // It must never ensure or spawn a broker, open a logical stream, request a | |
| 230 | - | // reconcile, or mutate membership; each of those would turn a machine probe | |
| 231 | - | // into a client façade that changes the state it reports. | |
| 232 | - | func TestConnectivityMatrixReadyProbeIsLocalDialOnly(t *testing.T) { | |
| 233 | - | t.Parallel() | |
| 234 | - | ||
| 235 | - | owner, ok := testConnectivityOwnerFor("kindBrokerReady") | |
| 236 | - | require.True(t, ok, "the readiness probe must have a matrix row") | |
| 237 | - | require.Equal(t, connectivityLocalOnly, owner, "the readiness probe is dial-only local infrastructure") | |
| 238 | - | for _, entry := range connectivityMatrix { | |
| 239 | - | if entry.Kind != "kindBrokerReady" { | |
| 240 | - | continue | |
| 241 | - | } | |
| 242 | - | require.Empty(t, entry.DirectDialDebt, "a dial-only probe owes no connectivity migration") | |
| 243 | - | require.False(t, entry.HostStoreWrite, "the readiness probe never owns the host store") | |
| 244 | - | require.False(t, entry.PersistMutation, "the readiness probe never mutates session persistence") | |
| 245 | - | } | |
| 246 | - | ||
| 247 | - | sources := loadAppSources(t, false) | |
| 248 | - | body, ok := sources["broker_ready.go"] | |
| 249 | - | require.True(t, ok, "expected app source file broker_ready.go") | |
| 250 | - | ||
| 251 | - | forbidden := []struct { | |
| 252 | - | symbol string | |
| 253 | - | reason string | |
| 254 | - | }{ | |
| 255 | - | {"ensureBrokerReady", "a readiness probe must never ensure a broker"}, | |
| 256 | - | {"ensureDaemonWithLifecycle", "a readiness probe must never start a daemon"}, | |
| 257 | - | {"spawnBrokerLauncher", "a readiness probe must never spawn a broker"}, | |
| 258 | - | {"spawnProductionBrokerLauncher", "a readiness probe must never spawn a broker"}, | |
| 259 | - | {"realDial", "a readiness probe dials the broker endpoint only"}, | |
| 260 | - | {"OpenStream", "a readiness probe opens no logical stream"}, | |
| 261 | - | {"RequestReconcile", "a readiness probe requests no reconcile"}, | |
| 262 | - | {"AddHost", "a readiness probe mutates no membership"}, | |
| 263 | - | {"RemoveHost", "a readiness probe mutates no membership"}, | |
| 264 | - | {"UpdateHostPolicy", "a readiness probe mutates no membership"}, | |
| 265 | - | {"NewFileHostStore", "a readiness probe owns no host store"}, | |
| 266 | - | } | |
| 267 | - | for _, forbidden := range forbidden { | |
| 268 | - | require.NotContains(t, body, forbidden.symbol, "%s (%s)", forbidden.symbol, forbidden.reason) | |
| 269 | - | } | |
| 270 | - | ||
| 271 | - | // Positive pins: the probe is one subscription over one snapshot loop, and | |
| 272 | - | // it classifies terminal failures without retrying them. | |
| 273 | - | for _, required := range []string{"func runBrokerReady", "func observeBrokerReady", "service.Subscribe()", "service.Snapshot()", "terminalReadyReason", "brokerReadyEndpointSecurity"} { | |
| 274 | - | require.Contains(t, body, required, "broker_ready.go must keep %s", required) | |
| 275 | - | } | |
| 276 | - | } | |
| 277 | - | ||
| 278 | - | // commandKindsFromRunGo extracts the kind* const names from run.go so the | |
| 279 | - | // matrix test tracks the real dispatch table instead of a hardcoded copy. | |
| 280 | - | func commandKindsFromRunGo(t *testing.T) []string { | |
| 281 | - | t.Helper() | |
| 282 | - | ||
| 283 | - | fset := token.NewFileSet() | |
| 284 | - | file, err := parser.ParseFile(fset, "run.go", nil, 0) | |
| 285 | - | require.NoError(t, err, "parse run.go for cmdKind constants") | |
| 286 | - | ||
| 287 | - | var kinds []string | |
| 288 | - | for _, decl := range file.Decls { | |
| 289 | - | gen, ok := decl.(*ast.GenDecl) | |
| 290 | - | if !ok || gen.Tok != token.CONST { | |
| 291 | - | continue | |
| 292 | - | } | |
| 293 | - | for _, spec := range gen.Specs { | |
| 294 | - | value, ok := spec.(*ast.ValueSpec) | |
| 295 | - | if !ok { | |
| 296 | - | continue | |
| 297 | - | } | |
| 298 | - | for _, name := range value.Names { | |
| 299 | - | if strings.HasPrefix(name.Name, "kind") { | |
| 300 | - | kinds = append(kinds, name.Name) | |
| 301 | - | } | |
| 302 | - | } | |
| 303 | - | } | |
| 304 | - | } | |
| 305 | - | sort.Strings(kinds) | |
| 306 | - | return kinds | |
| 307 | - | } | |
| 308 | - | ||
| 309 | - | // loadAppSources reads production (non-test) .go files in this package. | |
| 310 | - | // When includeTests is false, _test.go files are skipped. | |
| 311 | - | func loadAppSources(t *testing.T, includeTests bool) map[string]string { | |
| 312 | - | t.Helper() | |
| 313 | - | ||
| 314 | - | entries, err := os.ReadDir(".") | |
| 315 | - | require.NoError(t, err, "read app package dir") | |
| 316 | - | sources := make(map[string]string) | |
| 317 | - | for _, entry := range entries { | |
| 318 | - | name := entry.Name() | |
| 319 | - | if entry.IsDir() || !strings.HasSuffix(name, ".go") { | |
| 320 | - | continue | |
| 321 | - | } | |
| 322 | - | if !includeTests && strings.HasSuffix(name, "_test.go") { | |
| 323 | - | continue | |
| 324 | - | } | |
| 325 | - | raw, err := os.ReadFile(name) | |
| 326 | - | require.NoError(t, err, "read app source %q", name) | |
| 327 | - | sources[name] = string(raw) | |
| 328 | - | } | |
| 329 | - | require.NotEmpty(t, sources, "app sources must not be empty") | |
| 330 | - | return sources | |
| 331 | - | } | |
| @@ -1,331 +0,0 @@ | |||||
| 1 | - | package app | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "go/ast" | |||
| 5 | - | "go/parser" | |||
| 6 | - | "go/token" | |||
| 7 | - | "os" | |||
| 8 | - | "path/filepath" | |||
| 9 | - | "regexp" | |||
| 10 | - | "sort" | |||
| 11 | - | "strings" | |||
| 12 | - | "testing" | |||
| 13 | - | ||||
| 14 | - | "github.com/stretchr/testify/require" | |||
| 15 | - | ) | |||
| 16 | - | ||||
| 17 | - | // testConnectivityOwnerFor looks up the matrix owner for a cmdKind identifier | |||
| 18 | - | // directly against connectivityMatrix, the same declarative fixture every | |||
| 19 | - | // other pinning test in this file ranges over. | |||
| 20 | - | func testConnectivityOwnerFor(kind string) (connectivityOwner, bool) { | |||
| 21 | - | for _, entry := range connectivityMatrix { | |||
| 22 | - | if entry.Kind == kind { | |||
| 23 | - | return entry.Owner, true | |||
| 24 | - | } | |||
| 25 | - | } | |||
| 26 | - | return 0, false | |||
| 27 | - | } | |||
| 28 | - | ||||
| 29 | - | // TestConnectivityMatrixCoversEveryCommandKind pins P1.2: every cmdKind | |||
| 30 | - | // constant declared in run.go has exactly one matrix row. A new CLI entry | |||
| 31 | - | // point without a row fails here before any transport work can ignore it. | |||
| 32 | - | func TestConnectivityMatrixCoversEveryCommandKind(t *testing.T) { | |||
| 33 | - | t.Parallel() | |||
| 34 | - | ||||
| 35 | - | kinds := commandKindsFromRunGo(t) | |||
| 36 | - | require.NotEmpty(t, kinds, "run.go must declare cmdKind constants") | |||
| 37 | - | ||||
| 38 | - | seen := make(map[string]int, len(connectivityMatrix)) | |||
| 39 | - | for _, entry := range connectivityMatrix { | |||
| 40 | - | require.NotEmpty(t, entry.Kind, "matrix row must name a cmdKind") | |||
| 41 | - | require.NotEmpty(t, entry.Summary, "matrix row %q must summarize the operation", entry.Kind) | |||
| 42 | - | require.NotEmpty(t, entry.Notes, "matrix row %q must record cutover direction", entry.Kind) | |||
| 43 | - | seen[entry.Kind]++ | |||
| 44 | - | } | |||
| 45 | - | for kind, count := range seen { | |||
| 46 | - | require.Equal(t, 1, count, "matrix row %q must appear exactly once", kind) | |||
| 47 | - | } | |||
| 48 | - | var missing, extra []string | |||
| 49 | - | for _, kind := range kinds { | |||
| 50 | - | if _, ok := seen[kind]; !ok { | |||
| 51 | - | missing = append(missing, kind) | |||
| 52 | - | } | |||
| 53 | - | } | |||
| 54 | - | kindSet := make(map[string]struct{}, len(kinds)) | |||
| 55 | - | for _, kind := range kinds { | |||
| 56 | - | kindSet[kind] = struct{}{} | |||
| 57 | - | } | |||
| 58 | - | for kind := range seen { | |||
| 59 | - | if _, ok := kindSet[kind]; !ok { | |||
| 60 | - | extra = append(extra, kind) | |||
| 61 | - | } | |||
| 62 | - | } | |||
| 63 | - | sort.Strings(missing) | |||
| 64 | - | sort.Strings(extra) | |||
| 65 | - | require.Empty(t, missing, "matrix is missing cmdKind rows") | |||
| 66 | - | require.Empty(t, extra, "matrix references unknown cmdKind rows") | |||
| 67 | - | ||||
| 68 | - | owner, ok := testConnectivityOwnerFor("kindAttach") | |||
| 69 | - | require.True(t, ok, "matrix lookup must resolve known kinds") | |||
| 70 | - | require.Equal(t, connectivityBrokerOnly, owner) | |||
| 71 | - | _, ok = testConnectivityOwnerFor("kindDoesNotExist") | |||
| 72 | - | require.False(t, ok, "matrix lookup must reject unknown kinds") | |||
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | // TestConnectivityMatrixOwnershipRules pins the P1.2 ownership invariants: | |||
| 76 | - | // broker-only rows must declare migration debt, infra rows must never gain | |||
| 77 | - | // store or persistence ownership, and local-only rows carry no debt. | |||
| 78 | - | func TestConnectivityMatrixOwnershipRules(t *testing.T) { | |||
| 79 | - | t.Parallel() | |||
| 80 | - | ||||
| 81 | - | tests := make([]struct { | |||
| 82 | - | name string | |||
| 83 | - | entry connectivityEntry | |||
| 84 | - | }, 0, len(connectivityMatrix)) | |||
| 85 | - | for _, entry := range connectivityMatrix { | |||
| 86 | - | tests = append(tests, struct { | |||
| 87 | - | name string | |||
| 88 | - | entry connectivityEntry | |||
| 89 | - | }{name: entry.Kind, entry: entry}) | |||
| 90 | - | } | |||
| 91 | - | for _, tt := range tests { | |||
| 92 | - | tt := tt | |||
| 93 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 94 | - | t.Parallel() | |||
| 95 | - | entry := tt.entry | |||
| 96 | - | switch entry.Owner { | |||
| 97 | - | case connectivityBrokerOnly: | |||
| 98 | - | hasDebt := len(entry.DirectDialDebt) > 0 || entry.HostStoreWrite || entry.PersistMutation | |||
| 99 | - | if entry.Migrated { | |||
| 100 | - | require.False(t, hasDebt, "migrated broker-only %q must declare no remaining migration debt", entry.Kind) | |||
| 101 | - | break | |||
| 102 | - | } | |||
| 103 | - | require.True(t, hasDebt, "broker-only %q must declare migration debt (direct dial, host-store write, or persist mutation)", entry.Kind) | |||
| 104 | - | case connectivityTransportInfra: | |||
| 105 | - | require.False(t, entry.HostStoreWrite, "infra %q must never own the host store", entry.Kind) | |||
| 106 | - | require.False(t, entry.PersistMutation, "infra %q must never own session persistence", entry.Kind) | |||
| 107 | - | if len(entry.DirectDialDebt) > 0 { | |||
| 108 | - | require.NotEmpty(t, entry.Notes, "infra %q with local dial debt must justify why it stays infra", entry.Kind) | |||
| 109 | - | } | |||
| 110 | - | case connectivityLocalOnly: | |||
| 111 | - | require.Empty(t, entry.DirectDialDebt, "local-only %q must carry no dial debt", entry.Kind) | |||
| 112 | - | require.False(t, entry.HostStoreWrite, "local-only %q must never own the host store", entry.Kind) | |||
| 113 | - | require.False(t, entry.PersistMutation, "local-only %q must never own session persistence", entry.Kind) | |||
| 114 | - | default: | |||
| 115 | - | t.Fatalf("row %q has unknown owner %v", entry.Kind, entry.Owner) | |||
| 116 | - | } | |||
| 117 | - | for _, debt := range entry.DirectDialDebt { | |||
| 118 | - | require.NotEmpty(t, debt, "row %q has an empty debt entry", entry.Kind) | |||
| 119 | - | } | |||
| 120 | - | }) | |||
| 121 | - | } | |||
| 122 | - | } | |||
| 123 | - | ||||
| 124 | - | // TestConnectivityMatrixDebtFilesExist keeps the matrix honest: every file | |||
| 125 | - | // named in a debt entry must exist under internal/app. | |||
| 126 | - | func TestConnectivityMatrixDebtFilesExist(t *testing.T) { | |||
| 127 | - | t.Parallel() | |||
| 128 | - | ||||
| 129 | - | fileRef := regexp.MustCompile(`([a-z_]+\.go)`) | |||
| 130 | - | for _, entry := range connectivityMatrix { | |||
| 131 | - | for _, debt := range entry.DirectDialDebt { | |||
| 132 | - | for _, match := range fileRef.FindAllStringSubmatch(debt, -1) { | |||
| 133 | - | path := filepath.Join("internal", "app", match[1]) | |||
| 134 | - | // Tests run with the package dir as CWD, so resolve from there. | |||
| 135 | - | if _, err := os.Stat(match[1]); err == nil { | |||
| 136 | - | continue | |||
| 137 | - | } | |||
| 138 | - | if _, err := os.Stat(path); err == nil { | |||
| 139 | - | continue | |||
| 140 | - | } | |||
| 141 | - | t.Errorf("row %q references missing file %q (debt %q)", entry.Kind, match[1], debt) | |||
| 142 | - | } | |||
| 143 | - | } | |||
| 144 | - | } | |||
| 145 | - | } | |||
| 146 | - | ||||
| 147 | - | // TestConnectivityMatrixCurrentDebtPresent pins today's direct-connectivity | |||
| 148 | - | // debt so the P7 cutover can find every site it must remove in the same | |||
| 149 | - | // change that activates the broker owner. If any of these symbols disappear, | |||
| 150 | - | // update the matrix in the same change. | |||
| 151 | - | func TestConnectivityMatrixCurrentDebtPresent(t *testing.T) { | |||
| 152 | - | t.Parallel() | |||
| 153 | - | ||||
| 154 | - | sources := loadAppSources(t, false) | |||
| 155 | - | ||||
| 156 | - | assertContains := func(symbol, file string) { | |||
| 157 | - | t.Helper() | |||
| 158 | - | body, ok := sources[file] | |||
| 159 | - | require.True(t, ok, "expected app source file %q", file) | |||
| 160 | - | require.Contains(t, body, symbol, "expected debt symbol %q in %q", symbol, file) | |||
| 161 | - | } | |||
| 162 | - | ||||
| 163 | - | assertContains("func ensureDaemonWithLifecycle", "spawn.go") | |||
| 164 | - | assertContains("func realDial", "spawn.go") | |||
| 165 | - | assertContains("func realSpawn", "spawn.go") | |||
| 166 | - | } | |||
| 167 | - | ||||
| 168 | - | // TestConnectivityMatrixListKillStopAreBrokerOnly pins the P7.4f cutover for | |||
| 169 | - | // the list/kill/stop rows: each reaches the per-user broker through the private | |||
| 170 | - | // connectBroker seam and keeps no removed bypass, so runList and runKill/stop | |||
| 171 | - | // can never read durable state or signal a process directly. | |||
| 172 | - | func TestConnectivityMatrixListKillStopAreBrokerOnly(t *testing.T) { | |||
| 173 | - | t.Parallel() | |||
| 174 | - | ||||
| 175 | - | sources := loadAppSources(t, false) | |||
| 176 | - | runBody, ok := sources["run.go"] | |||
| 177 | - | require.True(t, ok, "expected app source file run.go") | |||
| 178 | - | require.Contains(t, runBody, "var connectBroker = connectProductionBroker", | |||
| 179 | - | "run.go must keep the private connectBroker composition seam") | |||
| 180 | - | require.Contains(t, runBody, "func runList", "run.go must keep runList") | |||
| 181 | - | require.Contains(t, runBody, "func runKill", "run.go must keep runKill") | |||
| 182 | - | require.Contains(t, runBody, "func requestDaemonStop", "run.go must keep requestDaemonStop") | |||
| 183 | - | ||||
| 184 | - | forbiddenFiles := map[string][]string{ | |||
| 185 | - | "run.go": { | |||
| 186 | - | "runOfflineNamedKill", "persist.LoadReadOnly", "forceStopDaemonFallback", | |||
| 187 | - | "forceStopDaemonFn", "waitForDaemonOrLifecycle", "waitForLifecycleAvailability", | |||
| 188 | - | }, | |||
| 189 | - | } | |||
| 190 | - | for file, forbidden := range forbiddenFiles { | |||
| 191 | - | body, ok := sources[file] | |||
| 192 | - | require.True(t, ok, "expected app source file %q", file) | |||
| 193 | - | for _, symbol := range forbidden { | |||
| 194 | - | require.NotContains(t, body, symbol, | |||
| 195 | - | "%s must not reintroduce the removed bypass %q", file, symbol) | |||
| 196 | - | } | |||
| 197 | - | } | |||
| 198 | - | ||||
| 199 | - | // The whole force-stop subsystem is removed, so no production file may | |||
| 200 | - | // resurrect an interactive process-signal fallback. | |||
| 201 | - | for file, body := range sources { | |||
| 202 | - | require.NotContains(t, body, "forceStop", | |||
| 203 | - | "the force-stop subsystem must stay removed (%s)", file) | |||
| 204 | - | } | |||
| 205 | - | } | |||
| 206 | - | ||||
| 207 | - | // TestConnectivityMatrixSingleHostStoreWriterSet fails when a new | |||
| 208 | - | // RemoteHostStore constructor call site appears outside the declared set. | |||
| 209 | - | // After cutover the broker is the sole writer; until then the matrix must | |||
| 210 | - | // name every writer so none is missed. | |||
| 211 | - | func TestConnectivityMatrixSingleHostStoreWriterSet(t *testing.T) { | |||
| 212 | - | t.Parallel() | |||
| 213 | - | ||||
| 214 | - | sources := loadAppSources(t, false) | |||
| 215 | - | var writers []string | |||
| 216 | - | for file, body := range sources { | |||
| 217 | - | if strings.Contains(body, "NewFileHostStore") { | |||
| 218 | - | writers = append(writers, file) | |||
| 219 | - | } | |||
| 220 | - | } | |||
| 221 | - | sort.Strings(writers) | |||
| 222 | - | require.Empty(t, writers, | |||
| 223 | - | "the broker is the sole host-store owner; app must not construct another writer") | |||
| 224 | - | } | |||
| 225 | - | ||||
| 226 | - | // TestConnectivityMatrixReadyProbeIsLocalDialOnly pins the kindBrokerReady row | |||
| 227 | - | // and its production source: the hidden readiness probe is local-only, reaches | |||
| 228 | - | // no daemon or store, and its production body dials, subscribes, and reads only. | |||
| 229 | - | // It must never ensure or spawn a broker, open a logical stream, request a | |||
| 230 | - | // reconcile, or mutate membership; each of those would turn a machine probe | |||
| 231 | - | // into a client façade that changes the state it reports. | |||
| 232 | - | func TestConnectivityMatrixReadyProbeIsLocalDialOnly(t *testing.T) { | |||
| 233 | - | t.Parallel() | |||
| 234 | - | ||||
| 235 | - | owner, ok := testConnectivityOwnerFor("kindBrokerReady") | |||
| 236 | - | require.True(t, ok, "the readiness probe must have a matrix row") | |||
| 237 | - | require.Equal(t, connectivityLocalOnly, owner, "the readiness probe is dial-only local infrastructure") | |||
| 238 | - | for _, entry := range connectivityMatrix { | |||
| 239 | - | if entry.Kind != "kindBrokerReady" { | |||
| 240 | - | continue | |||
| 241 | - | } | |||
| 242 | - | require.Empty(t, entry.DirectDialDebt, "a dial-only probe owes no connectivity migration") | |||
| 243 | - | require.False(t, entry.HostStoreWrite, "the readiness probe never owns the host store") | |||
| 244 | - | require.False(t, entry.PersistMutation, "the readiness probe never mutates session persistence") | |||
| 245 | - | } | |||
| 246 | - | ||||
| 247 | - | sources := loadAppSources(t, false) | |||
| 248 | - | body, ok := sources["broker_ready.go"] | |||
| 249 | - | require.True(t, ok, "expected app source file broker_ready.go") | |||
| 250 | - | ||||
| 251 | - | forbidden := []struct { | |||
| 252 | - | symbol string | |||
| 253 | - | reason string | |||
| 254 | - | }{ | |||
| 255 | - | {"ensureBrokerReady", "a readiness probe must never ensure a broker"}, | |||
| 256 | - | {"ensureDaemonWithLifecycle", "a readiness probe must never start a daemon"}, | |||
| 257 | - | {"spawnBrokerLauncher", "a readiness probe must never spawn a broker"}, | |||
| 258 | - | {"spawnProductionBrokerLauncher", "a readiness probe must never spawn a broker"}, | |||
| 259 | - | {"realDial", "a readiness probe dials the broker endpoint only"}, | |||
| 260 | - | {"OpenStream", "a readiness probe opens no logical stream"}, | |||
| 261 | - | {"RequestReconcile", "a readiness probe requests no reconcile"}, | |||
| 262 | - | {"AddHost", "a readiness probe mutates no membership"}, | |||
| 263 | - | {"RemoveHost", "a readiness probe mutates no membership"}, | |||
| 264 | - | {"UpdateHostPolicy", "a readiness probe mutates no membership"}, | |||
| 265 | - | {"NewFileHostStore", "a readiness probe owns no host store"}, | |||
| 266 | - | } | |||
| 267 | - | for _, forbidden := range forbidden { | |||
| 268 | - | require.NotContains(t, body, forbidden.symbol, "%s (%s)", forbidden.symbol, forbidden.reason) | |||
| 269 | - | } | |||
| 270 | - | ||||
| 271 | - | // Positive pins: the probe is one subscription over one snapshot loop, and | |||
| 272 | - | // it classifies terminal failures without retrying them. | |||
| 273 | - | for _, required := range []string{"func runBrokerReady", "func observeBrokerReady", "service.Subscribe()", "service.Snapshot()", "terminalReadyReason", "brokerReadyEndpointSecurity"} { | |||
| 274 | - | require.Contains(t, body, required, "broker_ready.go must keep %s", required) | |||
| 275 | - | } | |||
| 276 | - | } | |||
| 277 | - | ||||
| 278 | - | // commandKindsFromRunGo extracts the kind* const names from run.go so the | |||
| 279 | - | // matrix test tracks the real dispatch table instead of a hardcoded copy. | |||
| 280 | - | func commandKindsFromRunGo(t *testing.T) []string { | |||
| 281 | - | t.Helper() | |||
| 282 | - | ||||
| 283 | - | fset := token.NewFileSet() | |||
| 284 | - | file, err := parser.ParseFile(fset, "run.go", nil, 0) | |||
| 285 | - | require.NoError(t, err, "parse run.go for cmdKind constants") | |||
| 286 | - | ||||
| 287 | - | var kinds []string | |||
| 288 | - | for _, decl := range file.Decls { | |||
| 289 | - | gen, ok := decl.(*ast.GenDecl) | |||
| 290 | - | if !ok || gen.Tok != token.CONST { | |||
| 291 | - | continue | |||
| 292 | - | } | |||
| 293 | - | for _, spec := range gen.Specs { | |||
| 294 | - | value, ok := spec.(*ast.ValueSpec) | |||
| 295 | - | if !ok { | |||
| 296 | - | continue | |||
| 297 | - | } | |||
| 298 | - | for _, name := range value.Names { | |||
| 299 | - | if strings.HasPrefix(name.Name, "kind") { | |||
| 300 | - | kinds = append(kinds, name.Name) | |||
| 301 | - | } | |||
| 302 | - | } | |||
| 303 | - | } | |||
| 304 | - | } | |||
| 305 | - | sort.Strings(kinds) | |||
| 306 | - | return kinds | |||
| 307 | - | } | |||
| 308 | - | ||||
| 309 | - | // loadAppSources reads production (non-test) .go files in this package. | |||
| 310 | - | // When includeTests is false, _test.go files are skipped. | |||
| 311 | - | func loadAppSources(t *testing.T, includeTests bool) map[string]string { | |||
| 312 | - | t.Helper() | |||
| 313 | - | ||||
| 314 | - | entries, err := os.ReadDir(".") | |||
| 315 | - | require.NoError(t, err, "read app package dir") | |||
| 316 | - | sources := make(map[string]string) | |||
| 317 | - | for _, entry := range entries { | |||
| 318 | - | name := entry.Name() | |||
| 319 | - | if entry.IsDir() || !strings.HasSuffix(name, ".go") { | |||
| 320 | - | continue | |||
| 321 | - | } | |||
| 322 | - | if !includeTests && strings.HasSuffix(name, "_test.go") { | |||
| 323 | - | continue | |||
| 324 | - | } | |||
| 325 | - | raw, err := os.ReadFile(name) | |||
| 326 | - | require.NoError(t, err, "read app source %q", name) | |||
| 327 | - | sources[name] = string(raw) | |||
| 328 | - | } | |||
| 329 | - | require.NotEmpty(t, sources, "app sources must not be empty") | |||
| 330 | - | return sources | |||
| 331 | - | } | |||
M internal/app/run.go +20 -43
| @@ -65,10 +65,6 @@ const ( | |||
| 65 | 65 | kindWebDaemon | |
| 66 | 66 | kindWebRenew | |
| 67 | 67 | kindWebServe | |
| 68 | - | kindBrokerServe | |
| 69 | - | kindBrokerClient | |
| 70 | - | kindBrokerLauncher | |
| 71 | - | kindBrokerStatus | |
| 72 | 68 | kindBrokerMuxStdio | |
| 73 | 69 | kindBrokerMuxQUICBootstrap | |
| 74 | 70 | kindBrokerMuxQUICProxy | |
| @@ -82,29 +78,26 @@ const ( | |||
| 82 | 78 | // command is the parsed CLI invocation: what to do, plus the attach intent | |
| 83 | 79 | // and session name where relevant. | |
| 84 | 80 | type command struct { | |
| 85 | - | kind cmdKind | |
| 86 | - | intent uint8 | |
| 87 | - | name string | |
| 88 | - | remoteTarget string | |
| 89 | - | listHost string | |
| 90 | - | listAll bool | |
| 91 | - | hostAction string | |
| 92 | - | hostTarget string | |
| 93 | - | killAll bool | |
| 94 | - | killDaemon bool | |
| 95 | - | killBroker bool | |
| 96 | - | cmd cmdInvocation | |
| 97 | - | brokerServe brokerServeOptions | |
| 98 | - | brokerClient brokerClientOptions | |
| 99 | - | brokerLauncher brokerLauncherOptions | |
| 100 | - | brokerStatus brokerStatusOptions | |
| 101 | - | brokerMux brokerMuxOptions | |
| 102 | - | brokerReady brokerReadyOptions | |
| 103 | - | uiDriver uiDriverOptions | |
| 104 | - | uiObserve bool | |
| 105 | - | uiControl bool | |
| 106 | - | uiSocket string | |
| 107 | - | web webOptions | |
| 81 | + | kind cmdKind | |
| 82 | + | intent uint8 | |
| 83 | + | name string | |
| 84 | + | remoteTarget string | |
| 85 | + | listHost string | |
| 86 | + | listAll bool | |
| 87 | + | hostAction string | |
| 88 | + | hostTarget string | |
| 89 | + | killAll bool | |
| 90 | + | killDaemon bool | |
| 91 | + | killBroker bool | |
| 92 | + | cmd cmdInvocation | |
| 93 | + | brokerServe brokerServeOptions | |
| 94 | + | brokerMux brokerMuxOptions | |
| 95 | + | brokerReady brokerReadyOptions | |
| 96 | + | uiDriver uiDriverOptions | |
| 97 | + | uiObserve bool | |
| 98 | + | uiControl bool | |
| 99 | + | uiSocket string | |
| 100 | + | web webOptions | |
| 108 | 101 | } | |
| 109 | 102 | ||
| 110 | 103 | // usageError is a user-facing argument error; the app prints it (with usage) | |
| @@ -238,18 +231,10 @@ parsedUIFlags: | |||
| 238 | 231 | return command{kind: kindDaemon}, nil | |
| 239 | 232 | case "--daemon-launcher": | |
| 240 | 233 | return command{kind: kindDaemonLauncher}, nil | |
| 241 | - | case brokerServeCommand: | |
| 242 | - | return parseBrokerServeArgs(args[1:]) | |
| 243 | 234 | case productionBrokerServeCommand: | |
| 244 | 235 | return parseProductionBrokerServeArgs(args[1:]) | |
| 245 | - | case brokerClientCommand: | |
| 246 | - | return parseBrokerClientArgs(args[1:]) | |
| 247 | - | case brokerLauncherCommand: | |
| 248 | - | return parseBrokerLauncherArgs(args[1:]) | |
| 249 | 236 | case productionBrokerLauncherCommand: | |
| 250 | 237 | return parseProductionBrokerLauncherArgs(args[1:]) | |
| 251 | - | case brokerStatusCommand: | |
| 252 | - | return parseBrokerStatusArgs(args[1:]) | |
| 253 | 238 | case brokerReadyCommand: | |
| 254 | 239 | options, err := parseBrokerReadyArgs(args[1:]) | |
| 255 | 240 | return command{kind: kindBrokerReady, brokerReady: options}, err | |
| @@ -375,14 +360,6 @@ func dispatch(ctx context.Context, cmd command) error { | |||
| 375 | 360 | return runDaemon() | |
| 376 | 361 | case kindDaemonLauncher: | |
| 377 | 362 | return runDaemonLauncher() | |
| 378 | - | case kindBrokerServe: | |
| 379 | - | return runBrokerServeCommand(ctx, cmd.brokerServe) | |
| 380 | - | case kindBrokerClient: | |
| 381 | - | return runBrokerClientCommand(ctx, cmd.brokerClient) | |
| 382 | - | case kindBrokerLauncher: | |
| 383 | - | return runBrokerLauncherCommand(ctx, cmd.brokerLauncher) | |
| 384 | - | case kindBrokerStatus: | |
| 385 | - | return runBrokerStatusCommand(ctx, cmd.brokerStatus) | |
| 386 | 363 | case kindBrokerMuxStdio: | |
| 387 | 364 | return runBrokerMuxStdioCommand(ctx, cmd.brokerMux) | |
| 388 | 365 | case kindBrokerMuxQUICBootstrap: | |
| @@ -65,10 +65,6 @@ const ( | |||||
| 65 | kindWebDaemon | 65 | kindWebDaemon | ||
| 66 | kindWebRenew | 66 | kindWebRenew | ||
| 67 | kindWebServe | 67 | kindWebServe | ||
| 68 | - | kindBrokerServe | |||
| 69 | - | kindBrokerClient | |||
| 70 | - | kindBrokerLauncher | |||
| 71 | - | kindBrokerStatus | |||
| 72 | kindBrokerMuxStdio | 68 | kindBrokerMuxStdio | ||
| 73 | kindBrokerMuxQUICBootstrap | 69 | kindBrokerMuxQUICBootstrap | ||
| 74 | kindBrokerMuxQUICProxy | 70 | kindBrokerMuxQUICProxy | ||
| @@ -82,29 +78,26 @@ const ( | |||||
| 82 | // command is the parsed CLI invocation: what to do, plus the attach intent | 78 | // command is the parsed CLI invocation: what to do, plus the attach intent | ||
| 83 | // and session name where relevant. | 79 | // and session name where relevant. | ||
| 84 | type command struct { | 80 | type command struct { | ||
| 85 | - | kind cmdKind | 81 | + | kind cmdKind |
| 86 | - | intent uint8 | 82 | + | intent uint8 |
| 87 | - | name string | 83 | + | name string |
| 88 | - | remoteTarget string | 84 | + | remoteTarget string |
| 89 | - | listHost string | 85 | + | listHost string |
| 90 | - | listAll bool | 86 | + | listAll bool |
| 91 | - | hostAction string | 87 | + | hostAction string |
| 92 | - | hostTarget string | 88 | + | hostTarget string |
| 93 | - | killAll bool | 89 | + | killAll bool |
| 94 | - | killDaemon bool | 90 | + | killDaemon bool |
| 95 | - | killBroker bool | 91 | + | killBroker bool |
| 96 | - | cmd cmdInvocation | 92 | + | cmd cmdInvocation |
| 97 | - | brokerServe brokerServeOptions | 93 | + | brokerServe brokerServeOptions |
| 98 | - | brokerClient brokerClientOptions | 94 | + | brokerMux brokerMuxOptions |
| 99 | - | brokerLauncher brokerLauncherOptions | 95 | + | brokerReady brokerReadyOptions |
| 100 | - | brokerStatus brokerStatusOptions | 96 | + | uiDriver uiDriverOptions |
| 101 | - | brokerMux brokerMuxOptions | 97 | + | uiObserve bool |
| 102 | - | brokerReady brokerReadyOptions | 98 | + | uiControl bool |
| 103 | - | uiDriver uiDriverOptions | 99 | + | uiSocket string |
| 104 | - | uiObserve bool | 100 | + | web webOptions |
| 105 | - | uiControl bool | |||
| 106 | - | uiSocket string | |||
| 107 | - | web webOptions | |||
| 108 | } | 101 | } | ||
| 109 | 102 | ||||
| 110 | // usageError is a user-facing argument error; the app prints it (with usage) | 103 | // usageError is a user-facing argument error; the app prints it (with usage) | ||
| @@ -238,18 +231,10 @@ parsedUIFlags: | |||||
| 238 | return command{kind: kindDaemon}, nil | 231 | return command{kind: kindDaemon}, nil | ||
| 239 | case "--daemon-launcher": | 232 | case "--daemon-launcher": | ||
| 240 | return command{kind: kindDaemonLauncher}, nil | 233 | return command{kind: kindDaemonLauncher}, nil | ||
| 241 | - | case brokerServeCommand: | |||
| 242 | - | return parseBrokerServeArgs(args[1:]) | |||
| 243 | case productionBrokerServeCommand: | 234 | case productionBrokerServeCommand: | ||
| 244 | return parseProductionBrokerServeArgs(args[1:]) | 235 | return parseProductionBrokerServeArgs(args[1:]) | ||
| 245 | - | case brokerClientCommand: | |||
| 246 | - | return parseBrokerClientArgs(args[1:]) | |||
| 247 | - | case brokerLauncherCommand: | |||
| 248 | - | return parseBrokerLauncherArgs(args[1:]) | |||
| 249 | case productionBrokerLauncherCommand: | 236 | case productionBrokerLauncherCommand: | ||
| 250 | return parseProductionBrokerLauncherArgs(args[1:]) | 237 | return parseProductionBrokerLauncherArgs(args[1:]) | ||
| 251 | - | case brokerStatusCommand: | |||
| 252 | - | return parseBrokerStatusArgs(args[1:]) | |||
| 253 | case brokerReadyCommand: | 238 | case brokerReadyCommand: | ||
| 254 | options, err := parseBrokerReadyArgs(args[1:]) | 239 | options, err := parseBrokerReadyArgs(args[1:]) | ||
| 255 | return command{kind: kindBrokerReady, brokerReady: options}, err | 240 | return command{kind: kindBrokerReady, brokerReady: options}, err | ||
| @@ -375,14 +360,6 @@ func dispatch(ctx context.Context, cmd command) error { | |||||
| 375 | return runDaemon() | 360 | return runDaemon() | ||
| 376 | case kindDaemonLauncher: | 361 | case kindDaemonLauncher: | ||
| 377 | return runDaemonLauncher() | 362 | return runDaemonLauncher() | ||
| 378 | - | case kindBrokerServe: | |||
| 379 | - | return runBrokerServeCommand(ctx, cmd.brokerServe) | |||
| 380 | - | case kindBrokerClient: | |||
| 381 | - | return runBrokerClientCommand(ctx, cmd.brokerClient) | |||
| 382 | - | case kindBrokerLauncher: | |||
| 383 | - | return runBrokerLauncherCommand(ctx, cmd.brokerLauncher) | |||
| 384 | - | case kindBrokerStatus: | |||
| 385 | - | return runBrokerStatusCommand(ctx, cmd.brokerStatus) | |||
| 386 | case kindBrokerMuxStdio: | 363 | case kindBrokerMuxStdio: | ||
| 387 | return runBrokerMuxStdioCommand(ctx, cmd.brokerMux) | 364 | return runBrokerMuxStdioCommand(ctx, cmd.brokerMux) | ||
| 388 | case kindBrokerMuxQUICBootstrap: | 365 | case kindBrokerMuxQUICBootstrap: | ||
M internal/app/spawn_test.go +2 -3
| @@ -30,12 +30,11 @@ func TestMain(m *testing.M) { | |||
| 30 | 30 | // runs. | |
| 31 | 31 | if len(os.Args) >= 2 && os.Getenv(brokerHelperEnv) == "1" { | |
| 32 | 32 | switch os.Args[1] { | |
| 33 | - | case brokerServeCommand, brokerLauncherCommand, brokerStatusCommand, | |
| 34 | - | brokerMuxStdioCommand, brokerMuxQUICBootstrapCommand, brokerMuxQUICProxyCommand, | |
| 33 | + | case brokerMuxStdioCommand, brokerMuxQUICBootstrapCommand, brokerMuxQUICProxyCommand, | |
| 35 | 34 | brokerReadyCommand, | |
| 36 | 35 | productionBrokerServeCommand, productionBrokerLauncherCommand, "--daemon": | |
| 37 | 36 | recordBrokerHelperProcess(os.Args[1]) | |
| 38 | - | if os.Args[1] == brokerLauncherCommand || os.Args[1] == productionBrokerLauncherCommand { | |
| 37 | + | if os.Args[1] == productionBrokerLauncherCommand { | |
| 39 | 38 | if record := os.Getenv(brokerLauncherBlockRecordEnv); record != "" { | |
| 40 | 39 | blockBrokerLauncherHelper(record, os.Getenv(brokerLauncherBlockReleaseEnv)) | |
| 41 | 40 | } | |
| @@ -30,12 +30,11 @@ func TestMain(m *testing.M) { | |||||
| 30 | // runs. | 30 | // runs. | ||
| 31 | if len(os.Args) >= 2 && os.Getenv(brokerHelperEnv) == "1" { | 31 | if len(os.Args) >= 2 && os.Getenv(brokerHelperEnv) == "1" { | ||
| 32 | switch os.Args[1] { | 32 | switch os.Args[1] { | ||
| 33 | - | case brokerServeCommand, brokerLauncherCommand, brokerStatusCommand, | 33 | + | case brokerMuxStdioCommand, brokerMuxQUICBootstrapCommand, brokerMuxQUICProxyCommand, |
| 34 | - | brokerMuxStdioCommand, brokerMuxQUICBootstrapCommand, brokerMuxQUICProxyCommand, | |||
| 35 | brokerReadyCommand, | 34 | brokerReadyCommand, | ||
| 36 | productionBrokerServeCommand, productionBrokerLauncherCommand, "--daemon": | 35 | productionBrokerServeCommand, productionBrokerLauncherCommand, "--daemon": | ||
| 37 | recordBrokerHelperProcess(os.Args[1]) | 36 | recordBrokerHelperProcess(os.Args[1]) | ||
| 38 | - | if os.Args[1] == brokerLauncherCommand || os.Args[1] == productionBrokerLauncherCommand { | 37 | + | if os.Args[1] == productionBrokerLauncherCommand { |
| 39 | if record := os.Getenv(brokerLauncherBlockRecordEnv); record != "" { | 38 | if record := os.Getenv(brokerLauncherBlockRecordEnv); record != "" { | ||
| 40 | blockBrokerLauncherHelper(record, os.Getenv(brokerLauncherBlockReleaseEnv)) | 39 | blockBrokerLauncherHelper(record, os.Getenv(brokerLauncherBlockReleaseEnv)) | ||
| 41 | } | 40 | } | ||