feat(#2014): hivectl open verb + Urls host request for web surfaces
This commit is contained in:
parent
c2ba841932
commit
cae1dd8147
6 changed files with 200 additions and 32 deletions
|
|
@ -36,6 +36,7 @@ This document contains the help content for the `hivectl` command-line program.
|
|||
* [`hivectl quota limit`↴](#hivectl-quota-limit)
|
||||
* [`hivectl subvol`↴](#hivectl-subvol)
|
||||
* [`hivectl subvol upgrade`↴](#hivectl-subvol-upgrade)
|
||||
* [`hivectl open`↴](#hivectl-open)
|
||||
* [`hivectl completions`↴](#hivectl-completions)
|
||||
|
||||
## `hivectl`
|
||||
|
|
@ -58,6 +59,7 @@ Sibling to the `hive-c0re` daemon binary. Covers host-side admin operations that
|
|||
* `restart` — Restart containers hive-wide — `stop` then `start` over the same scope. Bare `hivectl restart` restarts **everything** (all sub-agents plus the ci/forge/gateway/matrix infra containers); the same scope flags as `stop`/`start` narrow it (`--agents`, `--ci`, `--forge`, `--gateway`, `--matrix`, `--agent <name>`). If the stop phase reports a failure the start phase is skipped so the operator can investigate. Requires the hive-c0re daemon
|
||||
* `quota` — Per-agent disk accounting + optional quotas via btrfs qgroups
|
||||
* `subvol` — btrfs subvolume management for agent state dirs
|
||||
* `open` — Print (and best-effort open in a browser) a hive web surface URL
|
||||
* `completions` — Generate a shell completion script for `hivectl` and print it to stdout
|
||||
|
||||
###### **Options:**
|
||||
|
|
@ -527,6 +529,31 @@ Convert an existing plain-dir agent state root into a btrfs subvolume in place.
|
|||
|
||||
|
||||
|
||||
## `hivectl open`
|
||||
|
||||
Print (and best-effort open in a browser) a hive web surface URL.
|
||||
|
||||
Resolves the URL from the running daemon (`HostRequest::Urls`), so custom forge / matrix domains work without guessing `forge.<domain>`. Prints the URL unconditionally — the reliable core, since the host is usually headless / driven over SSH where `xdg-open` is a no-op — then tries `xdg-open` as a convenience. Bare `hivectl open` opens the operator dashboard.
|
||||
|
||||
**Usage:** `hivectl open [TARGET]`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<TARGET>` — Which surface to open. Defaults to the operator dashboard
|
||||
|
||||
Default value: `home`
|
||||
|
||||
Possible values:
|
||||
- `home`:
|
||||
The operator dashboard (`https://<domain>/`)
|
||||
- `forge`:
|
||||
The forge (Forgejo) web UI
|
||||
- `matrix`:
|
||||
The matrix GUI (fluffychat)
|
||||
|
||||
|
||||
|
||||
|
||||
## `hivectl completions`
|
||||
|
||||
Generate a shell completion script for `hivectl` and print it to stdout.
|
||||
|
|
|
|||
|
|
@ -151,3 +151,29 @@ lands in a faithful copy of the agent's environment:
|
|||
the hyperhive/matrix MCP tools (which `--continue` needs to replay a
|
||||
tool-using history), and the role prompt. Each flag is included only
|
||||
when its file exists.
|
||||
|
||||
## Open
|
||||
|
||||
Print (and best-effort open in a browser) one of the hive's web surfaces.
|
||||
Requires the `hive-c0re` daemon to be running.
|
||||
|
||||
```bash
|
||||
hivectl open # operator dashboard (same as `open home`)
|
||||
hivectl open home # operator dashboard (https://<domain>/)
|
||||
hivectl open forge # the forge (Forgejo) web UI
|
||||
hivectl open matrix # the matrix GUI (fluffychat)
|
||||
```
|
||||
|
||||
The URL is resolved from the running daemon (`HostRequest::Urls`), which
|
||||
reads the per-surface public URLs from c0re's service env — so custom
|
||||
forge / matrix domains resolve correctly instead of assuming
|
||||
`forge.<domain>`. The URL is **always printed** (the reliable core, since
|
||||
the host is usually headless / driven over SSH), then `xdg-open` is tried
|
||||
as a convenience — a missing or failing opener is reported as a note, not
|
||||
an error.
|
||||
|
||||
A surface has no URL when it isn't browser-reachable: `home` needs
|
||||
`services.hyperhive.domain`; `forge` needs
|
||||
`services.hyperhive.forge.behindGateway = true`; `matrix` needs
|
||||
`services.hyperhive.matrix.gui.enable = true`. In those cases the command
|
||||
exits with a hint naming the option to set.
|
||||
|
|
|
|||
|
|
@ -204,6 +204,19 @@ enum Cmd {
|
|||
#[command(subcommand)]
|
||||
cmd: SubvolCmd,
|
||||
},
|
||||
/// Print (and best-effort open in a browser) a hive web surface URL.
|
||||
///
|
||||
/// Resolves the URL from the running daemon (`HostRequest::Urls`), so
|
||||
/// custom forge / matrix domains work without guessing `forge.<domain>`.
|
||||
/// Prints the URL unconditionally — the reliable core, since the host
|
||||
/// is usually headless / driven over SSH where `xdg-open` is a no-op —
|
||||
/// then tries `xdg-open` as a convenience. Bare `hivectl open` opens the
|
||||
/// operator dashboard.
|
||||
Open {
|
||||
/// Which surface to open. Defaults to the operator dashboard.
|
||||
#[arg(value_enum, default_value_t = OpenTarget::Home)]
|
||||
target: OpenTarget,
|
||||
},
|
||||
/// Emit the full CLI reference as `CommonMark` to stdout.
|
||||
///
|
||||
/// Hidden tooling command (not part of day-to-day operator admin):
|
||||
|
|
@ -227,6 +240,17 @@ enum Cmd {
|
|||
},
|
||||
}
|
||||
|
||||
/// Which hive web surface `hivectl open` targets.
|
||||
#[derive(Copy, Clone, Debug, clap::ValueEnum)]
|
||||
enum OpenTarget {
|
||||
/// The operator dashboard (`https://<domain>/`).
|
||||
Home,
|
||||
/// The forge (Forgejo) web UI.
|
||||
Forge,
|
||||
/// The matrix GUI (fluffychat).
|
||||
Matrix,
|
||||
}
|
||||
|
||||
/// Shared scope flags for `hivectl stop` / `hivectl start`. With no flag
|
||||
/// set the verb targets **everything** (all sub-agents + every controllable
|
||||
/// infra container). Setting any flag restricts to the selected classes,
|
||||
|
|
@ -628,6 +652,7 @@ async fn main() -> Result<()> {
|
|||
print!("{}", clap_markdown::help_markdown::<Cli>());
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Open { target } => open_url(&socket, target).await,
|
||||
Cmd::Completions { shell } => {
|
||||
generate_completions(shell);
|
||||
Ok(())
|
||||
|
|
@ -635,6 +660,41 @@ async fn main() -> Result<()> {
|
|||
}
|
||||
}
|
||||
|
||||
/// `open <home|forge|matrix>` — resolve the surface URL from the daemon,
|
||||
/// print it, then best-effort `xdg-open` it. Printing is the reliable
|
||||
/// core (headless / SSH hosts where no browser opener exists); the open
|
||||
/// is convenience on top, so a missing/failed `xdg-open` is not an error.
|
||||
async fn open_url(socket: &Path, target: OpenTarget) -> Result<()> {
|
||||
let urls = query_hive_urls(socket).await.context(
|
||||
"could not reach the hive-c0re daemon for URLs — is hive-c0re running? \
|
||||
(the socket is at /run/hyperhive/host.sock)",
|
||||
)?;
|
||||
let (url, hint) = match target {
|
||||
OpenTarget::Home => (
|
||||
urls.home,
|
||||
"the dashboard URL needs `services.hyperhive.domain` to be set",
|
||||
),
|
||||
OpenTarget::Forge => (
|
||||
urls.forge,
|
||||
"the public forge URL needs `services.hyperhive.forge.behindGateway = true`",
|
||||
),
|
||||
OpenTarget::Matrix => (
|
||||
urls.matrix,
|
||||
"the matrix GUI URL needs `services.hyperhive.matrix.gui.enable = true`",
|
||||
),
|
||||
};
|
||||
let url = url.with_context(|| format!("no URL available for this surface — {hint}"))?;
|
||||
println!("{url}");
|
||||
// Best-effort: many hosts are headless, so a missing opener or a
|
||||
// non-zero exit is fine — the URL is already printed.
|
||||
match std::process::Command::new("xdg-open").arg(&url).status() {
|
||||
Ok(status) if status.success() => {}
|
||||
Ok(status) => eprintln!("note: xdg-open exited with {status} (URL printed above)"),
|
||||
Err(e) => eprintln!("note: could not run xdg-open ({e}) (URL printed above)"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Emit a shell completion script for `hivectl` to stdout. Walks the clap
|
||||
/// command tree (the single source of truth — same tree `markdown-docs`
|
||||
/// renders) so completions never drift from the actual verbs/flags.
|
||||
|
|
@ -658,14 +718,20 @@ const WG_INTERFACE: &str = "wg-hive";
|
|||
const HIVE_TLS_CA_PATH: &str = "/var/lib/hive-tls/ca.pem";
|
||||
|
||||
/// Best-effort query for this hive's domain from the running daemon
|
||||
/// (`HostRequest::HiveDomain`, which reads `HYPERHIVE_HIVE_DOMAIN` from
|
||||
/// c0re's service env). `None` when the daemon is unreachable or the
|
||||
/// domain is unset — callers decide whether that's fatal.
|
||||
/// (`HostRequest::Urls`, which reads `HYPERHIVE_HIVE_DOMAIN` from c0re's
|
||||
/// service env). `None` when the daemon is unreachable or the domain is
|
||||
/// unset — callers decide whether that's fatal.
|
||||
async fn query_hive_domain(socket: &Path) -> Option<String> {
|
||||
hive_c0re::client::request(socket, hive_sh4re::HostRequest::HiveDomain)
|
||||
query_hive_urls(socket).await.and_then(|u| u.domain)
|
||||
}
|
||||
|
||||
/// Best-effort query for this hive's domain + browser-facing web URLs
|
||||
/// (`HostRequest::Urls`). `None` when the daemon is unreachable.
|
||||
async fn query_hive_urls(socket: &Path) -> Option<hive_sh4re::HiveUrls> {
|
||||
hive_c0re::client::request(socket, hive_sh4re::HostRequest::Urls)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|r| r.domain)
|
||||
.and_then(|r| r.urls)
|
||||
}
|
||||
|
||||
/// Require this hive's domain from the daemon for snippet generation.
|
||||
|
|
|
|||
|
|
@ -155,16 +155,12 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
|||
.collect();
|
||||
HostResponse::agent_statuses(rows)
|
||||
}
|
||||
// The hive domain is injected into c0re's service env by
|
||||
// hive-c0re.nix (`HYPERHIVE_HIVE_DOMAIN`); surface it so the
|
||||
// operator CLI can fill in this hive's own identity.
|
||||
HostRequest::HiveDomain => HostResponse::hive_domain(
|
||||
// Treat an empty env value as unset — otherwise the CLI
|
||||
// would emit `swarm.peers."" = …`, invalid nix.
|
||||
std::env::var("HYPERHIVE_HIVE_DOMAIN")
|
||||
.ok()
|
||||
.filter(|d| !d.is_empty()),
|
||||
),
|
||||
// The hive domain + per-surface public URLs are injected into
|
||||
// c0re's service env by hive-c0re.nix; surface them so the
|
||||
// operator CLI can fill in this hive's own identity (the
|
||||
// federation peer-config block) and open the web surfaces
|
||||
// (`hivectl open`).
|
||||
HostRequest::Urls => HostResponse::urls(hive_urls()),
|
||||
HostRequest::Pending => HostResponse::pending(coord.approvals.pending()?),
|
||||
HostRequest::Approve { id } => {
|
||||
actions::approve(coord.clone(), *id).await?;
|
||||
|
|
@ -261,7 +257,7 @@ async fn handle_restart_all() -> Result<HostResponse> {
|
|||
error: Some(errors.join("; ")),
|
||||
agents: Some(ok_agents),
|
||||
approvals: None,
|
||||
domain: None,
|
||||
urls: None,
|
||||
agent_statuses: None,
|
||||
})
|
||||
}
|
||||
|
|
@ -379,6 +375,25 @@ fn is_broad_scope(scope: &LifecycleScope) -> bool {
|
|||
scope.agents || scope.is_everything()
|
||||
}
|
||||
|
||||
/// Assemble this hive's domain + browser-facing web URLs from c0re's
|
||||
/// service env (injected by hive-c0re.nix). Each field is `None` when its
|
||||
/// surface isn't browser-reachable (domain unset, forge not behind the
|
||||
/// gateway, matrix GUI off), so the CLI can hint precisely instead of
|
||||
/// opening a dead link. Scheme matches the existing `HIVE_FORGE_PUBLIC_URL`
|
||||
/// convention (gateway terminates TLS, so https).
|
||||
fn hive_urls() -> hive_sh4re::HiveUrls {
|
||||
// Treat an empty env value as unset everywhere — an empty domain would
|
||||
// otherwise render `swarm.peers."" = …` (invalid nix) and `https:///`.
|
||||
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
|
||||
let domain = env("HYPERHIVE_HIVE_DOMAIN");
|
||||
hive_sh4re::HiveUrls {
|
||||
home: domain.as_ref().map(|d| format!("https://{d}/")),
|
||||
forge: env("HIVE_FORGE_PUBLIC_URL"),
|
||||
matrix: env("HIVE_MATRIX_PUBLIC_URL"),
|
||||
domain,
|
||||
}
|
||||
}
|
||||
|
||||
async fn scoped_agents(scope: &LifecycleScope) -> Result<Vec<String>> {
|
||||
use std::collections::BTreeSet;
|
||||
let mut set: BTreeSet<String> = BTreeSet::new();
|
||||
|
|
@ -430,7 +445,7 @@ fn finish_lifecycle(ok_items: Vec<String>, errors: &[String]) -> HostResponse {
|
|||
error: Some(errors.join("; ")),
|
||||
agents: Some(ok_items),
|
||||
approvals: None,
|
||||
domain: None,
|
||||
urls: None,
|
||||
agent_statuses: None,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,10 +50,13 @@ pub enum HostRequest {
|
|||
/// Reuses the dashboard's per-agent `ContainerView` aggregation.
|
||||
AgentStatus,
|
||||
/// Report this hive's canonical DNS domain
|
||||
/// (`services.hyperhive.domain`), or `None` when unset. Lets the
|
||||
/// operator CLI fill in the hive's own identity (e.g. the federation
|
||||
/// peer-config block) without the operator retyping it.
|
||||
HiveDomain,
|
||||
/// (`services.hyperhive.domain`) plus the browser-facing home /
|
||||
/// forge / matrix URLs, daemon-sourced so custom forge/matrix
|
||||
/// domains resolve correctly. Each URL is `None` when its subsystem
|
||||
/// is unreachable from a browser (e.g. forge not behind the gateway,
|
||||
/// matrix GUI disabled). Backs `hivectl open` + the federation
|
||||
/// peer-config block (which reads the bare `domain`).
|
||||
Urls,
|
||||
/// List pending approval requests.
|
||||
Pending,
|
||||
/// Approve a pending request by id; the action runs immediately.
|
||||
|
|
@ -132,6 +135,25 @@ impl LifecycleScope {
|
|||
}
|
||||
}
|
||||
|
||||
/// This hive's canonical domain plus the browser-facing URLs for its
|
||||
/// web surfaces — the `Urls` request result. Every field is `None` when
|
||||
/// the corresponding surface can't be reached from a browser (domain
|
||||
/// unset, forge not behind the gateway, matrix GUI disabled), so the CLI
|
||||
/// can give a precise hint instead of opening a dead link.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct HiveUrls {
|
||||
/// Canonical hive domain (`services.hyperhive.domain`).
|
||||
pub domain: Option<String>,
|
||||
/// Operator dashboard root (`https://<domain>/`).
|
||||
pub home: Option<String>,
|
||||
/// Forge browser URL (`HIVE_FORGE_PUBLIC_URL`) — only the
|
||||
/// behind-gateway public URL; `None` on direct-port forge deploys.
|
||||
pub forge: Option<String>,
|
||||
/// Matrix GUI (fluffychat) browser URL — `None` when the matrix GUI
|
||||
/// is disabled.
|
||||
pub matrix: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HostResponse {
|
||||
pub ok: bool,
|
||||
|
|
@ -141,10 +163,10 @@ pub struct HostResponse {
|
|||
pub agents: Option<Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub approvals: Option<Vec<Approval>>,
|
||||
/// This hive's canonical DNS domain — `HiveDomain` result. `None`
|
||||
/// when the domain is unset (no `services.hyperhive.domain`).
|
||||
/// `Urls` result — this hive's domain plus the browser-facing
|
||||
/// home / forge / matrix URLs. `None` for every other request kind.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub domain: Option<String>,
|
||||
pub urls: Option<HiveUrls>,
|
||||
/// `AgentStatus` result — one row per managed agent with its
|
||||
/// running/health flags + technical state. `None` for every other
|
||||
/// request kind.
|
||||
|
|
@ -245,7 +267,7 @@ impl HostResponse {
|
|||
error: None,
|
||||
agents: None,
|
||||
approvals: None,
|
||||
domain: None,
|
||||
urls: None,
|
||||
agent_statuses: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -257,7 +279,7 @@ impl HostResponse {
|
|||
error: Some(message.into()),
|
||||
agents: None,
|
||||
approvals: None,
|
||||
domain: None,
|
||||
urls: None,
|
||||
agent_statuses: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -269,7 +291,7 @@ impl HostResponse {
|
|||
error: None,
|
||||
agents: Some(agents),
|
||||
approvals: None,
|
||||
domain: None,
|
||||
urls: None,
|
||||
agent_statuses: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -281,20 +303,20 @@ impl HostResponse {
|
|||
error: None,
|
||||
agents: None,
|
||||
approvals: Some(approvals),
|
||||
domain: None,
|
||||
urls: None,
|
||||
agent_statuses: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `HiveDomain` result — this hive's canonical domain (or `None`).
|
||||
/// `Urls` result — this hive's domain + browser-facing web URLs.
|
||||
#[must_use]
|
||||
pub fn hive_domain(domain: Option<String>) -> Self {
|
||||
pub fn urls(urls: HiveUrls) -> Self {
|
||||
Self {
|
||||
ok: true,
|
||||
error: None,
|
||||
agents: None,
|
||||
approvals: None,
|
||||
domain,
|
||||
urls: Some(urls),
|
||||
agent_statuses: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -307,7 +329,7 @@ impl HostResponse {
|
|||
error: None,
|
||||
agents: None,
|
||||
approvals: None,
|
||||
domain: None,
|
||||
urls: None,
|
||||
agent_statuses: Some(rows),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -953,6 +953,18 @@ in
|
|||
# falls back to `<hostname>:3000`.
|
||||
HIVE_FORGE_PUBLIC_URL = "https://${config.services.hyperhive.forge.domain}";
|
||||
}
|
||||
//
|
||||
lib.optionalAttrs
|
||||
(
|
||||
config.services.hyperhive.matrix.gui.enable && config.services.hyperhive.matrix.gatewayHost != null
|
||||
)
|
||||
{
|
||||
# Browser-facing matrix GUI (fluffychat) URL — the gateway
|
||||
# vhost (`matrix.<domain>`). Surfaced via the daemon's `Urls`
|
||||
# request for `hivectl open matrix`. Absent when the GUI is off
|
||||
# or no gatewayHost is set (no browser-reachable matrix vhost).
|
||||
HIVE_MATRIX_PUBLIC_URL = "https://${config.services.hyperhive.matrix.gatewayHost}/";
|
||||
}
|
||||
// lib.optionalAttrs (config.services.hyperhive.swarm.peers != { }) {
|
||||
# Peer hives serialised as a JSON array of {domain, cert_fingerprint,
|
||||
# wireguard_address?} objects. Consumed by hive-ag3nt::identity::peers()
|
||||
|
|
|
|||
Loading…
Reference in a new issue