284 lines
11 KiB
Rust
284 lines
11 KiB
Rust
//! Pre-apply validation for agent `flake.lock` files (closes part of #317).
|
|
//!
|
|
//! Every `request_apply_commit` lands a `proposal/<id>` tag in the
|
|
//! agent's applied repo before the operator sees the approval. We
|
|
//! parse `flake.lock` from that tag's tree and reject the request if
|
|
//! two or more nodes share an identical `original` field — that
|
|
//! signals a missing `inputs.<X>.inputs.nixpkgs.follows = "nixpkgs"`
|
|
//! directive in `flake.nix` and would inflate meta's lock with
|
|
//! duplicates after deploy.
|
|
//!
|
|
//! Per mara's scope note on #317 (comment 4189): the check runs on
|
|
//! the agent repo, not meta, and catches *new* violations only.
|
|
//! Existing agents whose lock already has duplicates are out of
|
|
//! scope here and get a coordinated config-change pass via the
|
|
//! manager instead.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::fmt::Write as _;
|
|
use std::path::Path;
|
|
|
|
use anyhow::{Context, Result};
|
|
use serde_json::Value;
|
|
|
|
use crate::lifecycle::git_command;
|
|
|
|
/// One group of `flake.lock` nodes that all share the same canonical
|
|
/// `original` reference. Surfaced in the rejection message so the
|
|
/// operator (and the manager that submitted the apply) can see
|
|
/// exactly which input pair needs a `follows` directive.
|
|
#[derive(Debug, Clone)]
|
|
pub struct DuplicateGroup {
|
|
/// One of the original `Value`s from the lock — used for
|
|
/// pretty-printing in the error message. Canonical equivalence
|
|
/// is enforced by the `BTreeMap` key in `duplicate_groups`, so
|
|
/// we don't need to keep the canonicalised form on the struct.
|
|
pub original: Value,
|
|
/// Names of the flake.lock nodes that share this `original`,
|
|
/// sorted for stable error output.
|
|
pub keys: Vec<String>,
|
|
}
|
|
|
|
/// Read `flake.lock` from `<tag>:flake.lock` in `repo`. Returns
|
|
/// `Ok(None)` when the file isn't tracked in that tag (no inputs ⇒
|
|
/// nothing to dedup); `Err` only on real git plumbing failures.
|
|
async fn read_lock_at_tag(repo: &Path, tag: &str) -> Result<Option<String>> {
|
|
let spec = format!("{tag}:flake.lock");
|
|
let out = git_command()
|
|
.current_dir(repo)
|
|
.args(["show", &spec])
|
|
.output()
|
|
.await
|
|
.with_context(|| format!("git show {spec} in {}", repo.display()))?;
|
|
if !out.status.success() {
|
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
|
// git uses different messages for "path not in tree" depending
|
|
// on version + whether the path collides with an on-disk file.
|
|
// Treat any of them as "no flake.lock in this commit" rather
|
|
// than a hard failure — an agent with empty `inputs = { }` is
|
|
// a legitimate, dedup-clean state.
|
|
if stderr.contains("does not exist")
|
|
|| stderr.contains("exists on disk, but not in")
|
|
|| stderr.contains("Path '")
|
|
{
|
|
return Ok(None);
|
|
}
|
|
anyhow::bail!("git show {spec} failed: {}", stderr.trim());
|
|
}
|
|
Ok(Some(String::from_utf8_lossy(&out.stdout).into_owned()))
|
|
}
|
|
|
|
/// Recursively serialise `v` with object keys sorted, so two
|
|
/// JSON values that differ only in key insertion order produce the
|
|
/// same string. `serde_json::Value` preserves `IndexMap` order by
|
|
/// default, which is fine for parsing but breaks our group-by-key
|
|
/// idea — hence this hand-rolled canonicaliser.
|
|
fn canonical_json(v: &Value) -> String {
|
|
match v {
|
|
Value::Object(map) => {
|
|
let mut keys: Vec<&String> = map.keys().collect();
|
|
keys.sort();
|
|
let mut s = String::from("{");
|
|
for (i, k) in keys.iter().enumerate() {
|
|
if i > 0 {
|
|
s.push(',');
|
|
}
|
|
s.push_str(&serde_json::to_string(k).unwrap_or_default());
|
|
s.push(':');
|
|
s.push_str(&canonical_json(&map[*k]));
|
|
}
|
|
s.push('}');
|
|
s
|
|
}
|
|
Value::Array(arr) => {
|
|
let mut s = String::from("[");
|
|
for (i, x) in arr.iter().enumerate() {
|
|
if i > 0 {
|
|
s.push(',');
|
|
}
|
|
s.push_str(&canonical_json(x));
|
|
}
|
|
s.push(']');
|
|
s
|
|
}
|
|
_ => serde_json::to_string(v).unwrap_or_default(),
|
|
}
|
|
}
|
|
|
|
/// Parse `raw` (a `flake.lock` JSON document) and return every group
|
|
/// of nodes whose `original` field is identical. Nodes without an
|
|
/// `original` (the synthetic `root`, or anomalous entries) are
|
|
/// skipped. Groups with only one member are filtered out — only true
|
|
/// duplicates surface.
|
|
///
|
|
/// Pure function, no I/O — covered by the unit tests below.
|
|
pub fn duplicate_groups(raw: &str) -> Result<Vec<DuplicateGroup>> {
|
|
let json: Value = serde_json::from_str(raw).context("parse flake.lock")?;
|
|
let Some(nodes) = json.get("nodes").and_then(|v| v.as_object()) else {
|
|
return Ok(Vec::new());
|
|
};
|
|
let mut groups: BTreeMap<String, DuplicateGroup> = BTreeMap::new();
|
|
for (name, node) in nodes {
|
|
let Some(original) = node.get("original") else {
|
|
continue;
|
|
};
|
|
let key = canonical_json(original);
|
|
let entry = groups.entry(key).or_insert_with(|| DuplicateGroup {
|
|
original: original.clone(),
|
|
keys: Vec::new(),
|
|
});
|
|
entry.keys.push(name.clone());
|
|
}
|
|
let mut dups: Vec<DuplicateGroup> = groups
|
|
.into_values()
|
|
.filter(|g| g.keys.len() > 1)
|
|
.collect();
|
|
for g in &mut dups {
|
|
g.keys.sort();
|
|
}
|
|
Ok(dups)
|
|
}
|
|
|
|
/// Run the dedup check against the agent's freshly-applied tree.
|
|
///
|
|
/// `Ok(())` means either the commit doesn't carry a `flake.lock` (no
|
|
/// inputs declared) or every node has a unique `original`. `Err`
|
|
/// carries a multi-line message listing every duplicate group with
|
|
/// the offending node names, suitable for surfacing on the failed
|
|
/// approval row.
|
|
pub async fn check_no_duplicate_inputs(repo: &Path, tag: &str) -> Result<()> {
|
|
let Some(raw) = read_lock_at_tag(repo, tag).await? else {
|
|
return Ok(());
|
|
};
|
|
let dups = duplicate_groups(&raw)?;
|
|
if dups.is_empty() {
|
|
return Ok(());
|
|
}
|
|
let mut msg = String::from(
|
|
"flake.lock has duplicate flake inputs — add a `follows` directive in flake.nix to collapse them:\n",
|
|
);
|
|
for g in &dups {
|
|
let original = serde_json::to_string(&g.original).unwrap_or_else(|_| "?".into());
|
|
let _ = writeln!(msg, " - {original} → nodes [{}]", g.keys.join(", "));
|
|
}
|
|
anyhow::bail!("{}", msg.trim_end());
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const CLEAN_LOCK: &str = r#"{
|
|
"nodes": {
|
|
"nixpkgs": {
|
|
"locked": {"rev": "aaa"},
|
|
"original": {"owner": "NixOS", "repo": "nixpkgs", "ref": "nixos-25.11", "type": "github"}
|
|
},
|
|
"nixpkgs-unstable": {
|
|
"locked": {"rev": "bbb"},
|
|
"original": {"owner": "NixOS", "repo": "nixpkgs", "ref": "nixpkgs-unstable", "type": "github"}
|
|
},
|
|
"root": {"inputs": {"nixpkgs": "nixpkgs"}}
|
|
},
|
|
"root": "root",
|
|
"version": 7
|
|
}"#;
|
|
|
|
const DUPLICATE_LOCK: &str = r#"{
|
|
"nodes": {
|
|
"nixpkgs": {
|
|
"locked": {"rev": "aaa"},
|
|
"original": {"owner": "NixOS", "repo": "nixpkgs", "ref": "nixos-25.11", "type": "github"}
|
|
},
|
|
"nixpkgs_2": {
|
|
"locked": {"rev": "ccc"},
|
|
"original": {"owner": "NixOS", "repo": "nixpkgs", "ref": "nixos-25.11", "type": "github"}
|
|
},
|
|
"nixpkgs_3": {
|
|
"locked": {"rev": "ddd"},
|
|
"original": {"owner": "NixOS", "repo": "nixpkgs", "ref": "nixos-25.11", "type": "github"}
|
|
},
|
|
"treefmt-nix": {
|
|
"locked": {"rev": "eee"},
|
|
"original": {"owner": "numtide", "repo": "treefmt-nix", "type": "github"}
|
|
},
|
|
"treefmt-nix_2": {
|
|
"locked": {"rev": "fff"},
|
|
"original": {"type": "github", "owner": "numtide", "repo": "treefmt-nix"}
|
|
},
|
|
"root": {"inputs": {"nixpkgs": "nixpkgs"}}
|
|
},
|
|
"root": "root",
|
|
"version": 7
|
|
}"#;
|
|
|
|
#[test]
|
|
fn clean_lock_has_no_duplicates() {
|
|
let dups = duplicate_groups(CLEAN_LOCK).expect("parse");
|
|
assert!(dups.is_empty(), "expected no dups, got {dups:#?}");
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_lock_reports_groups() {
|
|
let dups = duplicate_groups(DUPLICATE_LOCK).expect("parse");
|
|
assert_eq!(dups.len(), 2, "expected nixpkgs + treefmt-nix groups");
|
|
|
|
// Order is BTreeMap-stable: sorted by canonical_json key. The
|
|
// numtide treefmt-nix key sorts before the NixOS nixpkgs one
|
|
// because `numtide` < `NixOS` lexicographically (case-sensitive,
|
|
// capitals come first... wait — capital N is 0x4e, lowercase n
|
|
// is 0x6e, so capitals come first). So nixpkgs group sorts
|
|
// first. Verify by content instead of position to avoid coupling
|
|
// to that subtlety.
|
|
let nixpkgs_group = dups
|
|
.iter()
|
|
.find(|g| g.original.get("ref").is_some())
|
|
.expect("nixpkgs group present");
|
|
assert_eq!(
|
|
nixpkgs_group.keys,
|
|
vec!["nixpkgs", "nixpkgs_2", "nixpkgs_3"]
|
|
);
|
|
|
|
let treefmt_group = dups
|
|
.iter()
|
|
.find(|g| g.original.get("ref").is_none())
|
|
.expect("treefmt-nix group present");
|
|
assert_eq!(treefmt_group.keys, vec!["treefmt-nix", "treefmt-nix_2"]);
|
|
}
|
|
|
|
#[test]
|
|
fn key_order_in_original_does_not_matter() {
|
|
// The treefmt-nix and treefmt-nix_2 entries above use different
|
|
// key orderings for `original` ({owner,repo,type} vs
|
|
// {type,owner,repo}); duplicate_groups should still merge them.
|
|
let dups = duplicate_groups(DUPLICATE_LOCK).expect("parse");
|
|
let treefmt = dups
|
|
.iter()
|
|
.find(|g| g.keys.iter().any(|k| k == "treefmt-nix"))
|
|
.expect("treefmt-nix group");
|
|
assert!(treefmt.keys.contains(&"treefmt-nix_2".to_owned()));
|
|
}
|
|
|
|
#[test]
|
|
fn nodes_without_original_are_ignored() {
|
|
// The synthetic `root` node has no `original` and must not be
|
|
// grouped against anything.
|
|
let dups = duplicate_groups(CLEAN_LOCK).expect("parse");
|
|
assert!(dups.iter().all(|g| !g.keys.iter().any(|k| k == "root")));
|
|
}
|
|
|
|
#[test]
|
|
fn missing_nodes_object_is_not_an_error() {
|
|
// A lock file that's syntactically JSON but lacks `nodes` (e.g.
|
|
// a partial test fixture) should fail open — no dups reported.
|
|
let raw = r#"{"root": "root", "version": 7}"#;
|
|
let dups = duplicate_groups(raw).expect("parse");
|
|
assert!(dups.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_json_is_a_hard_error() {
|
|
let err = duplicate_groups("not-json-at-all").unwrap_err();
|
|
assert!(format!("{err:#}").contains("parse flake.lock"));
|
|
}
|
|
}
|