refactor(hive-c0re): split lifecycle into submodules
mod.rs keeps the container verbs + priv_run plumbing; git helpers, repo/dir setup, and host drop-in config move to their own files
This commit is contained in:
parent
9e7af3b6bf
commit
3ee87d394c
5 changed files with 998 additions and 947 deletions
207
hive-c0re/src/lifecycle/git.rs
Normal file
207
hive-c0re/src/lifecycle/git.rs
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
//! 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 anyhow::{Context, Result, bail};
|
||||
use tokio::process::Command;
|
||||
|
||||
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 the first `ApplyCommit` diff shows the manager's real changes.
|
||||
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.
|
||||
#[must_use]
|
||||
pub fn git_command() -> Command {
|
||||
let exe = std::env::var("HYPERHIVE_GIT").unwrap_or_else(|_| "git".into());
|
||||
Command::new(exe)
|
||||
}
|
||||
|
||||
pub async fn git(dir: &Path, args: &[&str]) -> Result<()> {
|
||||
let out = git_command()
|
||||
.current_dir(dir)
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("git {} in {}", args.join(" "), dir.display()))?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"git {} failed ({}): {}",
|
||||
args.join(" "),
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch the commit `sha` from the `src` git repo into `dst` and pin
|
||||
/// it as `refs/tags/<tag>`. Used at `request_apply_commit` time so
|
||||
/// hive-c0re captures an immutable handle on the manager's commit;
|
||||
/// subsequent amendments / force-pushes in `src` no longer affect
|
||||
/// what gets built. Returns the resolved full sha.
|
||||
///
|
||||
/// `sha` must be a commit sha (short or full) — the caller
|
||||
/// (`submit_apply_commit`) shape-checks it first. We resolve it
|
||||
/// LOCALLY against `src` rather than asking the remote to resolve
|
||||
/// it: `git fetch <remote> <sha>:<dst>` treats the left side as a
|
||||
/// remote *ref name*, and a bare sha is not one ("couldn't find
|
||||
/// remote ref ..."). Fetching by sha would need a full 40-hex sha
|
||||
/// plus `uploadpack.allow*SHA1InWant` on the remote, which the
|
||||
/// proposed repos don't set. hive-c0re has direct read access to
|
||||
/// `src`, so a local `rev-parse` + a branch-glob fetch sidesteps
|
||||
/// the whole sha-want negotiation.
|
||||
pub async fn git_fetch_to_tag(dst: &Path, src: &Path, sha: &str, tag: &str) -> Result<String> {
|
||||
let src_str = src.display().to_string();
|
||||
// Resolve the (short-or-full) sha to a full sha against the
|
||||
// source repo. The `^{commit}` peel + non-zero exit on a missing
|
||||
// object means a typo'd / stale sha fails loudly right here.
|
||||
let full = git_rev_parse(src, &format!("{sha}^{{commit}}"))
|
||||
.await
|
||||
.with_context(|| format!("commit '{sha}' not found in proposed repo {src_str}"))?;
|
||||
// Bring src's objects into dst. Fetching every head pulls the
|
||||
// wanted commit's history (always reachable from a branch in the
|
||||
// manager's flow) into dst's object db without sha-want.
|
||||
git(
|
||||
dst,
|
||||
&[
|
||||
"fetch",
|
||||
"--no-tags",
|
||||
&src_str,
|
||||
"+refs/heads/*:refs/remotes/proposal-src/*",
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
// Pin the exact commit as the proposal tag. The objects are now
|
||||
// local so this resolves without touching the remote.
|
||||
git(dst, &["tag", tag, &full]).await.with_context(|| {
|
||||
format!("tag {tag} at {full}: commit not reachable from any branch in proposed repo")
|
||||
})?;
|
||||
Ok(full)
|
||||
}
|
||||
|
||||
/// 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 = git_command()
|
||||
.current_dir(dir)
|
||||
.args(["rev-parse", refname])
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("git rev-parse {refname} in {}", dir.display()))?;
|
||||
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
|
||||
}
|
||||
|
||||
/// Plant an annotated tag with `body` as the message. Used for
|
||||
/// `failed/<id>` (body = build error) and `denied/<id>` (body =
|
||||
/// operator note). Multi-line bodies handled via stdin so we don't
|
||||
/// have to escape anything.
|
||||
pub async fn git_tag_annotated(dir: &Path, name: &str, target: &str, body: &str) -> Result<()> {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
// Annotated tags are git objects, so they need a tagger identity
|
||||
// (same constraint as a commit). Pass the hive-c0re identity
|
||||
// inline rather than relying on a global git config — applied
|
||||
// repos are hive-c0re-owned and the host's user might not have
|
||||
// user.email set.
|
||||
let mut child = git_command()
|
||||
.current_dir(dir)
|
||||
.args([
|
||||
"-c",
|
||||
&format!("user.name={GIT_NAME}"),
|
||||
"-c",
|
||||
&format!("user.email={GIT_EMAIL}"),
|
||||
"tag",
|
||||
"-a",
|
||||
name,
|
||||
target,
|
||||
"-F",
|
||||
"-",
|
||||
])
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.with_context(|| format!("spawn git tag -a {name} in {}", dir.display()))?;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin
|
||||
.write_all(body.as_bytes())
|
||||
.await
|
||||
.context("write tag body to git stdin")?;
|
||||
// Drop closes stdin so git can finish reading.
|
||||
drop(stdin);
|
||||
}
|
||||
let out = child.wait_with_output().await.context("wait git tag -a")?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"git tag -a {name} failed ({}): {}",
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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`. Used to fast-forward `refs/heads/main`
|
||||
/// to the just-deployed proposal commit. Uses `update-ref`, not
|
||||
/// `branch -f`, so it works regardless of where HEAD currently sits.
|
||||
pub async fn git_update_ref(dir: &Path, refname: &str, target: &str) -> Result<()> {
|
||||
git(dir, &["update-ref", refname, target]).await
|
||||
}
|
||||
Loading…
Reference in a new issue