Skip to content

Add a gateway command (hidden) with status and stop - #226

Merged
pjbgf merged 1 commit into
mainfrom
gateway-subcommand
Sep 10, 2026
Merged

Add a gateway command (hidden) with status and stop#226
pjbgf merged 1 commit into
mainfrom
gateway-subcommand

Conversation

@pjbgf

@pjbgf pjbgf commented Sep 9, 2026

Copy link
Copy Markdown
Member

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.

Copilot AI lite review requested due to automatic review settings September 9, 2026 21:35

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread internal/gateway/stop.go
Comment thread cmd/cli/gateway.go
Comment thread internal/gateway/status.go
Comment thread internal/gateway/stop.go
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>
@pjbgf
pjbgf force-pushed the gateway-subcommand branch from 3ede683 to a76263a Compare September 10, 2026 22:04
Copilot AI review requested due to automatic review settings September 10, 2026 22:04

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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:126 and root.go:135). A valid config containing only a gateway block is therefore reported here as ConfigProblem: no qubesome config was loaded, so gateway status cannot 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() calls types.LoadConfig, which rejects a GatewayConfig.ConfigPath that leaves RootDir and returns nil. After an operator edits a running gateway's policy to ../gateway.yml, this command therefore prints no qubesome config was loaded instead 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 PolicyProblem branch is unreachable from the CLI for the invalid paths it is meant to explain: config() calls types.LoadConfig, whose validation rejects GatewayConfig.ConfigPath errors, then returns nil. gateway status consequently prints no qubesome config was loaded instead 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

  • Allocate serializes its read and write of this JSON file with the gateway lock, but status reads it without that lock. Because writeAlloc uses a truncating os.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 reap goroutine. 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.Alive returns, its PID can be reused, and this numeric Kill can 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

Comment thread internal/gateway/stop.go
Comment thread internal/gateway/status.go
@pjbgf
pjbgf merged commit fb1cfc7 into main Sep 10, 2026
6 checks passed
@pjbgf
pjbgf deleted the gateway-subcommand branch September 10, 2026 22:12
pjbgf added a commit that referenced this pull request Sep 10, 2026
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
pjbgf added a commit that referenced this pull request Sep 10, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants