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
3 changed files with 190 additions and 1634 deletions
|
|
@ -6,13 +6,9 @@
|
|||
|
||||
mod internal_meta;
|
||||
mod reader;
|
||||
mod reconcile;
|
||||
mod repos;
|
||||
|
||||
pub(crate) use internal_meta::internal_meta_source;
|
||||
pub use reader::reader_token;
|
||||
pub use reconcile::{fetch_forge_main, reconcile_config_apply, reconcile_config_status};
|
||||
pub use repos::fetch_config_main_into_applied;
|
||||
|
||||
use std::future::IntoFuture;
|
||||
use std::sync::OnceLock;
|
||||
|
|
|
|||
|
|
@ -1,158 +0,0 @@
|
|||
//! Reconcile an agent's local applied config checkout against its forge
|
||||
//! `agent-configs/<agent>` `main`. Backs `hivectl forge reconcile-config`
|
||||
//! via the host-socket `ReconcileConfig{Status,Apply}` requests. hive-c0re
|
||||
//! owns the applied checkout + the forge credential, so the reconcile op
|
||||
//! lives here rather than in the CLI.
|
||||
//!
|
||||
//! `Status` is read-only (fetches forge `main` into a scratch ref, reports
|
||||
//! the divergence). `Apply(Forge)` resets the local applied checkout to
|
||||
//! forge `main`; `internal/meta` pins forge `main` itself, not this tree. It
|
||||
//! does NOT auto-deploy. `Apply(Local)` is not supported: forge `main` is
|
||||
//! branch-protected, so it only advances through a config-PR merge.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use hive_host_sock::{HostResponse, ReconcileDirection};
|
||||
|
||||
use super::{CONFIG_ORG, forge_git_url, reader_token};
|
||||
|
||||
/// Scratch ref the forge `main` is fetched into — outside the normal
|
||||
/// branch/tag namespace so it never collides with real refs.
|
||||
pub(crate) const FORGE_MAIN_REF: &str = "refs/hyperhive/forge-config-main";
|
||||
|
||||
/// Fetch `agent-configs/<agent>` `main` into the applied repo's scratch
|
||||
/// ref (read-only, no working-tree change) and return the applied dir.
|
||||
/// Also how a swarm deploy of a merged commit gets that commit's objects.
|
||||
pub async fn fetch_forge_main(agent: &str) -> Result<PathBuf> {
|
||||
let Some(reader) = reader_token().await else {
|
||||
anyhow::bail!(
|
||||
"no forge read token: the swarm store holds none for this hive and there is no core \
|
||||
token file"
|
||||
);
|
||||
};
|
||||
let dir = crate::paths::applied_dir(agent);
|
||||
if !dir.join(".git").exists() {
|
||||
anyhow::bail!("agent `{agent}` has no applied config checkout");
|
||||
}
|
||||
let url = forge_git_url(&format!("{CONFIG_ORG}/{agent}"));
|
||||
crate::lifecycle::git_authed(
|
||||
&dir,
|
||||
&["fetch", "--force", &url, &format!("main:{FORGE_MAIN_REF}")],
|
||||
&reader.auth_header(),
|
||||
)
|
||||
.await
|
||||
.context("fetch forge config main (does forge main exist yet?)")?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Run a git command in `dir` and capture stdout, erroring on non-zero.
|
||||
async fn git_out(dir: &Path, args: &[&str]) -> Result<String> {
|
||||
let out = crate::lifecycle::git_command()
|
||||
.current_dir(dir)
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("invoke git {args:?}"))?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"git {args:?} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
/// Append `lines` (from a git text block) to `messages`, each indented.
|
||||
fn push_indented(messages: &mut Vec<String>, block: &str) {
|
||||
messages.extend(block.lines().map(|l| format!(" {l}")));
|
||||
}
|
||||
|
||||
/// Report the divergence between the local applied checkout and forge
|
||||
/// `main` (host request `ReconcileConfigStatus`). Read-only.
|
||||
pub async fn reconcile_config_status(agent: &str, verbose: bool) -> Result<HostResponse> {
|
||||
let dir = fetch_forge_main(agent).await?;
|
||||
let counts = git_out(
|
||||
&dir,
|
||||
&[
|
||||
"rev-list",
|
||||
"--left-right",
|
||||
"--count",
|
||||
&format!("HEAD...{FORGE_MAIN_REF}"),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let mut parts = counts.split_whitespace();
|
||||
let local_ahead = parts.next().unwrap_or("0");
|
||||
let forge_ahead = parts.next().unwrap_or("0");
|
||||
let mut messages = vec![
|
||||
format!("agent {agent}: local applied main <-> forge agent-configs/{agent} main"),
|
||||
format!(" local ahead by {local_ahead}, forge ahead by {forge_ahead} commit(s)"),
|
||||
];
|
||||
if local_ahead == "0" && forge_ahead == "0" {
|
||||
messages.push(" in sync — nothing to reconcile".to_owned());
|
||||
return Ok(HostResponse::messages(messages));
|
||||
}
|
||||
if forge_ahead != "0" {
|
||||
messages.push(" forge-only commits (applied by --from forge):".to_owned());
|
||||
push_indented(
|
||||
&mut messages,
|
||||
&git_out(
|
||||
&dir,
|
||||
&["log", "--oneline", &format!("HEAD..{FORGE_MAIN_REF}")],
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
if local_ahead != "0" {
|
||||
messages.push(" local-only commits (discarded by --from forge):".to_owned());
|
||||
push_indented(
|
||||
&mut messages,
|
||||
&git_out(
|
||||
&dir,
|
||||
&["log", "--oneline", &format!("{FORGE_MAIN_REF}..HEAD")],
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
messages.push(" diff --stat (local HEAD -> forge main):".to_owned());
|
||||
push_indented(
|
||||
&mut messages,
|
||||
&git_out(&dir, &["diff", "--stat", "HEAD", FORGE_MAIN_REF]).await?,
|
||||
);
|
||||
if verbose {
|
||||
messages.push(" full diff:".to_owned());
|
||||
push_indented(
|
||||
&mut messages,
|
||||
&git_out(&dir, &["diff", "HEAD", FORGE_MAIN_REF]).await?,
|
||||
);
|
||||
}
|
||||
Ok(HostResponse::messages(messages))
|
||||
}
|
||||
|
||||
/// Apply a reconcile in `direction` (host request `ReconcileConfigApply`).
|
||||
/// `Forge` resets the local applied checkout to forge `main`; `Local` is
|
||||
/// unsupported and returns a clear error.
|
||||
pub async fn reconcile_config_apply(
|
||||
agent: &str,
|
||||
direction: ReconcileDirection,
|
||||
) -> Result<HostResponse> {
|
||||
match direction {
|
||||
ReconcileDirection::Local => Ok(HostResponse::error(
|
||||
"reconciling forge from local is not supported yet: forge main is \
|
||||
branch-protected. Resolve via a config PR, or use \
|
||||
`--from forge` to reset the local checkout to forge main.",
|
||||
)),
|
||||
ReconcileDirection::Forge => {
|
||||
let dir = fetch_forge_main(agent).await?;
|
||||
crate::lifecycle::git(&dir, &["reset", "--hard", FORGE_MAIN_REF])
|
||||
.await
|
||||
.context("reset applied main to forge main")?;
|
||||
Ok(HostResponse::messages(vec![
|
||||
format!("reconciled applied/{agent} to forge agent-configs/{agent} main"),
|
||||
format!("effective on next deploy — rebuild {agent} to apply now"),
|
||||
]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
//! Repo plumbing on the local Forgejo: seeding an agent's `applied` repo from
|
||||
//! `agent-configs/<name>`. The typed API-client constructor + org-name
|
||||
//! constants live in the module root (`super`).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::{CONFIG_ORG, forge_git_url, reader_token};
|
||||
|
||||
/// Seed an agent's `applied` repo, missing its `.git` entirely (first spawn,
|
||||
/// or a revival after `destroy --purge`), by fetching `agent-configs/<name>`'s
|
||||
/// `main` from the forge. Forge `main` advances with every operator-merged
|
||||
/// config PR, so this reflects the last merged config. A config deployed
|
||||
/// through hive-c0re without a forge merge never reaches it.
|
||||
///
|
||||
/// Best-effort: returns `false` (not an error) rather than bailing when there
|
||||
/// is no [`reader_token`], `applied` already has a `.git` (nothing to do), the
|
||||
/// forge has no `main` for the agent yet (the normal case right after
|
||||
/// `swarm-controller` creates it, before it has pushed anything), or any git
|
||||
/// step fails. Callers fall back to the template or the original hard error. A
|
||||
/// failed seed leaves the directory empty, so a later "is it seeded" check reads
|
||||
/// the same as before the attempt. Only `true` once `applied` is seeded and
|
||||
/// tagged `deployed/0`.
|
||||
pub async fn fetch_config_main_into_applied(name: &str) -> bool {
|
||||
let Some(reader) = reader_token().await else {
|
||||
return false;
|
||||
};
|
||||
let applied = crate::paths::applied_dir(name);
|
||||
if applied.join(".git").exists() {
|
||||
return false;
|
||||
}
|
||||
let seeded = async {
|
||||
if let Err(e) = std::fs::create_dir_all(&applied) {
|
||||
tracing::warn!(%name, error = ?e, "forge: applied reseed mkdir failed");
|
||||
return false;
|
||||
}
|
||||
if let Err(e) = crate::lifecycle::git(&applied, &["init", "--initial-branch=main"]).await {
|
||||
tracing::warn!(%name, error = ?e, "forge: applied reseed init failed");
|
||||
return false;
|
||||
}
|
||||
let url = forge_git_url(&format!("{CONFIG_ORG}/{name}"));
|
||||
let auth = reader.auth_header();
|
||||
let out = crate::lifecycle::git_command_authed(&auth)
|
||||
.current_dir(&applied)
|
||||
.args([
|
||||
"fetch",
|
||||
"--no-tags",
|
||||
"--update-head-ok",
|
||||
&url,
|
||||
"refs/heads/main:refs/heads/main",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
match out {
|
||||
Ok(o) if o.status.success() => {}
|
||||
Ok(o) => {
|
||||
// `info`, not `warn`: the normal case right after
|
||||
// `swarm-controller` creates the agent, before it has pushed
|
||||
// anything into the repo it just made, and nothing is wrong.
|
||||
tracing::info!(
|
||||
%name,
|
||||
stderr = %String::from_utf8_lossy(&o.stderr).trim(),
|
||||
"forge: no config to fetch for this agent yet"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(%name, error = ?e, "forge: applied reseed fetch 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 reseed read-tree failed");
|
||||
return false;
|
||||
}
|
||||
if let Err(e) = crate::lifecycle::git_tag(&applied, "deployed/0", "refs/heads/main").await {
|
||||
tracing::warn!(%name, error = ?e, "forge: applied reseed tag failed");
|
||||
return false;
|
||||
}
|
||||
tracing::info!(%name, "forge: seeded applied repo from agent-configs main");
|
||||
true
|
||||
}
|
||||
.await;
|
||||
if !seeded {
|
||||
clear_dir(&applied);
|
||||
}
|
||||
seeded
|
||||
}
|
||||
|
||||
/// Empty `dir`, keeping the directory itself: a failed seed may have written
|
||||
/// the working tree as well as the `.git`, and the template fallback must start
|
||||
/// from nothing rather than adopt files that were meant for the forge's commit.
|
||||
fn clear_dir(dir: &Path) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let _ = if path.is_dir() {
|
||||
std::fs::remove_dir_all(path)
|
||||
} else {
|
||||
std::fs::remove_file(path)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::clear_dir;
|
||||
|
||||
#[test]
|
||||
fn clear_dir_empties_a_dir_but_keeps_it() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::create_dir(dir.path().join(".git")).expect("mkdir .git");
|
||||
std::fs::write(dir.path().join(".git/HEAD"), "ref").expect("write HEAD");
|
||||
std::fs::write(dir.path().join("agent.nix"), "{}").expect("write agent.nix");
|
||||
|
||||
clear_dir(dir.path());
|
||||
|
||||
assert!(dir.path().is_dir(), "the directory itself stays");
|
||||
assert_eq!(std::fs::read_dir(dir.path()).expect("read_dir").count(), 0);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue