drop the applied repo: the hive keeps no copy of an agent's config, only the deploy markers. Refs #5913
This commit is contained in:
parent
deeb5ecd86
commit
862495530c
1 changed files with 190 additions and 1634 deletions
|
|
@ -1,92 +1,8 @@
|
|||
//! Git shellout helpers for the per-agent applied repo: run
|
||||
//! `git` with the hive-c0re identity, resolve/plant refs and tags, and
|
||||
//! fetch proposal commits into the applied repo.
|
||||
//! Spawning `git` from hive-c0re: the binary lookup and the credential
|
||||
//! handoff.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::{Output, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt};
|
||||
use tokio::process::Command;
|
||||
|
||||
/// Deadline for a child that talks to a remote (forge fetch/push): a healthy
|
||||
/// one takes seconds, so ten minutes only ever fires on a hang.
|
||||
pub const FETCH_DEADLINE: Duration = Duration::from_mins(10);
|
||||
|
||||
/// Deadline for a git child that touches only the local repo.
|
||||
pub const GIT_LOCAL_DEADLINE: Duration = Duration::from_mins(2);
|
||||
|
||||
/// Run `cmd` to completion and capture its output, failing once `deadline`
|
||||
/// passes. `what` names the command in the error. The child leads its own
|
||||
/// process group and the whole group is `SIGKILL`ed on timeout, so a
|
||||
/// grandchild (a `git` or credential helper it spawned) dies with it instead
|
||||
/// of outliving the lock.
|
||||
pub async fn run_with_deadline(
|
||||
cmd: &mut Command,
|
||||
deadline: Duration,
|
||||
what: &str,
|
||||
) -> Result<Output> {
|
||||
// `kill_on_drop` covers the caller dropping this future; `process_group(0)`
|
||||
// makes the child's pid the group id the timeout path kills.
|
||||
let mut child = cmd
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.process_group(0)
|
||||
.spawn()
|
||||
.with_context(|| what.to_owned())?;
|
||||
// Read before waiting: a reaped child has no id.
|
||||
let pgid = child.id().and_then(|pid| i32::try_from(pid).ok());
|
||||
let (stdout, stderr) = (child.stdout.take(), child.stderr.take());
|
||||
let io = async { tokio::join!(child.wait(), drain(stdout), drain(stderr)) };
|
||||
if let Ok((status, stdout, stderr)) = tokio::time::timeout(deadline, io).await {
|
||||
return Ok(Output {
|
||||
status: status.with_context(|| what.to_owned())?,
|
||||
stdout: stdout.with_context(|| what.to_owned())?,
|
||||
stderr: stderr.with_context(|| what.to_owned())?,
|
||||
});
|
||||
}
|
||||
if let Some(pgid) = pgid {
|
||||
// SAFETY: plain `kill(2)`; `-pgid` targets the child's own process
|
||||
// group, which `process_group(0)` made it lead.
|
||||
unsafe {
|
||||
libc::kill(-pgid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
child.wait().await.with_context(|| what.to_owned())?;
|
||||
bail!("{what} timed out after {}s", deadline.as_secs_f64())
|
||||
}
|
||||
|
||||
/// Everything `pipe` yields until EOF.
|
||||
async fn drain<R: AsyncRead + Unpin>(pipe: Option<R>) -> std::io::Result<Vec<u8>> {
|
||||
let mut buf = Vec::new();
|
||||
if let Some(mut p) = pipe {
|
||||
p.read_to_end(&mut buf).await?;
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
const GIT_NAME: &str = "c0re";
|
||||
const GIT_EMAIL: &str = "c0re@hyperhive.local";
|
||||
|
||||
pub(super) async fn git_commit(dir: &Path, message: &str) -> Result<()> {
|
||||
git(
|
||||
dir,
|
||||
&[
|
||||
"-c",
|
||||
&format!("user.name={GIT_NAME}"),
|
||||
"-c",
|
||||
&format!("user.email={GIT_EMAIL}"),
|
||||
"commit",
|
||||
"-m",
|
||||
message,
|
||||
],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Spawn `git` honoring the `HYPERHIVE_GIT` env var (absolute path baked in
|
||||
/// by the NixOS module), falling back to bare `git` (PATH lookup) otherwise.
|
||||
///
|
||||
|
|
@ -121,125 +37,3 @@ pub fn git_command_authed(auth_header: &str) -> Command {
|
|||
.env("GIT_CONFIG_VALUE_0", auth_header);
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Run `cmd` as `git <args>` in `dir` within `deadline`, erroring on a
|
||||
/// non-zero exit.
|
||||
async fn run(mut cmd: Command, dir: &Path, args: &[&str], deadline: Duration) -> Result<()> {
|
||||
let what = format!("git {} in {}", args.join(" "), dir.display());
|
||||
let out = run_with_deadline(cmd.current_dir(dir).args(args), deadline, &what).await?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"git {} failed ({}): {}",
|
||||
args.join(" "),
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn git(dir: &Path, args: &[&str]) -> Result<()> {
|
||||
run(git_command(), dir, args, GIT_LOCAL_DEADLINE).await
|
||||
}
|
||||
|
||||
/// [`git`] against an authenticated remote — for the paths that talk to the
|
||||
/// forge. The credential rides the environment, so `args` (and therefore the
|
||||
/// error above) stay free of it.
|
||||
pub async fn git_authed(dir: &Path, args: &[&str], auth_header: &str) -> Result<()> {
|
||||
run(git_command_authed(auth_header), dir, args, FETCH_DEADLINE).await
|
||||
}
|
||||
|
||||
/// Resolve `refname` (a tag, branch, or sha) in `dir` to its full sha.
|
||||
pub async fn git_rev_parse(dir: &Path, refname: &str) -> Result<String> {
|
||||
let out = run_with_deadline(
|
||||
git_command().current_dir(dir).args(["rev-parse", refname]),
|
||||
GIT_LOCAL_DEADLINE,
|
||||
&format!("git rev-parse {refname} in {}", dir.display()),
|
||||
)
|
||||
.await?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"git rev-parse {refname} failed ({}): {}",
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
|
||||
}
|
||||
|
||||
/// Plant a lightweight tag at `target`. Errors if the tag already
|
||||
/// exists — we want loud failures on id reuse, not silent
|
||||
/// overwrites.
|
||||
pub async fn git_tag(dir: &Path, name: &str, target: &str) -> Result<()> {
|
||||
git(dir, &["tag", name, target]).await
|
||||
}
|
||||
|
||||
/// Replace working tree + index with the tree at `target` without
|
||||
/// moving HEAD. `applied/main` stays pointing at the last known-good
|
||||
/// `deployed/*` while we let `nixos-container update` evaluate the
|
||||
/// candidate. On build failure callers reset back to HEAD; on
|
||||
/// success they fast-forward main to `target`.
|
||||
pub async fn git_read_tree_reset(dir: &Path, target: &str) -> Result<()> {
|
||||
git(dir, &["read-tree", "--reset", "-u", target]).await
|
||||
}
|
||||
|
||||
/// Hard-set a ref to `target` with `update-ref`, so it works regardless of
|
||||
/// where HEAD currently sits. Test fixtures only: production moves refs with
|
||||
/// [`git_update_ref_cas`].
|
||||
#[cfg(test)]
|
||||
pub async fn git_update_ref(dir: &Path, refname: &str, target: &str) -> Result<()> {
|
||||
git(dir, &["update-ref", refname, target]).await
|
||||
}
|
||||
|
||||
/// Compare-and-swap a ref: move `refname` to `new` only if it currently points
|
||||
/// at `old`. `git update-ref <ref> <new> <old>` refuses — leaving the ref
|
||||
/// untouched — when the current value differs. That refusal is the whole
|
||||
/// difference between "advance this branch" and "overwrite whatever is there".
|
||||
///
|
||||
/// Prefer this over a plain `update-ref` whenever the caller already knows the
|
||||
/// value it believes the ref holds: a plain `update-ref` that raced another
|
||||
/// writer discards the other writer's commits without a word.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the ref does not currently point at `old` (the CAS lost)
|
||||
/// or if the `git` invocation itself fails.
|
||||
pub async fn git_update_ref_cas(dir: &Path, refname: &str, new: &str, old: &str) -> Result<()> {
|
||||
git(dir, &["update-ref", refname, new, old]).await
|
||||
}
|
||||
|
||||
/// True when `ancestor` is reachable from `descendant` — i.e. moving a branch
|
||||
/// from `ancestor` to `descendant` is a genuine fast-forward that discards
|
||||
/// nothing.
|
||||
///
|
||||
/// Not the same question as "did this branch land upstream": a squash-merge
|
||||
/// rewrites the commit, so `--is-ancestor` correctly answers `false` for a
|
||||
/// branch whose *contents* were merged. This helper compares two commits with
|
||||
/// real shared ancestry inside one repo, which is precisely what it decides.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if `git` fails to run or either revision can't be resolved.
|
||||
/// A clean "no, not an ancestor" is `Ok(false)`, not an error.
|
||||
pub async fn git_is_ancestor(dir: &Path, ancestor: &str, descendant: &str) -> Result<bool> {
|
||||
let out = git_command()
|
||||
.current_dir(dir)
|
||||
.args(["merge-base", "--is-ancestor", ancestor, descendant])
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"git merge-base --is-ancestor {ancestor} {descendant} in {}",
|
||||
dir.display()
|
||||
)
|
||||
})?;
|
||||
match out.status.code() {
|
||||
Some(0) => Ok(true),
|
||||
Some(1) => Ok(false),
|
||||
_ => bail!(
|
||||
"git merge-base --is-ancestor {ancestor} {descendant} failed ({}): {}",
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue