an interrupted meta sync resumes on the next run instead of leaving flake.lock stale
sync_agents returned early whenever the rendered flake.nix matched disk. A sync stopped after writing flake.nix but before its commit therefore never re-locked or committed. The early return now also requires that flake.nix and flake.lock have nothing uncommitted; otherwise the normal lock+commit path runs and logs that it is resuming.
This commit is contained in:
parent
53eed2a757
commit
f864ffba1b
2 changed files with 68 additions and 4 deletions
|
|
@ -144,9 +144,11 @@ autoupdate flake-rev bump (one shot before per-agent rebuilds).
|
||||||
carries `hyperhive_flake`, `dashboard_port`, and the rest of the
|
carries `hyperhive_flake`, `dashboard_port`, and the rest of the
|
||||||
per-hive config — is the idempotent reconciler called by `provision_container`,
|
per-hive config — is the idempotent reconciler called by `provision_container`,
|
||||||
`destroy`, `rebuild`, and the startup migration. Renders `flake.nix`
|
`destroy`, `rebuild`, and the startup migration. Renders `flake.nix`
|
||||||
from the agent list; if it differs from disk, runs
|
from the agent list; if it differs from disk, or `flake.nix` or `flake.lock`
|
||||||
|
has an uncommitted change (a sync stopped before its commit), runs
|
||||||
`nix flake lock` + commits as `regenerate meta flake` (or
|
`nix flake lock` + commits as `regenerate meta flake` (or
|
||||||
`seed meta from N agent(s)` on the first call).
|
`seed meta from N agent(s)` on the first call). A clean tree whose
|
||||||
|
`flake.nix` already matches returns without running nix.
|
||||||
|
|
||||||
The root agent has `/meta` RO-bound inside its container:
|
The root agent has `/meta` RO-bound inside its container:
|
||||||
`git -C /meta log --oneline` is the swarm-wide deploy log,
|
`git -C /meta log --oneline` is the swarm-wide deploy log,
|
||||||
|
|
|
||||||
|
|
@ -148,11 +148,22 @@ async fn stage_generated_meta_files(dir: &std::path::Path) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Meta files `sync_agents` writes and locks; an uncommitted change to them
|
||||||
|
/// means an earlier sync stopped before its commit.
|
||||||
|
const SYNC_OWNED_PATHS: &[&str] = &["flake.nix", "flake.lock"];
|
||||||
|
|
||||||
|
/// Whether `sync_agents` has nothing to do: the repo exists, the rendered
|
||||||
|
/// flake equals the one on disk, and no sync-owned file is uncommitted.
|
||||||
|
fn can_skip_sync(initial: bool, flake_unchanged: bool, uncommitted: bool) -> bool {
|
||||||
|
!initial && flake_unchanged && !uncommitted
|
||||||
|
}
|
||||||
|
|
||||||
/// Idempotently reconcile the meta repo with the current agent set.
|
/// Idempotently reconcile the meta repo with the current agent set.
|
||||||
/// First call inits the git repo, runs `nix flake lock`, and lands a
|
/// First call inits the git repo, runs `nix flake lock`, and lands a
|
||||||
/// seed commit. Subsequent calls only touch `flake.nix` when the
|
/// seed commit. Subsequent calls only touch `flake.nix` when the
|
||||||
/// rendered contents differ from disk; an unchanged `flake.nix` is a
|
/// rendered contents differ from disk; an unchanged `flake.nix` is a
|
||||||
/// no-op.
|
/// no-op unless `flake.nix` or `flake.lock` is uncommitted, in which case
|
||||||
|
/// the lock and commit run again.
|
||||||
pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
|
pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
|
||||||
let _guard = lock_meta("sync_agents").await;
|
let _guard = lock_meta("sync_agents").await;
|
||||||
// Before anything is written: a hive without a forge URL would deploy a
|
// Before anything is written: a hive without a forge URL would deploy a
|
||||||
|
|
@ -174,9 +185,18 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
|
||||||
let on_disk = std::fs::read_to_string(&flake_path).unwrap_or_default();
|
let on_disk = std::fs::read_to_string(&flake_path).unwrap_or_default();
|
||||||
let initial = !dir.join(".git").exists();
|
let initial = !dir.join(".git").exists();
|
||||||
|
|
||||||
if !initial && on_disk == new_flake {
|
let flake_unchanged = on_disk == new_flake;
|
||||||
|
let uncommitted = if !initial && flake_unchanged {
|
||||||
|
paths_dirty(&dir, SYNC_OWNED_PATHS).await?
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
if can_skip_sync(initial, flake_unchanged, uncommitted) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
if uncommitted {
|
||||||
|
tracing::info!("sync_agents: resuming an uncommitted flake.nix/flake.lock change");
|
||||||
|
}
|
||||||
|
|
||||||
// Safety guard: refuse to write an empty agent list over a non-empty
|
// Safety guard: refuse to write an empty agent list over a non-empty
|
||||||
// on-disk flake. An empty `agents` slice is never intentional — it
|
// on-disk flake. An empty `agents` slice is never intentional — it
|
||||||
|
|
@ -1162,6 +1182,48 @@ async fn nix(dir: &Path, args: &[&str]) -> Result<()> {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_skips_only_when_clean_unchanged_and_not_initial() {
|
||||||
|
assert!(can_skip_sync(false, true, false));
|
||||||
|
assert!(!can_skip_sync(false, true, true), "interrupted sync");
|
||||||
|
assert!(!can_skip_sync(false, false, false), "flake changed");
|
||||||
|
assert!(!can_skip_sync(true, true, false), "no repo yet");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An interrupted sync leaves the new `flake.nix` written with the old
|
||||||
|
/// lock; the dirty check over the sync-owned paths must see it, and stay
|
||||||
|
/// quiet on a clean tree and on files sync does not own.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn interrupted_sync_leaves_sync_owned_paths_dirty() {
|
||||||
|
let tmp = tempfile::tempdir().expect("tempdir");
|
||||||
|
let dir = tmp.path();
|
||||||
|
git(dir, &["init", "--initial-branch=main"])
|
||||||
|
.await
|
||||||
|
.expect("git init");
|
||||||
|
std::fs::write(dir.join("flake.nix"), "old").expect("write");
|
||||||
|
std::fs::write(dir.join("flake.lock"), "lock-old").expect("write");
|
||||||
|
std::fs::write(dir.join("other.json"), "{}").expect("write");
|
||||||
|
git(dir, &["add", "-A"]).await.expect("add");
|
||||||
|
git_commit(dir, "seed").await.expect("seed commit");
|
||||||
|
assert!(!paths_dirty(dir, SYNC_OWNED_PATHS).await.expect("check"));
|
||||||
|
|
||||||
|
std::fs::write(dir.join("other.json"), r#"{"x":1}"#).expect("write");
|
||||||
|
assert!(
|
||||||
|
!paths_dirty(dir, SYNC_OWNED_PATHS).await.expect("check"),
|
||||||
|
"a file sync does not own is not an interrupted sync"
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::write(dir.join("flake.nix"), "new").expect("write");
|
||||||
|
git(dir, &["add", "flake.nix"]).await.expect("stage");
|
||||||
|
assert!(
|
||||||
|
paths_dirty(dir, SYNC_OWNED_PATHS).await.expect("check"),
|
||||||
|
"new flake.nix written but not committed"
|
||||||
|
);
|
||||||
|
git(dir, &["add", "-A"]).await.expect("add");
|
||||||
|
git_commit(dir, "resume").await.expect("commit");
|
||||||
|
assert!(!paths_dirty(dir, SYNC_OWNED_PATHS).await.expect("check"));
|
||||||
|
}
|
||||||
|
|
||||||
/// A path-limited commit must leave an unrelated staged file untouched.
|
/// A path-limited commit must leave an unrelated staged file untouched.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn path_limited_commit_leaves_unrelated_staged_file_alone() {
|
async fn path_limited_commit_leaves_unrelated_staged_file_alone() {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue