Add a gateway command (hidden) with status and stop - #226
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved review findings must be addressed before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds hidden gateway status and gateway stop commands for inspecting and replacing the session gateway while preserving its holder.
Changes:
- Reports gateway configuration, liveness, readiness, and allocated addresses.
- Stops the gateway process without stopping the session holder.
- Adds focused tests and registers the CLI commands.
File summaries
| File | Description |
|---|---|
internal/gateway/stop.go |
Gateway shutdown logic. |
internal/gateway/stop_test.go |
Stop behavior and locking tests. |
internal/gateway/status.go |
Gateway status inspection and rendering. |
internal/gateway/status_test.go |
Status reporting tests. |
cmd/cli/root.go |
Registers the hidden gateway command. |
cmd/cli/gateway.go |
Defines the status and stop subcommands. |
Review details
Suppressed comments (1)
cmd/cli/gateway.go:62
profileConfigOrDefault("")only selects an active config when exactly one profile is active; with multiple active profiles it falls back to the user-level config. Because the gateway is session-wide and may have been started from any active profile, this can make status report an unrelated image, policy, or subnet (or claim the config is unknown) for the running gateway. Resolve the session's gateway configuration explicitly or report the ambiguity instead of using this fallback.
status := g.Inspect(session.Current(), profileConfigOrDefault(""), g.StatusReady)
- Files reviewed: 6/6 changed files
- Comments generated: 4
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
A launch reuses whatever gateway it finds running and never asks which image it came from, so an edit to the gateway block reaches nothing until the running one is gone. There was no way to make that happen, and the way it was being done was to read the pid out of sandbox-gateway.json, kill it and remove the file by hand. stop is that by name. It signals the pid in the record, which is the sandbox's own init and not the bwrap that started it: killing the outer bwrap does not signal the sandbox nested below it, while the init is pid 1 of its own pid namespace and a SIGKILL from an ancestor namespace takes the whole namespace with it. That is the pid running.stop already names when it takes down a gateway that failed to come up. It reads the record through sandbox.Alive rather than looking at the pid, so a record left behind by a gateway that crashed reads as no gateway and nothing signals a number the kernel has since given to something else. It takes the same lock startOnce takes, because a launch between its check and its record would otherwise be left naming a process this has already killed. The session's namespace holder stays up. Only the gateway is reused across launches, so the holder is not what has to go, and taking it down would make the next launch rebuild a user namespace that was never the problem. The next launch starts a fresh gateway inside the same session. Being asked to stop a gateway that is not running is not a failure. It is the state the caller wanted, so it is reported and the exit status stays zero. A record left behind is removed on the way, because reap would have removed it had the launch that started the gateway still been there. status reports only what qubesome already holds: the configured image, the policy path resolved against the directory the config was read from, the subnet, the liveness of the holder and the gateway, readiness through the Ready call that already exists, and the addresses handed out. No new RPC, no proto change, and nothing that needs a newer gateway than the one that is running. Its readiness question is bounded at five seconds and not by the client's ten minutes. That deadline is sized for a launch, where Ready may be waiting on an image still being unpacked and waiting is the whole job. A status is there to report, so a gateway that does not answer becomes a line rather than a command that does not return. doctor bounds the same call for the same reason. Neither subcommand takes a profile, since there is one gateway per session. The config is still what names the image, the policy and the subnet, and it is reached the way doctor reaches one, through a running profile or the user-level file. A status that finds neither says so rather than printing three blank lines, and Inspect is handed the whole config rather than its gateway block so that a config which did not load and a config with no gateway block stay different answers. Reporting the second for the first is the bug doctor's session checks were fixed for. It is hidden, but not for the reason supervise and session-hold are. Nothing here runs inside a sandbox. It is hidden because qubesome manages the gateway itself, so the ordinary way to get one is to run a workload and there is nothing for a user to do here. Signed-off-by: Paulo Gomes <paulo@entire.io>
3ede683 to
a76263a
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved gateway lifecycle, process-identity, locking, and configuration-reporting issues remain.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (6)
cmd/cli/gateway.go:62
profileConfigOrDefault("")only returns a loaded config when it has at least one profile (root.go:126androot.go:135). A valid config containing only agatewayblock is therefore reported here asConfigProblem: no qubesome config was loaded, sogateway statuscannot report the configured image, policy, or subnet for gateway-only configurations. Load the status config without requiring a profile, or use a dedicated config-selection path for this command.
status := g.Inspect(session.Current(), profileConfigOrDefault(""), g.StatusReady)
cmd/cli/gateway.go:62
profileConfigOrDefault("")cannot preserve the invalid gateway config needed for this report:config()callstypes.LoadConfig, which rejects aGatewayConfig.ConfigPaththat leavesRootDirand returnsnil. After an operator edits a running gateway's policy to../gateway.yml, this command therefore printsno qubesome config was loadedinstead of the promised unresolvable-policy reason. Load this command's config in a mode that retains the gateway block, or otherwise surface the validation error.
status := g.Inspect(session.Current(), profileConfigOrDefault(""), g.StatusReady)
internal/gateway/status.go:141
- This
PolicyProblembranch is unreachable from the CLI for the invalid paths it is meant to explain:config()callstypes.LoadConfig, whose validation rejectsGatewayConfig.ConfigPatherrors, then returns nil.gateway statusconsequently printsno qubesome config was loadedinstead of the promised policy-path reason. Preserve enough of the decoded config to inspect/report this validation error, or make status use a loader that does not discard it.
policy, err := cfg.Gateway.ConfigPath(cfg.RootDir)
if err != nil {
st.PolicyProblem = err.Error()
internal/gateway/status.go:172
Allocateserializes its read and write of this JSON file with the gateway lock, but status reads it without that lock. BecausewriteAllocuses a truncatingos.WriteFile, status can observe an empty or partial document during a normal allocation and report a spurious parse error. Read the allocation record under the same lock or change the writer to an atomic replacement.
a, err := readAllocFile(g.AllocPath)
internal/gateway/stop.go:81
- Removing the state file and releasing the lock does not wait for the gateway's existing
reapgoroutine. In the persistent profile host, that goroutine waits for the old bwrap and then unconditionally removes this same path; a next launch can write a fresh gateway state before it runs, after which the old reaper deletes the new record. Status will report the replacement as stopped and later launches can start duplicates. The reaper must remove the file only if it still belongs to the process it waited for, or stopping must coordinate with that reaper.
if err := os.Remove(g.StatePath); err != nil && !errors.Is(err, os.ErrNotExist) {
return st.PID, fmt.Errorf("failed to remove the gateway state %q: %w", g.StatePath, err)
}
internal/gateway/stop.go:47
- The start-time check is not atomic with this signal. The recorded process can exit after
sandbox.Alivereturns, its PID can be reused, and this numericKillcan then terminate an unrelated process even though the stale-record check passed. Use an identity-preserving mechanism such as a pidfd (and handle the not-running case) rather than relying on the earlier PID/start-time read.
if !sandbox.Alive(g.StatePath) {
- Files reviewed: 6/6 changed files
- Comments generated: 2
- Review effort level: Lite
Stop signalled the sandbox and removed its record in the same breath. A delivered signal is not a sandbox that has gone, so the next launch could take the lock, find no record, and start a replacement while the old pid namespace, its outer bwrap and its uplink were still coming down. Raised in review on #226. It now waits for the record to stop naming something running, and only then removes it. The record has to outlive the wait, because it is what the next launch decides by. Waiting is a poll, since there is nothing to wait on: a gateway is not the child of whatever stops it, so it cannot be reaped here and the kernel will not report its going. Five seconds bounds it, which is a ceiling on the kernel finishing rather than an estimate: SIGKILL to pid 1 of a pid namespace takes the namespace with it and is not something the process can put off. sandbox.Exited is new and is not the negation of Alive. A process killed by something that is not its parent stays in the table as a zombie, with its /proc entry and its start time intact, so Alive reads it as running when it is only waiting to be collected. The parent that would collect it is usually a qubesome run that exited at its terminal long ago, so a wait on Alive would have waited out the whole grace every time. It reads the state character from the same stat line the start time already comes from. Assisted-by: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Paulo Gomes <paulo@entire.io> Entire-Checkpoint: 01M26PCGB8A1QDXH7RYXWA20XB
The subnet mismatch reported "the running gateway hands addresses out of X", which is false in the state it is most likely to be read in. The record outlives the gateway that wrote it, and gateway stop leaves exactly that behind: nothing running, and a note of the range the last one handed addresses out of. The test covering it had no gateway running either. Raised in review on #226. It now describes the record, and says what to do about it, which is the same remedy a launch is given when it refuses the same mismatch. Assisted-by: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Paulo Gomes <paulo@entire.io> Entire-Checkpoint: 01M26PD3GTGSJB8AR4615X32WN
qubesome gateway status reports the configured image, policy path and subnet, holder and gateway liveness, readiness, and allocated addresses. qubesome gateway stop ends the gateway and leaves the session holder up, so the next launch starts a fresh one.
Hidden because qubesome manages the gateway itself; this is the operator's job of replacing a running one, which until now meant killing a pid out of a JSON state file by hand.
Reports honest unknowns rather than blanks: no config loaded, no gateway configured, an unresolvable policy path and a mismatched subnet each print a reason.