Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95898338fc | ||
|
|
c45d679a32 | ||
|
|
f0ddbe49d0 |
3 changed files with 189 additions and 8 deletions
|
|
@ -18,8 +18,8 @@ pub use pr_merge::{
|
|||
pub use reconcile::{reconcile_config_apply, reconcile_config_status};
|
||||
pub use repos::{
|
||||
clone_config_into_proposed, create_agent_repo, ensure_config_repo, ensure_knowledge_repo,
|
||||
ensure_meta_remote, ensure_repo, ensure_shared_docs_repo, fetch_config_main_into_applied,
|
||||
meta_read_access, push_config, push_meta, shared_docs_access,
|
||||
ensure_meta_remote, ensure_repo, ensure_shared_docs_repo, fast_forward_applied_main,
|
||||
fetch_config_main_into_applied, meta_read_access, push_config, push_meta, shared_docs_access,
|
||||
};
|
||||
pub use users::{core_token, ensure_user_for, provision_user_token};
|
||||
|
||||
|
|
|
|||
|
|
@ -593,6 +593,66 @@ pub async fn fetch_config_main_into_applied(name: &str) -> bool {
|
|||
true
|
||||
}
|
||||
|
||||
/// Fast-forward `applied/<name>/main` to the config repo's current live
|
||||
/// main. Unlike [`fetch_config_main_into_applied`] (reseed-only — bails
|
||||
/// if `applied` already has a `.git`), this is for an `applied` repo that
|
||||
/// already exists and just needs to catch up: force-sets the local ref to
|
||||
/// match live main rather than requiring a fast-forward or any review
|
||||
/// gate — there is no PR to review on this path, so nothing to check
|
||||
/// ancestry against. Contrast the approval-deploy flow's CAS'd,
|
||||
/// ancestry-gated advance for a reviewed PR merge, which this is not.
|
||||
///
|
||||
/// Best-effort, same shape as [`fetch_config_main_into_applied`]: returns
|
||||
/// `false` (not an error) when the forge is absent, the core token isn't
|
||||
/// minted, `applied` has no `.git` yet (nothing to fast-forward — reseed
|
||||
/// instead), or any git step fails.
|
||||
pub async fn fast_forward_applied_main(name: &str) -> bool {
|
||||
if !is_present().await {
|
||||
return false;
|
||||
}
|
||||
let Some(token) = core_token() else {
|
||||
return false;
|
||||
};
|
||||
let applied = crate::paths::applied_dir(name);
|
||||
if !applied.join(".git").exists() {
|
||||
return false;
|
||||
}
|
||||
let url = forge_git_url(&format!("{CONFIG_ORG}/{name}"));
|
||||
let auth = core_auth_header(&token);
|
||||
let out = crate::lifecycle::git_command_authed(&auth)
|
||||
.current_dir(&applied)
|
||||
.args(["fetch", "--no-tags", &url, "refs/heads/main"])
|
||||
.output()
|
||||
.await;
|
||||
match out {
|
||||
Ok(o) if o.status.success() => {}
|
||||
Ok(o) => {
|
||||
tracing::warn!(
|
||||
%name,
|
||||
stderr = %String::from_utf8_lossy(&o.stderr).trim(),
|
||||
"forge: applied fast-forward fetch failed"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(%name, error = ?e, "forge: applied fast-forward fetch failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Err(e) =
|
||||
crate::lifecycle::git_update_ref(&applied, "refs/heads/main", "FETCH_HEAD").await
|
||||
{
|
||||
tracing::warn!(%name, error = ?e, "forge: applied fast-forward update-ref failed");
|
||||
return false;
|
||||
}
|
||||
if let Err(e) = crate::lifecycle::git_read_tree_reset(&applied, "refs/heads/main").await {
|
||||
tracing::warn!(%name, error = ?e, "forge: applied fast-forward read-tree failed");
|
||||
return false;
|
||||
}
|
||||
tracing::info!(%name, "forge: fast-forwarded applied/main to agent-configs main");
|
||||
true
|
||||
}
|
||||
|
||||
/// Seed an agent's `proposed` repo from `agent-configs/<name>` on the forge.
|
||||
///
|
||||
/// When the swarm creates an agent it writes that agent's config to the forge
|
||||
|
|
|
|||
|
|
@ -545,9 +545,42 @@ async fn run_meta_lock(
|
|||
// Lock file changed — meta-inputs panel re-renders.
|
||||
crate::dashboard::emit_meta_inputs_snapshot(coord);
|
||||
let cascade = match fanout {
|
||||
Some(list) => list,
|
||||
Some(list) => validate_agent_names(list),
|
||||
None => meta_update_cascade_agents(inputs).await,
|
||||
};
|
||||
// Pull each cascade agent's own input too — an agent's config-repo main
|
||||
// is trusted, so a meta-input bump is a reasonable place to also catch
|
||||
// it up. Without this, a meta-input bump rebuilds every affected agent
|
||||
// against whatever `applied/<name>` already happened to be locked to —
|
||||
// normally current, but silently stale forever if a past deploy failed
|
||||
// and nothing since retried it. Relocks against each agent's *declared*
|
||||
// input (the forge URL, not the local `applied/<name>` mirror
|
||||
// `prepare_deploy` uses for a reviewed deploy) — this path has no PR to
|
||||
// review, so there's nothing to gate. One combined call, not per-agent:
|
||||
// simpler, at the cost of one unreachable/broken agent repo failing the
|
||||
// whole cascade relock rather than just that agent.
|
||||
if !cascade.is_empty() {
|
||||
let agent_inputs: Vec<String> =
|
||||
cascade.iter().map(|name| format!("agent-{name}")).collect();
|
||||
crate::meta::lock_update(&agent_inputs).await?;
|
||||
// The relock above is a one-shot effect on *this* rebuild only —
|
||||
// `applied/<name>` itself hasn't moved, so the next `relock = true`
|
||||
// rebuild trigger (the boot sweep, most notably) re-locks against
|
||||
// `applied/<name>` and reverts straight back to whatever it was
|
||||
// stuck on. Fast-forward each cascade agent's own applied mirror
|
||||
// too so a later rebuild through a different trigger doesn't undo
|
||||
// this one. Best-effort per agent (unlike the relock above): one
|
||||
// agent's forge repo being briefly unreachable here shouldn't be
|
||||
// fatal to the cascade that already relocked its input above.
|
||||
for name in &cascade {
|
||||
if !crate::forge::fast_forward_applied_main(name).await {
|
||||
tracing::warn!(
|
||||
%name,
|
||||
"meta-update cascade: applied/main fast-forward skipped or failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Grow one rebuild subgraph per affected agent into *this* meta-update
|
||||
// DAG (rooted on this `MetaLock`, so they build against the post-bump
|
||||
// lock), rather than fanning out child DAGs. `relock = false` — the
|
||||
|
|
@ -858,15 +891,20 @@ async fn run_deploy_tail(
|
|||
/// `inputs` or any input under `hyperhive` → every container;
|
||||
/// otherwise just the agents named by `agent-<name>` inputs.
|
||||
/// Topology-sorted so parents rebuild before their children.
|
||||
///
|
||||
/// `inputs` is the caller-supplied flake-input-name list
|
||||
/// (`RequestUpdateMetaInputs`'s `inputs` field, operator-approved but not
|
||||
/// otherwise validated) — the `agent-<name>` branch parses agent names
|
||||
/// straight out of it, so each is validated through [`hive_types::Ident`]
|
||||
/// before it ever reaches a filesystem path or a forge URL built from a
|
||||
/// cascade agent's name. The `touched_hyperhive` branch's names need no
|
||||
/// such filter: they come from `lifecycle::list()`, already real
|
||||
/// container names by construction, not parsed out of caller input.
|
||||
pub async fn meta_update_cascade_agents(inputs: &[String]) -> Vec<String> {
|
||||
let touched_hyperhive = inputs
|
||||
.iter()
|
||||
.any(|i| i == "hyperhive" || i.starts_with("hyperhive/"));
|
||||
let touched_agents: Vec<String> = inputs
|
||||
.iter()
|
||||
.filter_map(|i| i.strip_prefix("agent-"))
|
||||
.map(|rest| rest.split('/').next().unwrap_or(rest).to_owned())
|
||||
.collect();
|
||||
let touched_agents = parse_agent_input_names(inputs);
|
||||
let mut names = if touched_hyperhive || inputs.is_empty() {
|
||||
crate::lifecycle::list()
|
||||
.await
|
||||
|
|
@ -884,3 +922,86 @@ pub async fn meta_update_cascade_agents(inputs: &[String]) -> Vec<String> {
|
|||
crate::auto_update::topology_sort(&mut names, &topo);
|
||||
names
|
||||
}
|
||||
|
||||
/// Parse `agent-<name>` input strings into validated agent names. Pure and
|
||||
/// synchronous (no `lifecycle::list()` call) so the validation — the part
|
||||
/// that actually matters for safety, since these names end up in
|
||||
/// filesystem paths and forge URLs — is unit-testable without touching
|
||||
/// live container state. Delegates the actual filtering to
|
||||
/// [`validate_agent_names`] after stripping the `agent-` prefix.
|
||||
fn parse_agent_input_names(inputs: &[String]) -> Vec<String> {
|
||||
let candidates = inputs
|
||||
.iter()
|
||||
.filter_map(|i| i.strip_prefix("agent-"))
|
||||
.map(|rest| rest.split('/').next().unwrap_or(rest).to_owned());
|
||||
validate_agent_names(candidates)
|
||||
}
|
||||
|
||||
/// Drop any name that isn't a well-formed [`hive_types::Ident`] — the same
|
||||
/// validating parser every other agent-name wire field in this codebase
|
||||
/// goes through, so a name that reaches
|
||||
/// [`fast_forward_applied_main`](crate::forge::fast_forward_applied_main) or
|
||||
/// a `git+http://.../agent-configs/<name>.git` URL built off it has already
|
||||
/// passed the same bar as one that arrived over a socket. Not just a
|
||||
/// helper for [`parse_agent_input_names`]: `run_meta_lock`'s `fanout`
|
||||
/// parameter carries pre-computed agent names too, and while today's only
|
||||
/// caller of that branch (the boot sweep, `sweep = true`) returns before
|
||||
/// ever reaching the filesystem/git code below, nothing in the type
|
||||
/// enforces that pairing — so this same filter also guards the
|
||||
/// `Some(list)` arm, closing the gap for good rather than relying on it
|
||||
/// staying incidental.
|
||||
fn validate_agent_names(names: impl IntoIterator<Item = String>) -> Vec<String> {
|
||||
names
|
||||
.into_iter()
|
||||
.filter(|name| {
|
||||
let valid = hive_types::Ident::parse(name).is_ok();
|
||||
if !valid {
|
||||
tracing::warn!(%name, "meta-update cascade: dropping malformed agent name");
|
||||
}
|
||||
valid
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cascade_agent_tests {
|
||||
use super::{parse_agent_input_names, validate_agent_names};
|
||||
|
||||
#[test]
|
||||
fn well_formed_agent_inputs_pass_through() {
|
||||
let inputs = vec!["agent-iris".to_owned(), "agent-atlas".to_owned()];
|
||||
assert_eq!(parse_agent_input_names(&inputs), vec!["iris", "atlas"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_traversal_in_an_agent_input_is_dropped() {
|
||||
let inputs = vec!["agent-../../etc".to_owned(), "agent-iris".to_owned()];
|
||||
assert_eq!(parse_agent_input_names(&inputs), vec!["iris"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_agent_inputs_are_ignored_not_misparsed() {
|
||||
let inputs = vec!["hyperhive".to_owned(), "nixpkgs".to_owned()];
|
||||
assert!(parse_agent_input_names(&inputs).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_or_uppercase_names_are_dropped() {
|
||||
let too_long = format!("agent-{}", "a".repeat(64));
|
||||
let inputs = vec![too_long, "agent-Iris".to_owned(), "agent-iris".to_owned()];
|
||||
assert_eq!(parse_agent_input_names(&inputs), vec!["iris"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fanout_supplied_names_are_validated_too() {
|
||||
// `run_meta_lock`'s `Some(list)` arm feeds `fanout` straight into
|
||||
// this filter — pin that a bogus name in that pre-computed list
|
||||
// gets dropped exactly like a parsed `agent-<name>` input would.
|
||||
let names = vec![
|
||||
"iris".to_owned(),
|
||||
"../../etc".to_owned(),
|
||||
"atlas".to_owned(),
|
||||
];
|
||||
assert_eq!(validate_agent_names(names), vec!["iris", "atlas"]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue