With no nix build on the hive, the boot publish of the forge token for nix has no reader. Removes the ForgeSweep node, the token file, the git credential helper and hive-c0re's forge gitconfig sections.
264 lines
10 KiB
Rust
264 lines
10 KiB
Rust
//! Git shellout helpers for the per-agent proposed/applied repos: run
|
|
//! `git` with the hive-c0re identity, resolve/plant refs and tags, and
|
|
//! fetch proposal commits into the applied repo.
|
|
|
|
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";
|
|
|
|
/// Return the SHA of the root (oldest, no-parent) commit in a repo.
|
|
/// Used to seed the applied repo at the template baseline rather than at
|
|
/// `main`, so `deployed/0` records the template, not the manager's first commit.
|
|
pub(super) async fn git_root_commit(dir: &Path) -> Result<String> {
|
|
let out = git_command()
|
|
.current_dir(dir)
|
|
.args(["rev-list", "--max-parents=0", "HEAD"])
|
|
.output()
|
|
.await
|
|
.with_context(|| format!("git rev-list --max-parents=0 HEAD in {}", dir.display()))?;
|
|
if !out.status.success() {
|
|
anyhow::bail!(
|
|
"git rev-list --max-parents=0 failed: {}",
|
|
String::from_utf8_lossy(&out.stderr).trim()
|
|
);
|
|
}
|
|
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
|
|
}
|
|
|
|
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.
|
|
///
|
|
/// `kill_on_drop(true)`: if the caller's future is dropped before the child
|
|
/// exits — e.g. a `tokio::time::timeout` around startup migration fires — the
|
|
/// git child is killed instead of orphaned (left retrying an unreachable
|
|
/// forge). No-op on normal completion, where the child has already exited.
|
|
#[must_use]
|
|
pub fn git_command() -> Command {
|
|
let exe = std::env::var("HYPERHIVE_GIT").unwrap_or_else(|_| "git".into());
|
|
let mut cmd = Command::new(exe);
|
|
cmd.kill_on_drop(true);
|
|
cmd
|
|
}
|
|
|
|
/// [`git_command`] carrying HTTP credentials **in its environment**.
|
|
///
|
|
/// `auth_header` is an `Authorization:` line (see
|
|
/// `forge::basic_auth_header`) handed to git as `http.extraHeader`
|
|
/// through `GIT_CONFIG_*`, which git reads exactly like a config file. The
|
|
/// alternative — userinfo in the remote URL — puts the secret in `argv`, and
|
|
/// `/proc/<pid>/cmdline` is world-readable while `/proc/<pid>/environ` is
|
|
/// owner-only. Same credential, materially smaller audience.
|
|
///
|
|
/// `GIT_CONFIG_COUNT` needs git >= 2.31; `HYPERHIVE_GIT` points at a pinned
|
|
/// nixpkgs git well past that.
|
|
#[must_use]
|
|
pub fn git_command_authed(auth_header: &str) -> Command {
|
|
let mut cmd = git_command();
|
|
cmd.env("GIT_CONFIG_COUNT", "1")
|
|
.env("GIT_CONFIG_KEY_0", "http.extraHeader")
|
|
.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()
|
|
),
|
|
}
|
|
}
|