refactor(#2302): type socket wire fields as ident, validated by serde on deserialize
This commit is contained in:
parent
bf644cc126
commit
84b750fba5
33 changed files with 333 additions and 263 deletions
12
Cargo.lock
generated
12
Cargo.lock
generated
|
|
@ -1619,6 +1619,7 @@ dependencies = [
|
||||||
"hive-host-sock",
|
"hive-host-sock",
|
||||||
"hive-priv-sock",
|
"hive-priv-sock",
|
||||||
"hive-sh4re",
|
"hive-sh4re",
|
||||||
|
"hive-types",
|
||||||
"hmac 0.13.0",
|
"hmac 0.13.0",
|
||||||
"indicatif",
|
"indicatif",
|
||||||
"libc",
|
"libc",
|
||||||
|
|
@ -1671,8 +1672,8 @@ name = "hive-host-sock"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hive-sh4re",
|
"hive-sh4re",
|
||||||
|
"hive-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1758,6 +1759,14 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hive-types"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hivectl"
|
name = "hivectl"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -1768,6 +1777,7 @@ dependencies = [
|
||||||
"clap_complete",
|
"clap_complete",
|
||||||
"hive-host-sock",
|
"hive-host-sock",
|
||||||
"hive-sh4re",
|
"hive-sh4re",
|
||||||
|
"hive-types",
|
||||||
"indicatif",
|
"indicatif",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ members = [
|
||||||
"hive-priv",
|
"hive-priv",
|
||||||
"hive-priv-sock",
|
"hive-priv-sock",
|
||||||
"hive-sh4re",
|
"hive-sh4re",
|
||||||
|
"hive-types",
|
||||||
"hivectl",
|
"hivectl",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -54,6 +55,7 @@ hive-agent-sock = { path = "hive-agent-sock" }
|
||||||
hive-claude = { path = "hive-claude" }
|
hive-claude = { path = "hive-claude" }
|
||||||
hive-host-sock = { path = "hive-host-sock" }
|
hive-host-sock = { path = "hive-host-sock" }
|
||||||
hive-priv-sock = { path = "hive-priv-sock" }
|
hive-priv-sock = { path = "hive-priv-sock" }
|
||||||
|
hive-types = { path = "hive-types" }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
tower-http = { version = "0.7", features = ["fs"] }
|
tower-http = { version = "0.7", features = ["fs"] }
|
||||||
rmcp = { version = "2", default-features = false, features = [
|
rmcp = { version = "2", default-features = false, features = [
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ hive-agent-sock.workspace = true
|
||||||
hive-sh4re.workspace = true
|
hive-sh4re.workspace = true
|
||||||
hive-host-sock.workspace = true
|
hive-host-sock.workspace = true
|
||||||
hive-priv-sock.workspace = true
|
hive-priv-sock.workspace = true
|
||||||
|
hive-types.workspace = true
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
listenfd = "1"
|
listenfd = "1"
|
||||||
petgraph.workspace = true
|
petgraph.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
|
||||||
// Sub-second git seed + forge-remote wire. Routing through
|
// Sub-second git seed + forge-remote wire. Routing through
|
||||||
// the queue would surface a queue card that's gone before
|
// the queue would surface a queue card that's gone before
|
||||||
// the operator's eyes refocus. Run inline.
|
// the operator's eyes refocus. Run inline.
|
||||||
let agent = hive_host_sock::Ident::parse(&approval.agent).map_err(|e| {
|
let agent = hive_types::Ident::parse(&approval.agent).map_err(|e| {
|
||||||
anyhow::anyhow!("approval {} has invalid agent name: {e}", approval.id)
|
anyhow::anyhow!("approval {} has invalid agent name: {e}", approval.id)
|
||||||
})?;
|
})?;
|
||||||
let proposed_dir = Coordinator::agent_proposed_dir(&agent);
|
let proposed_dir = Coordinator::agent_proposed_dir(&agent);
|
||||||
|
|
@ -804,7 +804,7 @@ pub async fn destroy(coord: &Arc<Coordinator>, name: &str, purge: bool) -> Resul
|
||||||
// A malformed name can't have a persistent state tree (the state dir
|
// A malformed name can't have a persistent state tree (the state dir
|
||||||
// is only ever created under a validated Ident), so its removal is a
|
// is only ever created under a validated Ident), so its removal is a
|
||||||
// no-op — skip the state-dir sweep and just clear the applied dir.
|
// no-op — skip the state-dir sweep and just clear the applied dir.
|
||||||
let state_dir = hive_host_sock::Ident::parse(name)
|
let state_dir = hive_types::Ident::parse(name)
|
||||||
.ok()
|
.ok()
|
||||||
.map(|id| crate::paths::agent_state_dir(&id));
|
.map(|id| crate::paths::agent_state_dir(&id));
|
||||||
for dir in state_dir
|
for dir in state_dir
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
|
||||||
// Parse the nspawn machine suffix into an Ident once at this
|
// Parse the nspawn machine suffix into an Ident once at this
|
||||||
// enumeration origin; a suffix that isn't a valid ident isn't one
|
// enumeration origin; a suffix that isn't a valid ident isn't one
|
||||||
// of our agents, so skip it.
|
// of our agents, so skip it.
|
||||||
let Ok(logical) = hive_host_sock::Ident::parse(logical) else {
|
let Ok(logical) = hive_types::Ident::parse(logical) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let deployed_full = locked
|
let deployed_full = locked
|
||||||
|
|
@ -133,7 +133,7 @@ pub fn claude_has_session(dir: &Path) -> bool {
|
||||||
/// the consolidated `hyperhive-harness.json`. Falls back to the legacy
|
/// the consolidated `hyperhive-harness.json`. Falls back to the legacy
|
||||||
/// individual sentinel files written by older harness builds so in-place
|
/// individual sentinel files written by older harness builds so in-place
|
||||||
/// upgrades don't lose state during the transition window.
|
/// upgrades don't lose state during the transition window.
|
||||||
fn read_harness_flags(name: &hive_host_sock::Ident) -> (bool, bool) {
|
fn read_harness_flags(name: &hive_types::Ident) -> (bool, bool) {
|
||||||
let dir = Coordinator::agent_notes_dir(name);
|
let dir = Coordinator::agent_notes_dir(name);
|
||||||
if let Ok(raw) = std::fs::read_to_string(dir.join("hyperhive-harness.json"))
|
if let Ok(raw) = std::fs::read_to_string(dir.join("hyperhive-harness.json"))
|
||||||
&& let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw)
|
&& let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw)
|
||||||
|
|
@ -154,7 +154,7 @@ fn read_harness_flags(name: &hive_host_sock::Ident) -> (bool, bool) {
|
||||||
(rate_limited, needs_login)
|
(rate_limited, needs_login)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn auth_failed_sentinel(name: &hive_host_sock::Ident) -> bool {
|
fn auth_failed_sentinel(name: &hive_types::Ident) -> bool {
|
||||||
read_harness_flags(name).1
|
read_harness_flags(name).1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,7 +165,7 @@ fn auth_failed_sentinel(name: &hive_host_sock::Ident) -> bool {
|
||||||
/// NB: callers building `AgentMeta` for a *stopped* container should
|
/// NB: callers building `AgentMeta` for a *stopped* container should
|
||||||
/// clear the result — the on-disk status is a stale snapshot from
|
/// clear the result — the on-disk status is a stale snapshot from
|
||||||
/// before the stop. Use `read_agent_status_live` for that.
|
/// before the stop. Use `read_agent_status_live` for that.
|
||||||
pub fn read_agent_status(name: &hive_host_sock::Ident) -> (Option<String>, Option<i64>) {
|
pub fn read_agent_status(name: &hive_types::Ident) -> (Option<String>, Option<i64>) {
|
||||||
let path = Coordinator::agent_notes_dir(name).join("hyperhive-status");
|
let path = Coordinator::agent_notes_dir(name).join("hyperhive-status");
|
||||||
let meta = std::fs::metadata(&path).ok();
|
let meta = std::fs::metadata(&path).ok();
|
||||||
// Read at most STATUS_MAX_CHARS * 4 + 2 bytes: 4 is the max UTF-8 byte
|
// Read at most STATUS_MAX_CHARS * 4 + 2 bytes: 4 is the max UTF-8 byte
|
||||||
|
|
@ -206,7 +206,7 @@ pub fn read_agent_status(name: &hive_host_sock::Ident) -> (Option<String>, Optio
|
||||||
/// Returned tuple is `(status_text, status_set_at, running)`.
|
/// Returned tuple is `(status_text, status_set_at, running)`.
|
||||||
/// `name` is the logical agent name (same as the broker recipient).
|
/// `name` is the logical agent name (same as the broker recipient).
|
||||||
pub async fn read_agent_status_live(
|
pub async fn read_agent_status_live(
|
||||||
name: &hive_host_sock::Ident,
|
name: &hive_types::Ident,
|
||||||
) -> (Option<String>, Option<i64>, bool) {
|
) -> (Option<String>, Option<i64>, bool) {
|
||||||
if !lifecycle::is_running(name.as_str()).await {
|
if !lifecycle::is_running(name.as_str()).await {
|
||||||
return (None, None, false);
|
return (None, None, false);
|
||||||
|
|
@ -221,7 +221,7 @@ pub async fn read_agent_status_live(
|
||||||
/// so it always reflects the resolved priority (nix config > runtime
|
/// so it always reflects the resolved priority (nix config > runtime
|
||||||
/// override > default). Returns `None` when the field is absent or the
|
/// override > default). Returns `None` when the field is absent or the
|
||||||
/// harness has not yet started a turn.
|
/// harness has not yet started a turn.
|
||||||
fn read_active_model(name: &hive_host_sock::Ident) -> Option<String> {
|
fn read_active_model(name: &hive_types::Ident) -> Option<String> {
|
||||||
let path = Coordinator::agent_notes_dir(name).join("hyperhive-harness.json");
|
let path = Coordinator::agent_notes_dir(name).join("hyperhive-harness.json");
|
||||||
let raw = std::fs::read_to_string(path).ok()?;
|
let raw = std::fs::read_to_string(path).ok()?;
|
||||||
let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
|
let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
|
||||||
|
|
|
||||||
|
|
@ -553,7 +553,7 @@ impl Coordinator {
|
||||||
// MANAGER_NAME const), so an invalid ident here is a construction
|
// MANAGER_NAME const), so an invalid ident here is a construction
|
||||||
// bug. This is the step-3 boundary between the Ident-threaded path
|
// bug. This is the step-3 boundary between the Ident-threaded path
|
||||||
// builders and the job_queue layer (threaded post hive-jobq cutover).
|
// builders and the job_queue layer (threaded post hive-jobq cutover).
|
||||||
let name = hive_host_sock::Ident::parse(name)
|
let name = hive_types::Ident::parse(name)
|
||||||
.expect("agent_paths: name must be a valid ident (validated at spawn/enqueue)");
|
.expect("agent_paths: name must be a valid ident (validated at spawn/enqueue)");
|
||||||
AgentPaths {
|
AgentPaths {
|
||||||
agent: agent_dir,
|
agent: agent_dir,
|
||||||
|
|
@ -1448,7 +1448,7 @@ impl Coordinator {
|
||||||
|
|
||||||
/// Manager-editable proposed config repo. Bind-mounted into the manager
|
/// Manager-editable proposed config repo. Bind-mounted into the manager
|
||||||
/// container as `/agents/<name>/config/`.
|
/// container as `/agents/<name>/config/`.
|
||||||
pub fn agent_proposed_dir(name: &hive_host_sock::Ident) -> PathBuf {
|
pub fn agent_proposed_dir(name: &hive_types::Ident) -> PathBuf {
|
||||||
crate::paths::agent_state_dir(name).join("config")
|
crate::paths::agent_state_dir(name).join("config")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1456,14 +1456,14 @@ impl Coordinator {
|
||||||
/// container at `/root/.claude` so OAuth state survives container
|
/// container at `/root/.claude` so OAuth state survives container
|
||||||
/// destroy/recreate. Each agent owns its own token lineage — sharing
|
/// destroy/recreate. Each agent owns its own token lineage — sharing
|
||||||
/// would break on the first refresh-token rotation.
|
/// would break on the first refresh-token rotation.
|
||||||
pub fn agent_claude_dir(name: &hive_host_sock::Ident) -> PathBuf {
|
pub fn agent_claude_dir(name: &hive_types::Ident) -> PathBuf {
|
||||||
crate::paths::agent_state_dir(name).join("claude")
|
crate::paths::agent_state_dir(name).join("claude")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-agent durable knowledge dir. Bind-mounted RW into the agent
|
/// Per-agent durable knowledge dir. Bind-mounted RW into the agent
|
||||||
/// container at `/agents/{name}/state`. Survives destroy/recreate.
|
/// container at `/agents/{name}/state`. Survives destroy/recreate.
|
||||||
/// Agent-visible — claude is told to write long-lived notes here.
|
/// Agent-visible — claude is told to write long-lived notes here.
|
||||||
pub fn agent_notes_dir(name: &hive_host_sock::Ident) -> PathBuf {
|
pub fn agent_notes_dir(name: &hive_types::Ident) -> PathBuf {
|
||||||
crate::paths::agent_state_dir(name).join("state")
|
crate::paths::agent_state_dir(name).join("state")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1473,7 +1473,7 @@ impl Coordinator {
|
||||||
/// `hyperhive-turn-stats.sqlite`, `hyperhive-model`) — kept separate
|
/// `hyperhive-turn-stats.sqlite`, `hyperhive-model`) — kept separate
|
||||||
/// from the agent-visible `state/` so claude's "my notes" view is
|
/// from the agent-visible `state/` so claude's "my notes" view is
|
||||||
/// uncluttered and the host vacuum has a clean sweep root.
|
/// uncluttered and the host vacuum has a clean sweep root.
|
||||||
pub fn agent_harness_dir(name: &hive_host_sock::Ident) -> PathBuf {
|
pub fn agent_harness_dir(name: &hive_types::Ident) -> PathBuf {
|
||||||
crate::paths::agent_state_dir(name).join("harness")
|
crate::paths::agent_state_dir(name).join("harness")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1483,14 +1483,14 @@ impl Coordinator {
|
||||||
/// destroyed-but-kept tombstones; callers filter the latter by
|
/// destroyed-but-kept tombstones; callers filter the latter by
|
||||||
/// subtracting `lifecycle::list()`.
|
/// subtracting `lifecycle::list()`.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn kept_state_names() -> Vec<hive_host_sock::Ident> {
|
pub fn kept_state_names() -> Vec<hive_types::Ident> {
|
||||||
let Ok(rd) = std::fs::read_dir(crate::paths::agents_root()) else {
|
let Ok(rd) = std::fs::read_dir(crate::paths::agents_root()) else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
let mut out: Vec<hive_host_sock::Ident> = rd
|
let mut out: Vec<hive_types::Ident> = rd
|
||||||
.flatten()
|
.flatten()
|
||||||
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
|
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
|
||||||
.filter_map(|e| hive_host_sock::Ident::parse(&e.file_name().into_string().ok()?).ok())
|
.filter_map(|e| hive_types::Ident::parse(&e.file_name().into_string().ok()?).ok())
|
||||||
.collect();
|
.collect();
|
||||||
out.sort();
|
out.sort();
|
||||||
out
|
out
|
||||||
|
|
@ -1503,7 +1503,7 @@ impl Coordinator {
|
||||||
/// apply-commit spawns the container. Distinct from tombstones,
|
/// apply-commit spawns the container. Distinct from tombstones,
|
||||||
/// which have an applied repo from a prior deploy.
|
/// which have an applied repo from a prior deploy.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn pending_init_names() -> Vec<hive_host_sock::Ident> {
|
pub fn pending_init_names() -> Vec<hive_types::Ident> {
|
||||||
Self::kept_state_names()
|
Self::kept_state_names()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|n| {
|
.filter(|n| {
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<A
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
let Ok(agent) = hive_host_sock::Ident::parse(&a.agent) else {
|
let Ok(agent) = hive_types::Ident::parse(&a.agent) else {
|
||||||
let _ = coord.approvals.mark_failed(a.id, "invalid agent name");
|
let _ = coord.approvals.mark_failed(a.id, "invalid agent name");
|
||||||
tracing::warn!(id = a.id, agent = %a.agent, "auto-failed approval with invalid agent name");
|
tracing::warn!(id = a.id, agent = %a.agent, "auto-failed approval with invalid agent name");
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ mod extra_forges;
|
||||||
// owning agent-path facts) so every dashboard path-param validates through the
|
// owning agent-path facts) so every dashboard path-param validates through the
|
||||||
// same type used to build agent paths. Re-exported so submodules + the socket
|
// same type used to build agent paths. Re-exported so submodules + the socket
|
||||||
// server reach it as `crate::dashboard::Ident`.
|
// server reach it as `crate::dashboard::Ident`.
|
||||||
pub(crate) use hive_host_sock::Ident;
|
pub(crate) use hive_types::Ident;
|
||||||
mod infra_containers;
|
mod infra_containers;
|
||||||
mod journal;
|
mod journal;
|
||||||
mod lifecycle_ops;
|
mod lifecycle_ops;
|
||||||
|
|
|
||||||
|
|
@ -344,7 +344,7 @@ pub(super) async fn get_stale_permissions(
|
||||||
let kept: std::collections::HashSet<String> =
|
let kept: std::collections::HashSet<String> =
|
||||||
crate::coordinator::Coordinator::kept_state_names()
|
crate::coordinator::Coordinator::kept_state_names()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(hive_host_sock::Ident::into_string)
|
.map(hive_types::Ident::into_string)
|
||||||
.collect();
|
.collect();
|
||||||
// Known = live roster ∪ kept-state names.
|
// Known = live roster ∪ kept-state names.
|
||||||
let known: std::collections::HashSet<&String> = live.iter().chain(kept.iter()).collect();
|
let known: std::collections::HashSet<&String> = live.iter().chain(kept.iter()).collect();
|
||||||
|
|
|
||||||
|
|
@ -422,7 +422,7 @@ pub async fn ensure_meta_remote(name: &str) -> Result<()> {
|
||||||
}
|
}
|
||||||
// A malformed name has no proposed config repo (repos are only created
|
// A malformed name has no proposed config repo (repos are only created
|
||||||
// under a validated Ident), so there's nothing to wire — no-op.
|
// under a validated Ident), so there's nothing to wire — no-op.
|
||||||
let Ok(agent) = hive_host_sock::Ident::parse(name) else {
|
let Ok(agent) = hive_types::Ident::parse(name) else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let proposed_dir = Coordinator::agent_proposed_dir(&agent);
|
let proposed_dir = Coordinator::agent_proposed_dir(&agent);
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ pub const CONTAINER_MANAGER_APPLIED_MOUNT: &str = "/applied";
|
||||||
/// state") for the rationale. Creates missing host-side directories so
|
/// state") for the rationale. Creates missing host-side directories so
|
||||||
/// nspawn doesn't refuse to start; missing dirs are non-fatal.
|
/// nspawn doesn't refuse to start; missing dirs are non-fatal.
|
||||||
fn bind_child_agent_dirs(child: &str, binds: &mut Vec<BindMount>) {
|
fn bind_child_agent_dirs(child: &str, binds: &mut Vec<BindMount>) {
|
||||||
let Ok(child) = hive_host_sock::Ident::parse(child) else {
|
let Ok(child) = hive_types::Ident::parse(child) else {
|
||||||
tracing::warn!(%child, "skipping child bind: invalid agent name");
|
tracing::warn!(%child, "skipping child bind: invalid agent name");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
@ -201,7 +201,7 @@ async fn set_nspawn_flags(
|
||||||
read_only: false,
|
read_only: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let agent_id = hive_host_sock::Ident::parse(agent_name)
|
let agent_id = hive_types::Ident::parse(agent_name)
|
||||||
.map_err(|e| anyhow::anyhow!("invalid agent name {agent_name:?}: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("invalid agent name {agent_name:?}: {e}"))?;
|
||||||
let own_config = crate::paths::agent_state_dir(&agent_id).join("config");
|
let own_config = crate::paths::agent_state_dir(&agent_id).join("config");
|
||||||
std::fs::create_dir_all(&own_config)
|
std::fs::create_dir_all(&own_config)
|
||||||
|
|
|
||||||
|
|
@ -212,7 +212,7 @@ pub fn ensure_state_dir(notes_dir: &Path) -> Result<()> {
|
||||||
/// brand-new agent on a btrfs host gets a real subvolume. Subvolume creation
|
/// brand-new agent on a btrfs host gets a real subvolume. Subvolume creation
|
||||||
/// is privileged, so it's delegated to hive-priv.
|
/// is privileged, so it's delegated to hive-priv.
|
||||||
pub async fn ensure_agent_state_subvolume(name: &str) -> Result<()> {
|
pub async fn ensure_agent_state_subvolume(name: &str) -> Result<()> {
|
||||||
let agent = hive_host_sock::Ident::parse(name)
|
let agent = hive_types::Ident::parse(name)
|
||||||
.map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?;
|
||||||
let root = crate::paths::agent_state_dir(&agent);
|
let root = crate::paths::agent_state_dir(&agent);
|
||||||
if root.exists() {
|
if root.exists() {
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ pub fn admin_token_path() -> PathBuf {
|
||||||
|
|
||||||
/// Token file inside the agent's bind-mounted state dir (visible as
|
/// Token file inside the agent's bind-mounted state dir (visible as
|
||||||
/// `/state/matrix-token` from inside the container).
|
/// `/state/matrix-token` from inside the container).
|
||||||
fn token_path(name: &hive_host_sock::Ident) -> PathBuf {
|
fn token_path(name: &hive_types::Ident) -> PathBuf {
|
||||||
Coordinator::agent_notes_dir(name).join("matrix-token")
|
Coordinator::agent_notes_dir(name).join("matrix-token")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,7 +89,7 @@ fn password_path(name: &str) -> PathBuf {
|
||||||
/// move credentials from old deployments to the new location. Safe to
|
/// move credentials from old deployments to the new location. Safe to
|
||||||
/// call after `destroy --purge` — the path will simply not exist and
|
/// call after `destroy --purge` — the path will simply not exist and
|
||||||
/// the migration is a no-op.
|
/// the migration is a no-op.
|
||||||
fn legacy_password_path(name: &hive_host_sock::Ident) -> PathBuf {
|
fn legacy_password_path(name: &hive_types::Ident) -> PathBuf {
|
||||||
Coordinator::agent_notes_dir(name).join("matrix-password")
|
Coordinator::agent_notes_dir(name).join("matrix-password")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -611,7 +611,7 @@ pub async fn ensure_user_for(
|
||||||
register_token: &str,
|
register_token: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
let agent = hive_host_sock::Ident::parse(name)
|
let agent = hive_types::Ident::parse(name)
|
||||||
.map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?;
|
||||||
let path = token_path(&agent);
|
let path = token_path(&agent);
|
||||||
if path.exists()
|
if path.exists()
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
|
||||||
let agent_names: Vec<String> = agents.iter().map(|a| a.name.clone()).collect();
|
let agent_names: Vec<String> = agents.iter().map(|a| a.name.clone()).collect();
|
||||||
let pending: Vec<String> = crate::coordinator::Coordinator::pending_init_names()
|
let pending: Vec<String> = crate::coordinator::Coordinator::pending_init_names()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(hive_host_sock::Ident::into_string)
|
.map(hive_types::Ident::into_string)
|
||||||
.collect();
|
.collect();
|
||||||
crate::topology::reconcile(&agent_names, &pending)
|
crate::topology::reconcile(&agent_names, &pending)
|
||||||
.with_context(|| format!("reconcile {}", crate::topology::topology_path().display()))?;
|
.with_context(|| format!("reconcile {}", crate::topology::topology_path().display()))?;
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,7 @@ pub async fn run(coord: &Arc<Coordinator>) -> Result<()> {
|
||||||
/// and into the sibling harness dir. Best-effort: logs warnings but never
|
/// and into the sibling harness dir. Best-effort: logs warnings but never
|
||||||
/// fails. Idempotent — each file is only moved if present at the old path
|
/// fails. Idempotent — each file is only moved if present at the old path
|
||||||
/// and absent at the new path.
|
/// and absent at the new path.
|
||||||
fn migrate_harness_files(name: &hive_host_sock::Ident) {
|
fn migrate_harness_files(name: &hive_types::Ident) {
|
||||||
const HARNESS_FILES: &[&str] = &[
|
const HARNESS_FILES: &[&str] = &[
|
||||||
"hyperhive-events.sqlite",
|
"hyperhive-events.sqlite",
|
||||||
"hyperhive-turn-stats.sqlite",
|
"hyperhive-turn-stats.sqlite",
|
||||||
|
|
@ -267,7 +267,7 @@ async fn rename_manager_container(coord: &Arc<Coordinator>) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn enumerate_agents() -> Vec<hive_host_sock::Ident> {
|
async fn enumerate_agents() -> Vec<hive_types::Ident> {
|
||||||
let containers = lifecycle::list().await.unwrap_or_default();
|
let containers = lifecycle::list().await.unwrap_or_default();
|
||||||
containers
|
containers
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
@ -277,7 +277,7 @@ async fn enumerate_agents() -> Vec<hive_host_sock::Ident> {
|
||||||
} else {
|
} else {
|
||||||
c.strip_prefix(AGENT_PREFIX)?
|
c.strip_prefix(AGENT_PREFIX)?
|
||||||
};
|
};
|
||||||
hive_host_sock::Ident::parse(name).ok()
|
hive_types::Ident::parse(name).ok()
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
@ -353,7 +353,7 @@ async fn repoint_container(name: &str) -> Result<()> {
|
||||||
/// Idempotent — skips when entry already present. Prevents a silent tool
|
/// Idempotent — skips when entry already present. Prevents a silent tool
|
||||||
/// downgrade when upgrading from a build that relied on the manager-flavor
|
/// downgrade when upgrading from a build that relied on the manager-flavor
|
||||||
/// fallback in `effective_tool_groups()`.
|
/// fallback in `effective_tool_groups()`.
|
||||||
fn backfill_manager_tool_groups(names: &[hive_host_sock::Ident]) {
|
fn backfill_manager_tool_groups(names: &[hive_types::Ident]) {
|
||||||
if !names.iter().any(|n| n.as_str() == MANAGER_NAME) {
|
if !names.iter().any(|n| n.as_str() == MANAGER_NAME) {
|
||||||
return; // ruth not deployed — nothing to backfill
|
return; // ruth not deployed — nothing to backfill
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,11 +83,11 @@ async fn handle(stream: UnixStream, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
||||||
let result: anyhow::Result<HostResponse> = async {
|
let result: anyhow::Result<HostResponse> = async {
|
||||||
Ok(match req {
|
Ok(match req {
|
||||||
HostRequest::Spawn { name } => handle_spawn(&coord, name).await?,
|
HostRequest::Spawn { name } => handle_spawn(&coord, name.as_str()).await?,
|
||||||
HostRequest::RequestSpawn { name } => {
|
HostRequest::RequestSpawn { name } => {
|
||||||
tracing::info!(%name, "request_spawn");
|
tracing::info!(%name, "request_spawn");
|
||||||
let id = coord.approvals.submit_kind(
|
let id = coord.approvals.submit_kind(
|
||||||
name,
|
name.as_str(),
|
||||||
hive_sh4re::ApprovalKind::Spawn,
|
hive_sh4re::ApprovalKind::Spawn,
|
||||||
"",
|
"",
|
||||||
None,
|
None,
|
||||||
|
|
@ -97,8 +97,10 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
||||||
tracing::info!(%id, %name, "spawn approval queued");
|
tracing::info!(%id, %name, "spawn approval queued");
|
||||||
HostResponse::success()
|
HostResponse::success()
|
||||||
}
|
}
|
||||||
HostRequest::Kill { name } => submit_single(&coord, name, Verb::Kill).await,
|
HostRequest::Kill { name } => submit_single(&coord, name.as_str(), Verb::Kill).await,
|
||||||
HostRequest::Restart { name } => submit_single(&coord, name, Verb::Restart).await,
|
HostRequest::Restart { name } => {
|
||||||
|
submit_single(&coord, name.as_str(), Verb::Restart).await
|
||||||
|
}
|
||||||
HostRequest::RestartAll => handle_restart_all(&coord).await?,
|
HostRequest::RestartAll => handle_restart_all(&coord).await?,
|
||||||
HostRequest::RestartScoped { scope, graceful } => {
|
HostRequest::RestartScoped { scope, graceful } => {
|
||||||
handle_restart_scoped(&coord, scope, *graceful).await?
|
handle_restart_scoped(&coord, scope, *graceful).await?
|
||||||
|
|
@ -140,10 +142,12 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
||||||
handle_start(&coord, &agents, &infra).await?
|
handle_start(&coord, &agents, &infra).await?
|
||||||
}
|
}
|
||||||
HostRequest::Destroy { name, purge } => {
|
HostRequest::Destroy { name, purge } => {
|
||||||
actions::destroy(&coord, name, *purge).await?;
|
actions::destroy(&coord, name.as_str(), *purge).await?;
|
||||||
HostResponse::success()
|
HostResponse::success()
|
||||||
}
|
}
|
||||||
HostRequest::Rebuild { name } => submit_single(&coord, name, Verb::Rebuild).await,
|
HostRequest::Rebuild { name } => {
|
||||||
|
submit_single(&coord, name.as_str(), Verb::Rebuild).await
|
||||||
|
}
|
||||||
HostRequest::QueueDag { id } => {
|
HostRequest::QueueDag { id } => {
|
||||||
// A multi-step op is one DAG now (no fan-out children to gather).
|
// A multi-step op is one DAG now (no fan-out children to gather).
|
||||||
let dags = coord
|
let dags = coord
|
||||||
|
|
@ -179,7 +183,10 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
||||||
// skip both the messages and the disk write per the
|
// skip both the messages and the disk write per the
|
||||||
// topology fast-path.
|
// topology fast-path.
|
||||||
coord
|
coord
|
||||||
.reparent_with_notify(child, new_parent.as_deref())
|
.reparent_with_notify(
|
||||||
|
child.as_str(),
|
||||||
|
new_parent.as_ref().map(hive_types::Ident::as_str),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(anyhow::Error::msg)?;
|
.map_err(anyhow::Error::msg)?;
|
||||||
HostResponse::success()
|
HostResponse::success()
|
||||||
|
|
@ -188,8 +195,12 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
||||||
handle_matrix_create_user(name, password.as_deref()).await?
|
handle_matrix_create_user(name, password.as_deref()).await?
|
||||||
}
|
}
|
||||||
HostRequest::MatrixSyncAdmin => handle_matrix_sync_admin().await?,
|
HostRequest::MatrixSyncAdmin => handle_matrix_sync_admin().await?,
|
||||||
HostRequest::MatrixPromoteUser { name } => handle_matrix_promote_user(name).await?,
|
HostRequest::MatrixPromoteUser { name } => {
|
||||||
HostRequest::MatrixResetPassword { name } => handle_matrix_reset_password(name).await?,
|
handle_matrix_promote_user(name.as_str()).await?
|
||||||
|
}
|
||||||
|
HostRequest::MatrixResetPassword { name } => {
|
||||||
|
handle_matrix_reset_password(name.as_str()).await?
|
||||||
|
}
|
||||||
HostRequest::MatrixInvite { user, room } => {
|
HostRequest::MatrixInvite { user, room } => {
|
||||||
handle_matrix_invite(user, room.as_deref()).await?
|
handle_matrix_invite(user, room.as_deref()).await?
|
||||||
}
|
}
|
||||||
|
|
@ -197,10 +208,10 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
||||||
handle_forge_create_user(name, password.as_deref()).await?
|
handle_forge_create_user(name, password.as_deref()).await?
|
||||||
}
|
}
|
||||||
HostRequest::ReconcileConfigStatus { agent, verbose } => {
|
HostRequest::ReconcileConfigStatus { agent, verbose } => {
|
||||||
crate::forge::reconcile_config_status(agent, *verbose).await?
|
crate::forge::reconcile_config_status(agent.as_str(), *verbose).await?
|
||||||
}
|
}
|
||||||
HostRequest::ReconcileConfigApply { agent, direction } => {
|
HostRequest::ReconcileConfigApply { agent, direction } => {
|
||||||
crate::forge::reconcile_config_apply(agent, *direction).await?
|
crate::forge::reconcile_config_apply(agent.as_str(), *direction).await?
|
||||||
}
|
}
|
||||||
HostRequest::GatewayCreateUser { username, password } => {
|
HostRequest::GatewayCreateUser { username, password } => {
|
||||||
HostResponse::messages(vec![crate::gateway_nginx::create_user(username, password)?])
|
HostResponse::messages(vec![crate::gateway_nginx::create_user(username, password)?])
|
||||||
|
|
@ -212,24 +223,30 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
||||||
HostResponse::messages(crate::gateway_nginx::list_users()?)
|
HostResponse::messages(crate::gateway_nginx::list_users()?)
|
||||||
}
|
}
|
||||||
HostRequest::SetAgentGithubToken { agent, token } => {
|
HostRequest::SetAgentGithubToken { agent, token } => {
|
||||||
handle_set_agent_github_token(agent, token).await?
|
handle_set_agent_github_token(agent.as_str(), token).await?
|
||||||
}
|
}
|
||||||
HostRequest::QuotaEnable => handle_quota_enable().await?,
|
HostRequest::QuotaEnable => handle_quota_enable().await?,
|
||||||
HostRequest::QuotaLimit { name, limit } => handle_quota_limit(name, *limit).await?,
|
HostRequest::QuotaLimit { name, limit } => {
|
||||||
HostRequest::QuotaShow { name } => handle_quota_show(name.as_deref()).await?,
|
handle_quota_limit(name.as_str(), *limit).await?
|
||||||
HostRequest::UpgradeSubvolume { name } => handle_upgrade_subvolume(name).await?,
|
}
|
||||||
|
HostRequest::QuotaShow { name } => {
|
||||||
|
handle_quota_show(name.as_ref().map(hive_types::Ident::as_str)).await?
|
||||||
|
}
|
||||||
|
HostRequest::UpgradeSubvolume { name } => {
|
||||||
|
handle_upgrade_subvolume(name.as_str()).await?
|
||||||
|
}
|
||||||
HostRequest::SnapshotSubvolume { name, label } => {
|
HostRequest::SnapshotSubvolume { name, label } => {
|
||||||
handle_snapshot_subvolume(name, label).await?
|
handle_snapshot_subvolume(name.as_str(), label).await?
|
||||||
}
|
}
|
||||||
HostRequest::DeleteSnapshot { name, label } => {
|
HostRequest::DeleteSnapshot { name, label } => {
|
||||||
handle_delete_snapshot(name, label).await?
|
handle_delete_snapshot(name.as_str(), label).await?
|
||||||
}
|
}
|
||||||
HostRequest::SendSnapshot {
|
HostRequest::SendSnapshot {
|
||||||
name,
|
name,
|
||||||
label,
|
label,
|
||||||
parent,
|
parent,
|
||||||
dest,
|
dest,
|
||||||
} => handle_send_snapshot(name, label, parent.as_deref(), dest).await?,
|
} => handle_send_snapshot(name.as_str(), label, parent.as_deref(), dest).await?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
.await;
|
.await;
|
||||||
|
|
@ -320,10 +337,8 @@ fn matrix_http_client() -> Result<reqwest::Client> {
|
||||||
|
|
||||||
/// True when `name` has a state dir under the agents root, i.e. it's a
|
/// True when `name` has a state dir under the agents root, i.e. it's a
|
||||||
/// managed agent rather than a bare (operator/human) matrix account.
|
/// managed agent rather than a bare (operator/human) matrix account.
|
||||||
fn agent_exists(name: &str) -> Result<bool> {
|
fn agent_exists(name: &hive_types::Ident) -> Result<bool> {
|
||||||
let name = hive_host_sock::Ident::parse(name)
|
crate::paths::agent_state_dir(name)
|
||||||
.map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?;
|
|
||||||
crate::paths::agent_state_dir(&name)
|
|
||||||
.try_exists()
|
.try_exists()
|
||||||
.with_context(|| format!("check agent state dir for {name}"))
|
.with_context(|| format!("check agent state dir for {name}"))
|
||||||
}
|
}
|
||||||
|
|
@ -338,7 +353,10 @@ async fn require_matrix_present() -> Result<()> {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_matrix_create_user(name: &str, password: Option<&str>) -> Result<HostResponse> {
|
async fn handle_matrix_create_user(
|
||||||
|
name: &hive_types::Ident,
|
||||||
|
password: Option<&str>,
|
||||||
|
) -> Result<HostResponse> {
|
||||||
require_matrix_present().await?;
|
require_matrix_present().await?;
|
||||||
let register_token =
|
let register_token =
|
||||||
crate::matrix::ensure_register_token().context("read matrix register token")?;
|
crate::matrix::ensure_register_token().context("read matrix register token")?;
|
||||||
|
|
@ -353,12 +371,10 @@ async fn handle_matrix_create_user(name: &str, password: Option<&str>) -> Result
|
||||||
"matrix create-user: a password is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via access_token"
|
"matrix create-user: a password is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via access_token"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
crate::matrix::ensure_user_for(&client, name, ®ister_token)
|
crate::matrix::ensure_user_for(&client, name.as_str(), ®ister_token)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("matrix create-user {name}"))?;
|
.with_context(|| format!("matrix create-user {name}"))?;
|
||||||
let agent = hive_host_sock::Ident::parse(name)
|
let path = Coordinator::agent_notes_dir(name).join("matrix-token");
|
||||||
.map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?;
|
|
||||||
let path = Coordinator::agent_notes_dir(&agent).join("matrix-token");
|
|
||||||
out.push(format!("matrix: provisioned agent user '{name}'"));
|
out.push(format!("matrix: provisioned agent user '{name}'"));
|
||||||
out.push(format!("token persisted at: {}", path.display()));
|
out.push(format!("token persisted at: {}", path.display()));
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -368,7 +384,7 @@ async fn handle_matrix_create_user(name: &str, password: Option<&str>) -> Result
|
||||||
};
|
};
|
||||||
let token = crate::matrix::provision_user_token(
|
let token = crate::matrix::provision_user_token(
|
||||||
&client,
|
&client,
|
||||||
name,
|
name.as_str(),
|
||||||
®ister_token,
|
®ister_token,
|
||||||
&effective_password,
|
&effective_password,
|
||||||
)
|
)
|
||||||
|
|
@ -391,7 +407,10 @@ async fn handle_matrix_create_user(name: &str, password: Option<&str>) -> Result
|
||||||
Ok(HostResponse::messages(out))
|
Ok(HostResponse::messages(out))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_forge_create_user(name: &str, password: Option<&str>) -> Result<HostResponse> {
|
async fn handle_forge_create_user(
|
||||||
|
name: &hive_types::Ident,
|
||||||
|
password: Option<&str>,
|
||||||
|
) -> Result<HostResponse> {
|
||||||
if !crate::forge::is_present().await {
|
if !crate::forge::is_present().await {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"hive-forge container not running — wait for hive-c0re to start it before provisioning forge users"
|
"hive-forge container not running — wait for hive-c0re to start it before provisioning forge users"
|
||||||
|
|
@ -406,16 +425,14 @@ async fn handle_forge_create_user(name: &str, password: Option<&str>) -> Result<
|
||||||
"forge create-user: a password is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via API token"
|
"forge create-user: a password is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via API token"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
crate::forge::ensure_user_for(name)
|
crate::forge::ensure_user_for(name.as_str())
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("forge create-user {name}"))?;
|
.with_context(|| format!("forge create-user {name}"))?;
|
||||||
let agent = hive_host_sock::Ident::parse(name)
|
let path = Coordinator::agent_notes_dir(name).join("forge-token");
|
||||||
.map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?;
|
|
||||||
let path = Coordinator::agent_notes_dir(&agent).join("forge-token");
|
|
||||||
out.push(format!("forge: provisioned agent user '{name}'"));
|
out.push(format!("forge: provisioned agent user '{name}'"));
|
||||||
out.push(format!("token persisted at: {}", path.display()));
|
out.push(format!("token persisted at: {}", path.display()));
|
||||||
} else {
|
} else {
|
||||||
let token = crate::forge::provision_user_token(name, password)
|
let token = crate::forge::provision_user_token(name.as_str(), password)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("forge create-user {name}"))?;
|
.with_context(|| format!("forge create-user {name}"))?;
|
||||||
out.push(format!(
|
out.push(format!(
|
||||||
|
|
@ -467,7 +484,7 @@ async fn handle_quota_show(name: Option<&str>) -> Result<HostResponse> {
|
||||||
Some(n) => vec![n.to_owned()],
|
Some(n) => vec![n.to_owned()],
|
||||||
None => Coordinator::kept_state_names()
|
None => Coordinator::kept_state_names()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(hive_host_sock::Ident::into_string)
|
.map(hive_types::Ident::into_string)
|
||||||
.collect(),
|
.collect(),
|
||||||
};
|
};
|
||||||
let mut rows = Vec::with_capacity(agents.len());
|
let mut rows = Vec::with_capacity(agents.len());
|
||||||
|
|
|
||||||
|
|
@ -195,7 +195,7 @@ pub(crate) fn submit_init_config(
|
||||||
parent: Option<&str>,
|
parent: Option<&str>,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
) -> anyhow::Result<i64> {
|
) -> anyhow::Result<i64> {
|
||||||
let agent = hive_host_sock::Ident::parse(name)
|
let agent = hive_types::Ident::parse(name)
|
||||||
.map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?;
|
||||||
let proposed_dir = crate::coordinator::Coordinator::agent_proposed_dir(&agent);
|
let proposed_dir = crate::coordinator::Coordinator::agent_proposed_dir(&agent);
|
||||||
if proposed_dir.join(".git").exists() {
|
if proposed_dir.join(".git").exists() {
|
||||||
|
|
|
||||||
|
|
@ -425,7 +425,7 @@ async fn handle_get_agent_meta(
|
||||||
// the OS level. Validate it before any path is built. The `None` default
|
// the OS level. Validate it before any path is built. The `None` default
|
||||||
// (`target == agent`) is the caller's own authenticated name, already
|
// (`target == agent`) is the caller's own authenticated name, already
|
||||||
// valid — but validating unconditionally is simplest and harmless.
|
// valid — but validating unconditionally is simplest and harmless.
|
||||||
let target_id = match hive_host_sock::Ident::parse(target) {
|
let target_id = match hive_types::Ident::parse(target) {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
Err(reason) => {
|
Err(reason) => {
|
||||||
return hive_agent_sock::Response::Err {
|
return hive_agent_sock::Response::Err {
|
||||||
|
|
@ -461,7 +461,7 @@ async fn handle_get_agent_meta(
|
||||||
/// or the daemon not up yet) yields an empty list. The `MatrixIdentity`
|
/// or the daemon not up yet) yields an empty list. The `MatrixIdentity`
|
||||||
/// serde shape matches the snapshot entries; the snapshot's `live` field is
|
/// serde shape matches the snapshot entries; the snapshot's `live` field is
|
||||||
/// ignored (only live accounts are written).
|
/// ignored (only live accounts are written).
|
||||||
fn read_agent_matrix_identities(agent: &hive_host_sock::Ident) -> Vec<hive_sh4re::MatrixIdentity> {
|
fn read_agent_matrix_identities(agent: &hive_types::Ident) -> Vec<hive_sh4re::MatrixIdentity> {
|
||||||
let path = Coordinator::agent_notes_dir(agent).join("matrix-accounts.json");
|
let path = Coordinator::agent_notes_dir(agent).join("matrix-accounts.json");
|
||||||
std::fs::read_to_string(&path)
|
std::fs::read_to_string(&path)
|
||||||
.ok()
|
.ok()
|
||||||
|
|
@ -751,7 +751,7 @@ fn require_group(agent: &str, group: &str, action: &str) -> Option<Response> {
|
||||||
/// `submit_init_config`, which builds filesystem paths from it, so validate
|
/// `submit_init_config`, which builds filesystem paths from it, so validate
|
||||||
/// before that.
|
/// before that.
|
||||||
fn require_new_child(agent: &str, target: &str, action: &str) -> Option<Response> {
|
fn require_new_child(agent: &str, target: &str, action: &str) -> Option<Response> {
|
||||||
if let Err(reason) = hive_host_sock::Ident::parse(target) {
|
if let Err(reason) = hive_types::Ident::parse(target) {
|
||||||
return Some(Response::Err {
|
return Some(Response::Err {
|
||||||
message: format!("agent `{agent}` cannot {action} `{target}`: {reason}"),
|
message: format!("agent `{agent}` cannot {action} `{target}`: {reason}"),
|
||||||
});
|
});
|
||||||
|
|
@ -1118,7 +1118,7 @@ pub(crate) fn handle_send(
|
||||||
// A name that doesn't parse as an Ident can't be a local agent, so
|
// A name that doesn't parse as an Ident can't be a local agent, so
|
||||||
// it collapses into the same "unknown recipient" error as a valid
|
// it collapses into the same "unknown recipient" error as a valid
|
||||||
// name with no state dir.
|
// name with no state dir.
|
||||||
let exists = hive_host_sock::Ident::parse(&resolved)
|
let exists = hive_types::Ident::parse(&resolved)
|
||||||
.is_ok_and(|id| crate::paths::agent_state_dir(&id).exists());
|
.is_ok_and(|id| crate::paths::agent_state_dir(&id).exists());
|
||||||
if !exists {
|
if !exists {
|
||||||
return Response::Err {
|
return Response::Err {
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ async fn du_bytes(path: &std::path::Path) -> Option<u64> {
|
||||||
/// agent's state-dir contribution was 0; with the writable rootfs nearly empty
|
/// agent's state-dir contribution was 0; with the writable rootfs nearly empty
|
||||||
/// (almost everything is bind-mounted), that surfaced as all agents reporting
|
/// (almost everything is bind-mounted), that surfaced as all agents reporting
|
||||||
/// 0 disk.
|
/// 0 disk.
|
||||||
async fn measure_agent_disk(name: &hive_host_sock::Ident) -> u64 {
|
async fn measure_agent_disk(name: &hive_types::Ident) -> u64 {
|
||||||
let state_dir = Coordinator::agent_notes_dir(name);
|
let state_dir = Coordinator::agent_notes_dir(name);
|
||||||
let rootfs = PathBuf::from(format!("{NIXOS_CONTAINERS_ROOT}/h-{name}"));
|
let rootfs = PathBuf::from(format!("{NIXOS_CONTAINERS_ROOT}/h-{name}"));
|
||||||
let mut total = 0u64;
|
let mut total = 0u64;
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ pub fn spawn(coord: Arc<Coordinator>) {
|
||||||
if lifecycle::is_running(&logical).await {
|
if lifecycle::is_running(&logical).await {
|
||||||
current_running.insert(logical.clone());
|
current_running.insert(logical.clone());
|
||||||
}
|
}
|
||||||
if hive_host_sock::Ident::parse(&logical)
|
if hive_types::Ident::parse(&logical)
|
||||||
.is_ok_and(|id| claude_has_session(&Coordinator::agent_claude_dir(&id)))
|
.is_ok_and(|id| claude_has_session(&Coordinator::agent_claude_dir(&id)))
|
||||||
{
|
{
|
||||||
current_logged_in.insert(logical.clone());
|
current_logged_in.insert(logical.clone());
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,7 @@ fn inline_fallback(req_path: &str, reason: &str, message: &str) -> String {
|
||||||
/// inline-falls-back). `pub` because `socket_server::handle_remind`
|
/// inline-falls-back). `pub` because `socket_server::handle_remind`
|
||||||
/// reuses it for the at-remind-time auto-file path.
|
/// reuses it for the at-remind-time auto-file path.
|
||||||
pub fn write_payload(agent: &str, host_path: &Path, message: &str) -> Result<(), String> {
|
pub fn write_payload(agent: &str, host_path: &Path, message: &str) -> Result<(), String> {
|
||||||
let agent = hive_host_sock::Ident::parse(agent)
|
let agent = hive_types::Ident::parse(agent)
|
||||||
.map_err(|e| format!("invalid agent name {agent:?}: {e}"))?;
|
.map_err(|e| format!("invalid agent name {agent:?}: {e}"))?;
|
||||||
let Some(parent) = host_path.parent() else {
|
let Some(parent) = host_path.parent() else {
|
||||||
return Err("internal: host path has no parent".to_owned());
|
return Err("internal: host path has no parent".to_owned());
|
||||||
|
|
@ -191,7 +191,7 @@ pub fn container_state_prefix(agent: &str) -> String {
|
||||||
/// reason string on rejection. `pub` so `socket_server::handle_remind`
|
/// reason string on rejection. `pub` so `socket_server::handle_remind`
|
||||||
/// can reuse it for the at-remind-time auto-file path.
|
/// can reuse it for the at-remind-time auto-file path.
|
||||||
pub fn resolve_host_path(agent: &str, req_path: &str) -> Result<PathBuf, String> {
|
pub fn resolve_host_path(agent: &str, req_path: &str) -> Result<PathBuf, String> {
|
||||||
let agent = hive_host_sock::Ident::parse(agent)
|
let agent = hive_types::Ident::parse(agent)
|
||||||
.map_err(|e| format!("invalid agent name {agent:?}: {e}"))?;
|
.map_err(|e| format!("invalid agent name {agent:?}: {e}"))?;
|
||||||
let prefix = container_state_prefix(agent.as_str());
|
let prefix = container_state_prefix(agent.as_str());
|
||||||
let Some(rel) = req_path.strip_prefix(&prefix) else {
|
let Some(rel) = req_path.strip_prefix(&prefix) else {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,5 @@ workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
hive-sh4re.workspace = true
|
hive-sh4re.workspace = true
|
||||||
|
hive-types.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
serde_json.workspace = true
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use hive_sh4re::{AgentStatusRow, Approval, jobs};
|
use hive_sh4re::{AgentStatusRow, Approval, jobs};
|
||||||
|
use hive_types::Ident;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
// ── Shared hive layout facts ──────────────────────────────────────────────
|
// ── Shared hive layout facts ──────────────────────────────────────────────
|
||||||
|
|
@ -55,152 +56,6 @@ pub fn container_name(name: &str) -> String {
|
||||||
format!("{AGENT_PREFIX}{name}")
|
format!("{AGENT_PREFIX}{name}")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A validated hive identifier: 1-63 chars of `[a-z0-9-]`.
|
|
||||||
///
|
|
||||||
/// The single ident type for agent names, forge labels, and matrix / github
|
|
||||||
/// account names — every value that becomes a filesystem path segment or an
|
|
||||||
/// nspawn machine-name component. Constructed only through the validating
|
|
||||||
/// [`Ident::parse`], so "this string passed the naming whitelist" is a fact
|
|
||||||
/// the type carries instead of a convention every call site re-checks against
|
|
||||||
/// a raw `String`. The charset is deliberately conservative — lowercase
|
|
||||||
/// ascii, digits, and hyphen only (no underscore, dot, slash, or non-ASCII) —
|
|
||||||
/// and length-capped, tracking `nixos-container` basename rules and keeping
|
|
||||||
/// `../` traversal, unicode homoglyphs, and unbounded path segments out of
|
|
||||||
/// any path built from it. Deserialization runs the same parse, so a value
|
|
||||||
/// arriving over the wire is validated on the way in.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
|
||||||
pub struct Ident(String);
|
|
||||||
|
|
||||||
impl Ident {
|
|
||||||
/// Maximum length in bytes. A cap stops an unbounded operator-supplied
|
|
||||||
/// name from becoming an over-long path segment (a filesystem / `DoS`
|
|
||||||
/// footgun).
|
|
||||||
pub const MAX_LEN: usize = 63;
|
|
||||||
|
|
||||||
/// Parse + validate an identifier.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// Returns `Err(reason)` — a caller-ready message — when `s` is empty,
|
|
||||||
/// longer than [`Ident::MAX_LEN`], or contains any byte outside
|
|
||||||
/// `[a-z0-9-]`.
|
|
||||||
pub fn parse(s: &str) -> Result<Self, &'static str> {
|
|
||||||
if s.is_empty() {
|
|
||||||
return Err("identifier must not be empty");
|
|
||||||
}
|
|
||||||
if s.len() > Self::MAX_LEN {
|
|
||||||
return Err("identifier must be 63 characters or fewer");
|
|
||||||
}
|
|
||||||
if !s
|
|
||||||
.bytes()
|
|
||||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
|
||||||
{
|
|
||||||
return Err("identifier must contain only [a-z0-9-]");
|
|
||||||
}
|
|
||||||
Ok(Self(s.to_owned()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The validated identifier as a string slice.
|
|
||||||
#[must_use]
|
|
||||||
pub fn as_str(&self) -> &str {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Consume into the inner `String`.
|
|
||||||
#[must_use]
|
|
||||||
pub fn into_string(self) -> String {
|
|
||||||
self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for Ident {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
f.write_str(&self.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsRef<str> for Ident {
|
|
||||||
fn as_ref(&self) -> &str {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lets an `Ident` key a `HashMap`/`BTreeMap` be looked up with a `&str`.
|
|
||||||
impl std::borrow::Borrow<str> for Ident {
|
|
||||||
fn borrow(&self) -> &str {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl serde::Serialize for Ident {
|
|
||||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
|
||||||
serializer.serialize_str(&self.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'de> serde::Deserialize<'de> for Ident {
|
|
||||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
|
||||||
use serde::de::Error as _;
|
|
||||||
let s = String::deserialize(deserializer)?;
|
|
||||||
Ident::parse(&s).map_err(D::Error::custom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod ident_tests {
|
|
||||||
use super::Ident;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn accepts_canonical_shapes() {
|
|
||||||
for ok in [
|
|
||||||
"damocles",
|
|
||||||
"hm1nd",
|
|
||||||
"agent-with-dashes",
|
|
||||||
"codeberg",
|
|
||||||
"acct-1",
|
|
||||||
] {
|
|
||||||
assert!(Ident::parse(ok).is_ok(), "should accept {ok:?}");
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
Ident::parse(&"a".repeat(Ident::MAX_LEN)).is_ok(),
|
|
||||||
"63 chars is the boundary"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_bad_input() {
|
|
||||||
let too_long = "a".repeat(Ident::MAX_LEN + 1);
|
|
||||||
for bad in [
|
|
||||||
"",
|
|
||||||
&too_long,
|
|
||||||
"Alice", // uppercase
|
|
||||||
"snake_case", // underscore (tightened out)
|
|
||||||
"alice.bob", // dot
|
|
||||||
"alice/bob", // slash
|
|
||||||
"../etc/passwd", // traversal
|
|
||||||
"damóclès", // non-ASCII
|
|
||||||
"alice\u{2013}b", // en-dash homoglyph
|
|
||||||
] {
|
|
||||||
assert!(Ident::parse(bad).is_err(), "should reject {bad:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn round_trips_and_serde_validates() {
|
|
||||||
let id = Ident::parse("damocles").unwrap();
|
|
||||||
assert_eq!(id.as_str(), "damocles");
|
|
||||||
// Serialize is transparent (just the inner string).
|
|
||||||
let json = serde_json::to_string(&id).unwrap();
|
|
||||||
assert_eq!(json, "\"damocles\"");
|
|
||||||
// Deserialize runs the same parse.
|
|
||||||
let back: Ident = serde_json::from_str(&json).unwrap();
|
|
||||||
assert_eq!(back, id);
|
|
||||||
assert!(
|
|
||||||
serde_json::from_str::<Ident>("\"BAD_NAME\"").is_err(),
|
|
||||||
"deserialize must reject an invalid ident"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Which way to reconcile an agent's config branches
|
/// Which way to reconcile an agent's config branches
|
||||||
/// ([`HostRequest::ReconcileConfigApply`]).
|
/// ([`HostRequest::ReconcileConfigApply`]).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
@ -221,23 +76,23 @@ pub enum HostRequest {
|
||||||
/// Create and start a sub-agent container directly, bypassing the
|
/// Create and start a sub-agent container directly, bypassing the
|
||||||
/// approval queue. Privileged-context only. See
|
/// approval queue. Privileged-context only. See
|
||||||
/// `docs/approvals.md::Approval kinds (wire shapes)`.
|
/// `docs/approvals.md::Approval kinds (wire shapes)`.
|
||||||
Spawn { name: String },
|
Spawn { name: Ident },
|
||||||
/// Submit a spawn request for the operator to approve. See
|
/// Submit a spawn request for the operator to approve. See
|
||||||
/// `docs/approvals.md::Approval kinds (wire shapes)` (`Spawn`).
|
/// `docs/approvals.md::Approval kinds (wire shapes)` (`Spawn`).
|
||||||
RequestSpawn { name: String },
|
RequestSpawn { name: Ident },
|
||||||
/// Stop a managed container (graceful).
|
/// Stop a managed container (graceful).
|
||||||
Kill { name: String },
|
Kill { name: Ident },
|
||||||
/// Tear down a sub-agent container, optionally purging state.
|
/// Tear down a sub-agent container, optionally purging state.
|
||||||
/// See `docs/approvals.md::Destroy semantics`.
|
/// See `docs/approvals.md::Destroy semantics`.
|
||||||
Destroy {
|
Destroy {
|
||||||
name: String,
|
name: Ident,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
purge: bool,
|
purge: bool,
|
||||||
},
|
},
|
||||||
/// Stop and start a managed container without rebuilding config.
|
/// Stop and start a managed container without rebuilding config.
|
||||||
/// For "kick the container" operations that don't touch the flake or
|
/// For "kick the container" operations that don't touch the flake or
|
||||||
/// nspawn flags. Mirrors `lifecycle::restart` (kill + start).
|
/// nspawn flags. Mirrors `lifecycle::restart` (kill + start).
|
||||||
Restart { name: String },
|
Restart { name: Ident },
|
||||||
/// Stop and restart all managed containers in sequence. Convenience
|
/// Stop and restart all managed containers in sequence. Convenience
|
||||||
/// wrapper for `hivectl agents restart-all`; iterates the live
|
/// wrapper for `hivectl agents restart-all`; iterates the live
|
||||||
/// container list and restarts each one.
|
/// container list and restarts each one.
|
||||||
|
|
@ -265,7 +120,7 @@ pub enum HostRequest {
|
||||||
graceful: bool,
|
graceful: bool,
|
||||||
},
|
},
|
||||||
/// Apply pending config to a managed container.
|
/// Apply pending config to a managed container.
|
||||||
Rebuild { name: String },
|
Rebuild { name: Ident },
|
||||||
/// List managed containers.
|
/// List managed containers.
|
||||||
List,
|
List,
|
||||||
/// List managed agents with their full status + technical state
|
/// List managed agents with their full status + technical state
|
||||||
|
|
@ -296,8 +151,8 @@ pub enum HostRequest {
|
||||||
/// Validation rules + bind-mount caveat documented in
|
/// Validation rules + bind-mount caveat documented in
|
||||||
/// `docs/agent-hierarchy.md::Current state`.
|
/// `docs/agent-hierarchy.md::Current state`.
|
||||||
SetParent {
|
SetParent {
|
||||||
child: String,
|
child: Ident,
|
||||||
new_parent: Option<String>,
|
new_parent: Option<Ident>,
|
||||||
},
|
},
|
||||||
/// Stop managed containers hive-wide in one operator action
|
/// Stop managed containers hive-wide in one operator action
|
||||||
/// (`hivectl stop`): agents plus the selected infra containers. `scope`
|
/// (`hivectl stop`): agents plus the selected infra containers. `scope`
|
||||||
|
|
@ -327,7 +182,7 @@ pub enum HostRequest {
|
||||||
/// [`HostResponse::messages`]. `password` is resolved by the client
|
/// [`HostResponse::messages`]. `password` is resolved by the client
|
||||||
/// (inline flag or stdin) and `None` requests a random throwaway.
|
/// (inline flag or stdin) and `None` requests a random throwaway.
|
||||||
MatrixCreateUser {
|
MatrixCreateUser {
|
||||||
name: String,
|
name: Ident,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
},
|
},
|
||||||
|
|
@ -337,11 +192,11 @@ pub enum HostRequest {
|
||||||
/// Promote a matrix user to homeserver admin via the admin API.
|
/// Promote a matrix user to homeserver admin via the admin API.
|
||||||
/// Uses the daemon's system admin token; `server_name` is discovered
|
/// Uses the daemon's system admin token; `server_name` is discovered
|
||||||
/// from the running homeserver.
|
/// from the running homeserver.
|
||||||
MatrixPromoteUser { name: String },
|
MatrixPromoteUser { name: Ident },
|
||||||
/// Reset a matrix user's password via the admin API and persist the
|
/// Reset a matrix user's password via the admin API and persist the
|
||||||
/// new password to the matrix creds dir so a later token mint can
|
/// new password to the matrix creds dir so a later token mint can
|
||||||
/// re-login. Returns the outcome in [`HostResponse::messages`].
|
/// re-login. Returns the outcome in [`HostResponse::messages`].
|
||||||
MatrixResetPassword { name: String },
|
MatrixResetPassword { name: Ident },
|
||||||
/// Invite a matrix user to the hive Space (default) or a specific
|
/// Invite a matrix user to the hive Space (default) or a specific
|
||||||
/// `room`. Uses the daemon's admin token; idempotent
|
/// `room`. Uses the daemon's admin token; idempotent
|
||||||
/// (already-member / already-invited is a no-op).
|
/// (already-member / already-invited is a no-op).
|
||||||
|
|
@ -357,7 +212,7 @@ pub enum HostRequest {
|
||||||
/// in [`HostResponse::messages`]. `password` is resolved client-side
|
/// in [`HostResponse::messages`]. `password` is resolved client-side
|
||||||
/// (inline flag or stdin) and only meaningful for non-agent accounts.
|
/// (inline flag or stdin) and only meaningful for non-agent accounts.
|
||||||
ForgeCreateUser {
|
ForgeCreateUser {
|
||||||
name: String,
|
name: Ident,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
},
|
},
|
||||||
|
|
@ -369,7 +224,7 @@ pub enum HostRequest {
|
||||||
/// — never mutates either side. Backs `hivectl forge reconcile-config`
|
/// — never mutates either side. Backs `hivectl forge reconcile-config`
|
||||||
/// (the diff it always shows first).
|
/// (the diff it always shows first).
|
||||||
ReconcileConfigStatus {
|
ReconcileConfigStatus {
|
||||||
agent: String,
|
agent: Ident,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
},
|
},
|
||||||
|
|
@ -380,7 +235,7 @@ pub enum HostRequest {
|
||||||
/// local needs lifting branch protection — resolve via a config PR).
|
/// local needs lifting branch protection — resolve via a config PR).
|
||||||
/// Backs `hivectl forge reconcile-config --from <forge|local>`.
|
/// Backs `hivectl forge reconcile-config --from <forge|local>`.
|
||||||
ReconcileConfigApply {
|
ReconcileConfigApply {
|
||||||
agent: String,
|
agent: Ident,
|
||||||
direction: ReconcileDirection,
|
direction: ReconcileDirection,
|
||||||
},
|
},
|
||||||
/// Add or update a gateway HTTP-Basic user in the daemon's htpasswd file
|
/// Add or update a gateway HTTP-Basic user in the daemon's htpasswd file
|
||||||
|
|
@ -403,7 +258,7 @@ pub enum HostRequest {
|
||||||
/// set-token`. `token` is resolved + non-empty-validated client-side
|
/// set-token`. `token` is resolved + non-empty-validated client-side
|
||||||
/// (inline flag or stdin); the daemon just persists it. Read live by
|
/// (inline flag or stdin); the daemon just persists it. Read live by
|
||||||
/// the agent's `gh` wrapper / git credential helper — no rebuild needed.
|
/// the agent's `gh` wrapper / git credential helper — no rebuild needed.
|
||||||
SetAgentGithubToken { agent: String, token: String },
|
SetAgentGithubToken { agent: Ident, token: String },
|
||||||
/// Turn on btrfs qgroup accounting on the agent-state filesystem, via
|
/// Turn on btrfs qgroup accounting on the agent-state filesystem, via
|
||||||
/// the privileged helper. Daemon-side equivalent of `hivectl quota
|
/// the privileged helper. Daemon-side equivalent of `hivectl quota
|
||||||
/// enable`. Returns advisory lines in [`HostResponse::messages`].
|
/// enable`. Returns advisory lines in [`HostResponse::messages`].
|
||||||
|
|
@ -414,7 +269,7 @@ pub enum HostRequest {
|
||||||
/// bare success — the client prints the confirmation from the value it
|
/// bare success — the client prints the confirmation from the value it
|
||||||
/// sent.
|
/// sent.
|
||||||
QuotaLimit {
|
QuotaLimit {
|
||||||
name: String,
|
name: Ident,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
limit: Option<u64>,
|
limit: Option<u64>,
|
||||||
},
|
},
|
||||||
|
|
@ -426,27 +281,27 @@ pub enum HostRequest {
|
||||||
/// plain [`HostResponse::error`] so the client can print the enable hint.
|
/// plain [`HostResponse::error`] so the client can print the enable hint.
|
||||||
QuotaShow {
|
QuotaShow {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
name: Option<String>,
|
name: Option<Ident>,
|
||||||
},
|
},
|
||||||
/// Migrate an agent's plain state dir to a btrfs subvolume via the
|
/// Migrate an agent's plain state dir to a btrfs subvolume via the
|
||||||
/// privileged helper (`hivectl subvol upgrade`). The agent MUST already
|
/// privileged helper (`hivectl subvol upgrade`). The agent MUST already
|
||||||
/// be stopped — the client orchestrates stop → this → start. Returns a
|
/// be stopped — the client orchestrates stop → this → start. Returns a
|
||||||
/// bare success; the client prints its own progress lines.
|
/// bare success; the client prints its own progress lines.
|
||||||
UpgradeSubvolume { name: String },
|
UpgradeSubvolume { name: Ident },
|
||||||
/// Create a read-only btrfs snapshot of an agent's state subvolume
|
/// Create a read-only btrfs snapshot of an agent's state subvolume
|
||||||
/// (`hivectl subvol snapshot create`). `label` is validated client-side
|
/// (`hivectl subvol snapshot create`). `label` is validated client-side
|
||||||
/// AND by hive-priv. Returns the snapshot's host path in
|
/// AND by hive-priv. Returns the snapshot's host path in
|
||||||
/// [`HostResponse::messages`].
|
/// [`HostResponse::messages`].
|
||||||
SnapshotSubvolume { name: String, label: String },
|
SnapshotSubvolume { name: Ident, label: String },
|
||||||
/// Delete a snapshot created by `SnapshotSubvolume` (`hivectl subvol
|
/// Delete a snapshot created by `SnapshotSubvolume` (`hivectl subvol
|
||||||
/// snapshot delete`). Bare success; the client prints the confirmation.
|
/// snapshot delete`). Bare success; the client prints the confirmation.
|
||||||
DeleteSnapshot { name: String, label: String },
|
DeleteSnapshot { name: Ident, label: String },
|
||||||
/// Export a snapshot to a local file via `btrfs send` (`hivectl subvol
|
/// Export a snapshot to a local file via `btrfs send` (`hivectl subvol
|
||||||
/// snapshot send`). `dest` is a bare filename (hive-priv rejects paths);
|
/// snapshot send`). `dest` is a bare filename (hive-priv rejects paths);
|
||||||
/// `parent` names an optional parent snapshot for an incremental send.
|
/// `parent` names an optional parent snapshot for an incremental send.
|
||||||
/// Returns the written file's host path in [`HostResponse::messages`].
|
/// Returns the written file's host path in [`HostResponse::messages`].
|
||||||
SendSnapshot {
|
SendSnapshot {
|
||||||
name: String,
|
name: Ident,
|
||||||
label: String,
|
label: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
parent: Option<String>,
|
parent: Option<String>,
|
||||||
|
|
|
||||||
13
hive-types/Cargo.toml
Normal file
13
hive-types/Cargo.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
[package]
|
||||||
|
name = "hive-types"
|
||||||
|
edition.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json.workspace = true
|
||||||
153
hive-types/src/lib.rs
Normal file
153
hive-types/src/lib.rs
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
//! Foundational shared newtypes for the hyperhive workspace.
|
||||||
|
//!
|
||||||
|
//! A zero-dependency (bar `serde`) leaf crate so every wire-type crate
|
||||||
|
//! (`hive-sh4re`, `hive-host-sock`, `hive-agent-sock`) and both binaries
|
||||||
|
//! (`hive-c0re`, `hivectl`) can type their agent-name fields as [`Ident`]
|
||||||
|
//! and get serde-validated parsing at the socket boundary for free — with
|
||||||
|
//! no cross-crate coupling and without growing `hive-sh4re`.
|
||||||
|
|
||||||
|
/// A validated hive identifier: 1-63 chars of `[a-z0-9-]`.
|
||||||
|
///
|
||||||
|
/// The single ident type for agent names, forge labels, and matrix / github
|
||||||
|
/// account names — every value that becomes a filesystem path segment or an
|
||||||
|
/// nspawn machine-name component. Constructed only through the validating
|
||||||
|
/// [`Ident::parse`], so "this string passed the naming whitelist" is a fact
|
||||||
|
/// the type carries instead of a convention every call site re-checks against
|
||||||
|
/// a raw `String`. The charset is deliberately conservative — lowercase
|
||||||
|
/// ascii, digits, and hyphen only (no underscore, dot, slash, or non-ASCII) —
|
||||||
|
/// and length-capped, tracking `nixos-container` basename rules and keeping
|
||||||
|
/// `../` traversal, unicode homoglyphs, and unbounded path segments out of
|
||||||
|
/// any path built from it. Deserialization runs the same parse, so a value
|
||||||
|
/// arriving over the wire is validated on the way in.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
|
pub struct Ident(String);
|
||||||
|
|
||||||
|
impl Ident {
|
||||||
|
/// Maximum length in bytes. A cap stops an unbounded operator-supplied
|
||||||
|
/// name from becoming an over-long path segment (a filesystem / `DoS`
|
||||||
|
/// footgun).
|
||||||
|
pub const MAX_LEN: usize = 63;
|
||||||
|
|
||||||
|
/// Parse + validate an identifier.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns `Err(reason)` — a caller-ready message — when `s` is empty,
|
||||||
|
/// longer than [`Ident::MAX_LEN`], or contains any byte outside
|
||||||
|
/// `[a-z0-9-]`.
|
||||||
|
pub fn parse(s: &str) -> Result<Self, &'static str> {
|
||||||
|
if s.is_empty() {
|
||||||
|
return Err("identifier must not be empty");
|
||||||
|
}
|
||||||
|
if s.len() > Self::MAX_LEN {
|
||||||
|
return Err("identifier must be 63 characters or fewer");
|
||||||
|
}
|
||||||
|
if !s
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||||
|
{
|
||||||
|
return Err("identifier must contain only [a-z0-9-]");
|
||||||
|
}
|
||||||
|
Ok(Self(s.to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The validated identifier as a string slice.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consume into the inner `String`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn into_string(self) -> String {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Ident {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsRef<str> for Ident {
|
||||||
|
fn as_ref(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lets an `Ident` key a `HashMap`/`BTreeMap` be looked up with a `&str`.
|
||||||
|
impl std::borrow::Borrow<str> for Ident {
|
||||||
|
fn borrow(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl serde::Serialize for Ident {
|
||||||
|
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
serializer.serialize_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> serde::Deserialize<'de> for Ident {
|
||||||
|
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
|
use serde::de::Error as _;
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
Ident::parse(&s).map_err(D::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod ident_tests {
|
||||||
|
use super::Ident;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_canonical_shapes() {
|
||||||
|
for ok in [
|
||||||
|
"damocles",
|
||||||
|
"hm1nd",
|
||||||
|
"agent-with-dashes",
|
||||||
|
"codeberg",
|
||||||
|
"acct-1",
|
||||||
|
] {
|
||||||
|
assert!(Ident::parse(ok).is_ok(), "should accept {ok:?}");
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
Ident::parse(&"a".repeat(Ident::MAX_LEN)).is_ok(),
|
||||||
|
"63 chars is the boundary"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_bad_input() {
|
||||||
|
let too_long = "a".repeat(Ident::MAX_LEN + 1);
|
||||||
|
for bad in [
|
||||||
|
"",
|
||||||
|
&too_long,
|
||||||
|
"Alice", // uppercase
|
||||||
|
"snake_case", // underscore (tightened out)
|
||||||
|
"alice.bob", // dot
|
||||||
|
"alice/bob", // slash
|
||||||
|
"../etc/passwd", // traversal
|
||||||
|
"damóclès", // non-ASCII
|
||||||
|
"alice\u{2013}b", // en-dash homoglyph
|
||||||
|
] {
|
||||||
|
assert!(Ident::parse(bad).is_err(), "should reject {bad:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trips_and_serde_validates() {
|
||||||
|
let id = Ident::parse("damocles").unwrap();
|
||||||
|
assert_eq!(id.as_str(), "damocles");
|
||||||
|
// Serialize is transparent (just the inner string).
|
||||||
|
let json = serde_json::to_string(&id).unwrap();
|
||||||
|
assert_eq!(json, "\"damocles\"");
|
||||||
|
// Deserialize runs the same parse.
|
||||||
|
let back: Ident = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(back, id);
|
||||||
|
assert!(
|
||||||
|
serde_json::from_str::<Ident>("\"BAD_NAME\"").is_err(),
|
||||||
|
"deserialize must reject an invalid ident"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ clap_complete.workspace = true
|
||||||
clap-markdown = "0.1"
|
clap-markdown = "0.1"
|
||||||
hive-host-sock.workspace = true
|
hive-host-sock.workspace = true
|
||||||
hive-sh4re.workspace = true
|
hive-sh4re.workspace = true
|
||||||
|
hive-types.workspace = true
|
||||||
indicatif.workspace = true
|
indicatif.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ async fn agents_restart(socket: &Path, name: &str, no_wait: bool) -> Result<()>
|
||||||
let resp = crate::client::request(
|
let resp = crate::client::request(
|
||||||
socket,
|
socket,
|
||||||
hive_host_sock::HostRequest::Restart {
|
hive_host_sock::HostRequest::Restart {
|
||||||
name: name.to_owned(),
|
name: crate::util::parse_ident(name)?,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|
@ -135,18 +135,23 @@ pub(crate) async fn run_agents(socket: &Path, cmd: AgentsCmd) -> Result<()> {
|
||||||
AgentsCmd::Restart { name, no_wait } => agents_restart(socket, &name, no_wait).await,
|
AgentsCmd::Restart { name, no_wait } => agents_restart(socket, &name, no_wait).await,
|
||||||
AgentsCmd::RestartAll { no_wait } => agents_restart_all(socket, no_wait).await,
|
AgentsCmd::RestartAll { no_wait } => agents_restart_all(socket, no_wait).await,
|
||||||
AgentsCmd::Spawn { name } => {
|
AgentsCmd::Spawn { name } => {
|
||||||
|
let name = crate::util::parse_ident(&name)?;
|
||||||
render(crate::client::request(socket, HostRequest::Spawn { name }).await?)
|
render(crate::client::request(socket, HostRequest::Spawn { name }).await?)
|
||||||
}
|
}
|
||||||
AgentsCmd::RequestSpawn { name } => {
|
AgentsCmd::RequestSpawn { name } => {
|
||||||
|
let name = crate::util::parse_ident(&name)?;
|
||||||
render(crate::client::request(socket, HostRequest::RequestSpawn { name }).await?)
|
render(crate::client::request(socket, HostRequest::RequestSpawn { name }).await?)
|
||||||
}
|
}
|
||||||
AgentsCmd::Kill { name } => {
|
AgentsCmd::Kill { name } => {
|
||||||
|
let name = crate::util::parse_ident(&name)?;
|
||||||
render(crate::client::request(socket, HostRequest::Kill { name }).await?)
|
render(crate::client::request(socket, HostRequest::Kill { name }).await?)
|
||||||
}
|
}
|
||||||
AgentsCmd::Destroy { name, purge } => {
|
AgentsCmd::Destroy { name, purge } => {
|
||||||
|
let name = crate::util::parse_ident(&name)?;
|
||||||
render(crate::client::request(socket, HostRequest::Destroy { name, purge }).await?)
|
render(crate::client::request(socket, HostRequest::Destroy { name, purge }).await?)
|
||||||
}
|
}
|
||||||
AgentsCmd::Rebuild { name } => {
|
AgentsCmd::Rebuild { name } => {
|
||||||
|
let name = crate::util::parse_ident(&name)?;
|
||||||
render(crate::client::request(socket, HostRequest::Rebuild { name }).await?)
|
render(crate::client::request(socket, HostRequest::Rebuild { name }).await?)
|
||||||
}
|
}
|
||||||
AgentsCmd::SetParent {
|
AgentsCmd::SetParent {
|
||||||
|
|
@ -154,7 +159,12 @@ pub(crate) async fn run_agents(socket: &Path, cmd: AgentsCmd) -> Result<()> {
|
||||||
parent,
|
parent,
|
||||||
root,
|
root,
|
||||||
} => {
|
} => {
|
||||||
let new_parent = if root { None } else { parent };
|
let child = crate::util::parse_ident(&child)?;
|
||||||
|
let new_parent = if root {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
parent.map(|p| crate::util::parse_ident(&p)).transpose()?
|
||||||
|
};
|
||||||
render(
|
render(
|
||||||
crate::client::request(socket, HostRequest::SetParent { child, new_parent })
|
crate::client::request(socket, HostRequest::SetParent { child, new_parent })
|
||||||
.await?,
|
.await?,
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ pub(crate) async fn forge_create_user(
|
||||||
daemon_request(
|
daemon_request(
|
||||||
socket,
|
socket,
|
||||||
hive_host_sock::HostRequest::ForgeCreateUser {
|
hive_host_sock::HostRequest::ForgeCreateUser {
|
||||||
name: name.to_owned(),
|
name: crate::util::parse_ident(name)?,
|
||||||
password,
|
password,
|
||||||
},
|
},
|
||||||
"forge",
|
"forge",
|
||||||
|
|
@ -45,7 +45,7 @@ pub(crate) async fn forge_reconcile_config(
|
||||||
daemon_request(
|
daemon_request(
|
||||||
socket,
|
socket,
|
||||||
HostRequest::ReconcileConfigStatus {
|
HostRequest::ReconcileConfigStatus {
|
||||||
agent: agent.to_owned(),
|
agent: crate::util::parse_ident(agent)?,
|
||||||
verbose,
|
verbose,
|
||||||
},
|
},
|
||||||
"forge",
|
"forge",
|
||||||
|
|
@ -62,7 +62,7 @@ pub(crate) async fn forge_reconcile_config(
|
||||||
daemon_request(
|
daemon_request(
|
||||||
socket,
|
socket,
|
||||||
HostRequest::ReconcileConfigApply {
|
HostRequest::ReconcileConfigApply {
|
||||||
agent: agent.to_owned(),
|
agent: crate::util::parse_ident(agent)?,
|
||||||
direction,
|
direction,
|
||||||
},
|
},
|
||||||
"forge",
|
"forge",
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ pub(crate) async fn github_set_token(
|
||||||
daemon_request(
|
daemon_request(
|
||||||
socket,
|
socket,
|
||||||
hive_host_sock::HostRequest::SetAgentGithubToken {
|
hive_host_sock::HostRequest::SetAgentGithubToken {
|
||||||
agent: agent.to_owned(),
|
agent: crate::util::parse_ident(agent)?,
|
||||||
token,
|
token,
|
||||||
},
|
},
|
||||||
"github",
|
"github",
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ async fn matrix_create_user(
|
||||||
matrix_request(
|
matrix_request(
|
||||||
socket,
|
socket,
|
||||||
hive_host_sock::HostRequest::MatrixCreateUser {
|
hive_host_sock::HostRequest::MatrixCreateUser {
|
||||||
name: name.to_owned(),
|
name: crate::util::parse_ident(name)?,
|
||||||
password,
|
password,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -74,7 +74,7 @@ async fn matrix_promote_user(socket: &Path, name: &str) -> Result<()> {
|
||||||
matrix_request(
|
matrix_request(
|
||||||
socket,
|
socket,
|
||||||
hive_host_sock::HostRequest::MatrixPromoteUser {
|
hive_host_sock::HostRequest::MatrixPromoteUser {
|
||||||
name: name.to_owned(),
|
name: crate::util::parse_ident(name)?,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|
@ -95,7 +95,7 @@ async fn matrix_reset_password(socket: &Path, name: &str) -> Result<()> {
|
||||||
matrix_request(
|
matrix_request(
|
||||||
socket,
|
socket,
|
||||||
hive_host_sock::HostRequest::MatrixResetPassword {
|
hive_host_sock::HostRequest::MatrixResetPassword {
|
||||||
name: name.to_owned(),
|
name: crate::util::parse_ident(name)?,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ pub(crate) async fn quota_show(socket: &Path, name: Option<&str>) -> Result<()>
|
||||||
let resp = crate::client::request(
|
let resp = crate::client::request(
|
||||||
socket,
|
socket,
|
||||||
hive_host_sock::HostRequest::QuotaShow {
|
hive_host_sock::HostRequest::QuotaShow {
|
||||||
name: name.map(str::to_owned),
|
name: name.map(crate::util::parse_ident).transpose()?,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|
@ -60,7 +60,7 @@ pub(crate) async fn quota_limit(socket: &Path, name: &str, size: &str) -> Result
|
||||||
daemon_request(
|
daemon_request(
|
||||||
socket,
|
socket,
|
||||||
hive_host_sock::HostRequest::QuotaLimit {
|
hive_host_sock::HostRequest::QuotaLimit {
|
||||||
name: name.to_owned(),
|
name: crate::util::parse_ident(name)?,
|
||||||
limit,
|
limit,
|
||||||
},
|
},
|
||||||
"quota",
|
"quota",
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@ async fn subvol_upgrade(socket: &Path, name: &str, yes: bool) -> Result<()> {
|
||||||
let upgrade = daemon_request(
|
let upgrade = daemon_request(
|
||||||
socket,
|
socket,
|
||||||
hive_host_sock::HostRequest::UpgradeSubvolume {
|
hive_host_sock::HostRequest::UpgradeSubvolume {
|
||||||
name: name.to_owned(),
|
name: crate::util::parse_ident(name)?,
|
||||||
},
|
},
|
||||||
"upgrade",
|
"upgrade",
|
||||||
)
|
)
|
||||||
|
|
@ -170,7 +170,7 @@ async fn subvol_snapshot_create(socket: &Path, name: &str, label: String) -> Res
|
||||||
daemon_request(
|
daemon_request(
|
||||||
socket,
|
socket,
|
||||||
hive_host_sock::HostRequest::SnapshotSubvolume {
|
hive_host_sock::HostRequest::SnapshotSubvolume {
|
||||||
name: name.to_owned(),
|
name: crate::util::parse_ident(name)?,
|
||||||
label,
|
label,
|
||||||
},
|
},
|
||||||
"snapshot",
|
"snapshot",
|
||||||
|
|
@ -184,7 +184,7 @@ async fn subvol_snapshot_delete(socket: &Path, name: &str, label: &str) -> Resul
|
||||||
daemon_request(
|
daemon_request(
|
||||||
socket,
|
socket,
|
||||||
hive_host_sock::HostRequest::DeleteSnapshot {
|
hive_host_sock::HostRequest::DeleteSnapshot {
|
||||||
name: name.to_owned(),
|
name: crate::util::parse_ident(name)?,
|
||||||
label: label.to_owned(),
|
label: label.to_owned(),
|
||||||
},
|
},
|
||||||
"snapshot delete",
|
"snapshot delete",
|
||||||
|
|
@ -211,7 +211,7 @@ async fn subvol_snapshot_send(
|
||||||
daemon_request(
|
daemon_request(
|
||||||
socket,
|
socket,
|
||||||
hive_host_sock::HostRequest::SendSnapshot {
|
hive_host_sock::HostRequest::SendSnapshot {
|
||||||
name: name.to_owned(),
|
name: crate::util::parse_ident(name)?,
|
||||||
label: label.to_owned(),
|
label: label.to_owned(),
|
||||||
parent: parent.map(str::to_owned),
|
parent: parent.map(str::to_owned),
|
||||||
dest: dest.to_owned(),
|
dest: dest.to_owned(),
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,16 @@ use std::path::Path;
|
||||||
|
|
||||||
use anyhow::{Context as _, Result, bail};
|
use anyhow::{Context as _, Result, bail};
|
||||||
|
|
||||||
|
/// Parse a CLI-supplied agent/account name into a validated
|
||||||
|
/// [`hive_types::Ident`], mapping the parse error to an `anyhow` error that
|
||||||
|
/// names the offending input. Used at hivectl's `HostRequest` construction
|
||||||
|
/// sites so the wire `Ident` fields are built from validated names (the daemon
|
||||||
|
/// re-validates on deserialize; parsing here gives the operator an immediate,
|
||||||
|
/// local error instead of a round-trip rejection).
|
||||||
|
pub(crate) fn parse_ident(name: &str) -> Result<hive_types::Ident> {
|
||||||
|
hive_types::Ident::parse(name).map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Send a provisioning request to the daemon and print its result lines.
|
/// Send a provisioning request to the daemon and print its result lines.
|
||||||
/// The daemon owns the provisioning logic; hivectl just relays the outcome,
|
/// The daemon owns the provisioning logic; hivectl just relays the outcome,
|
||||||
/// prefixing any error with `label` (e.g. `forge` / `github`).
|
/// prefixing any error with `label` (e.g. `forge` / `github`).
|
||||||
|
|
@ -115,7 +125,7 @@ pub(crate) async fn query_hive_urls(socket: &Path) -> Option<hive_host_sock::Hiv
|
||||||
/// "needs root" error fixes that first-run footgun, where running a
|
/// "needs root" error fixes that first-run footgun, where running a
|
||||||
/// privileged verb without sudo reported as a missing agent.
|
/// privileged verb without sudo reported as a missing agent.
|
||||||
pub(crate) fn agent_exists(name: &str) -> Result<bool> {
|
pub(crate) fn agent_exists(name: &str) -> Result<bool> {
|
||||||
let Ok(name) = hive_host_sock::Ident::parse(name) else {
|
let Ok(name) = hive_types::Ident::parse(name) else {
|
||||||
bail!("invalid agent name {name:?}");
|
bail!("invalid agent name {name:?}");
|
||||||
};
|
};
|
||||||
let root = hive_host_sock::agent_state_dir(&name);
|
let root = hive_host_sock::agent_state_dir(&name);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue