Watch
0
0
Fork
You've already forked hyperhive
0

hive-c0re: validate forge env at startup; char-safe short revs

HIVE_FORGE_URL (set + parseable) and HIVE_ASSETS_DIR are checked once in
main before workers start. Short revs in container_view and pr_status
take 12 chars instead of byte-slicing.
This commit is contained in:
atlas 2026-10-09 10:10:24 +02:00 • committed by mara
commit 6bb13f964d
4 changed files with 56 additions and 6 deletions

View file

@ -187,7 +187,7 @@ pub async fn build_all(hive: &crate::coordinator::HiveEnv) -> anyhow::Result<Vec
.map(std::string::String::as_str);
let needs_update =
crate::auto_update::agent_config_pending(logical.as_str(), deployed_full).await;
let deployed_sha = deployed_full.map(|s| s[..s.len().min(12)].to_owned());
let deployed_sha = deployed_full.map(short_rev);
// One `systemctl` call for both facts: `unit_state` is the same
// shell-out `is_running` makes, minus `--quiet`. Asking twice would
// double the per-agent subprocess count on every SSE scan.
@ -449,9 +449,28 @@ fn parse_locked_revs(raw: &str) -> HashMap<String, String> {
out
}
/// First 12 characters of a rev. Counts chars, not bytes: the value comes
/// from `flake.lock` JSON, and a byte slice at 12 can split a multi-byte char.
fn short_rev(rev: &str) -> String {
rev.chars().take(12).collect()
}
#[cfg(test)]
mod tests {
use super::parse_locked_revs;
use super::{parse_locked_revs, short_rev};
#[test]
fn short_rev_truncates_and_keeps_short() {
assert_eq!(short_rev("0123456789abcdef"), "0123456789ab");
assert_eq!(short_rev("abc123"), "abc123");
}
#[test]
fn short_rev_survives_multibyte_at_boundary() {
// 'é' is 2 bytes and straddles byte index 12.
let rev = "0123456789aébcdef";
assert_eq!(short_rev(rev), "0123456789aé");
}
/// Shape of a real `meta/flake.lock`: `root` names a node whose
/// `inputs` map alias → node name, and each node carries `locked.rev`.

View file

@ -37,7 +37,8 @@ const FORGE_CONTAINER: &str = "hive-forge";
///
/// # Panics
///
/// When `HIVE_FORGE_URL` is unset. That is deliberate: this daemon only
/// When `HIVE_FORGE_URL` is unset (`validate_env` rejects that at startup, so
/// the daemon never reaches this). That is deliberate: this daemon only
/// runs under the NixOS module, which always sets it, so an unset var
/// means the deployment is broken. There is no loopback fallback,
/// because a guess is wrong in exactly the cases that matter — the
@ -55,6 +56,22 @@ pub(crate) fn forge_http_base() -> &'static str {
})
}
/// Check the env the forge integration cannot run without: `HIVE_FORGE_URL`
/// set and a valid URL, `HIVE_ASSETS_DIR` set. Called once from `main`
/// before any worker is spawned, so a missing or malformed value fails the
/// unit start naming the variable; the lazy accessors then never hit their
/// `expect`s.
pub(crate) fn validate_env() -> Result<()> {
let url = std::env::var("HIVE_FORGE_URL").context(
"HIVE_FORGE_URL is unset (the hyperhive NixOS module sets it on the hive-c0re unit)",
)?;
Url::parse(&url).with_context(|| format!("HIVE_FORGE_URL is not a valid URL: {url:?}"))?;
std::env::var("HIVE_ASSETS_DIR").context(
"HIVE_ASSETS_DIR is unset (the hyperhive NixOS module sets it on the hive-c0re unit)",
)?;
Ok(())
}
/// Git remote for `repo` (e.g. `"core/meta"`) — **credential-free**.
///
/// The token does not go here. A URL is a process argument, and `argv` is

View file

@ -219,6 +219,9 @@ async fn main() -> Result<()> {
// succeeded.
lifecycle::network_isolation_from_env()
.context("network isolation settings are required at startup")?;
// The forge integration is mandatory (the NixOS module sets both
// vars unconditionally), so check them before any worker starts.
forge::validate_env()?;
cmd_serve(sc.env, sc.model_prices, sc.build_slots, db, &cli.socket).await
}
}

View file

@ -263,9 +263,15 @@ fn status_mark(status: &str) -> &'static str {
}
}
/// First 12 characters of a sha. Counts chars, not bytes, so a multi-byte
/// char at the cut cannot panic.
fn short_sha(sha: &str) -> String {
sha.chars().take(12).collect()
}
/// Render the CI section (overall verdict + per-context rows).
fn print_ci(sha: &str, state: &str, statuses: &[Value]) {
let short = &sha[..sha.len().min(12)];
let short = short_sha(sha);
if statuses.is_empty() {
println!("{short}: no CI statuses reported (CI may not have started)");
return;
@ -462,12 +468,17 @@ mod tests {
#[test]
fn short_sha_truncates() {
let sha = "0123456789abcdef0123456789abcdef";
assert_eq!(&sha[..sha.len().min(12)], "0123456789ab");
assert_eq!(short_sha(sha), "0123456789ab");
}
#[test]
fn short_sha_handles_already_short() {
assert_eq!(&"abc123"[.."abc123".len().min(12)], "abc123");
assert_eq!(short_sha("abc123"), "abc123");
}
#[test]
fn short_sha_survives_multibyte_at_boundary() {
assert_eq!(short_sha("0123456789aébcdef"), "0123456789aé");
}
#[test]