Files
AWG2_harness/traffic_core/src/managed_file.rs
T
founder 116b1ea91a fix(swap): roll back partial teardown, resize without a swapless gap, lock the disable path [P1]
Four defects from the follow-up audit.

1. Legacy migration swallowed every error.

   drop_legacy_managed_file was `let _ = file.remove(); Ok(())`, which tolerated not only
   the expected "that path is someone else's now" but also PermissionDenied, read errors
   and failed unlinks. If /etc/sysctl.d/99-awg2-harness.conf survived, it would keep
   winning over the new 90- file - it sorts later - while the reconcile reported success,
   so vm.swappiness would silently stay at the old value. The three cases are now
   distinguished: Absent is fine, Foreign warns and continues, Managed removes and any
   failure propagates.

2. The disable branches ran outside the lock.

   Both ensure_swap and ensure_sysctl_tuning checked `enabled` before acquiring their
   lock, so the cleanup path - which deletes managed files - was unsynchronised. A manual
   CLI invocation and the daemon's periodic reconcile could write and delete the same file
   concurrently. The lock is now taken first in both.

3. A failed teardown left foreign swaps half-disabled.

   disable_foreign_swaps aborted the loop on the first failure, so a host with two foreign
   swaps could end with the first one off and the second still up - a state nobody asked
   for and the error message did not describe. It now tracks what it disabled and
   re-enables it, in reverse order and with the original priority, before returning the
   error. The restore is best-effort and warns per entry rather than replacing the reported
   failure with a rollback failure.

4. Resizing dropped swap for the whole allocation.

   A live swapfile cannot be resized in place, and the previous code did swapoff, remove,
   fallocate/dd, mkswap, swapon. On a host with no other swap that is a swapless window
   spanning the slowest and most failure-prone part of the operation - on a machine that
   was given swap precisely because memory is tight, with no way back if any step failed.

   The replacement is now built and activated at a staging sibling while the old swap is
   still serving, and only then is the old file released. The remaining gap is
   swapoff/rename/swapon, which allocates nothing. A failure while staging removes the
   staging file and leaves the original swap untouched; a failure at the final rename says
   explicitly that the file is in place and can be activated with swapon.

   Related, same function: changing only swap.priority used to fall through to the full
   recreate path, destroying and rebuilding a correct multi-gigabyte file to change one
   integer. Size and priority are now separate decisions - a priority-only change is a
   reactivation.

The reconciliation is now decided by an explicit match over (size correct, currently
active) rather than a single `geometry_ok` flag, which is what hid the priority case.

Three tests added around the new decision surface: the priority-only case is not a resize,
the staging path is a sibling of the swapfile, and the managed swap is never part of the
foreign teardown set.

cargo test --workspace: 179 + 4 passed. clippy -D warnings clean.
2026-08-07 04:15:40 +05:00

247 lines
8.5 KiB
Rust

//! Marker-guarded ownership for the files this package writes under `/etc`.
//!
//! Both `sysctl_tuning` and `swap` drop files into `/etc/sysctl.d`, and they used to do it
//! differently: one refused to touch a file without its marker, the other overwrote
//! whatever was at its path and never cleaned up when disabled. The rules belong in one
//! place, because the failure mode of getting them wrong is silently clobbering an
//! administrator's configuration.
//!
//! Invariants:
//!
//! - a read that fails is never treated as an absent file;
//! - a file whose first line does not carry our marker is never written or removed;
//! - writes are atomic: temp sibling, `sync_all`, rename, directory flush.
use std::{fs, io::ErrorKind, path::PathBuf};
use crate::atomic;
/// Debian default for files under `/etc/sysctl.d` and `/etc/modules-load.d`.
const MANAGED_MODE: u32 = 0o644;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ownership {
Absent,
Managed,
Foreign,
}
#[derive(Debug, Clone)]
pub struct ManagedFile {
path: PathBuf,
marker: String,
}
impl ManagedFile {
pub fn new(path: impl Into<PathBuf>, marker: impl Into<String>) -> Self {
Self {
path: path.into(),
marker: marker.into(),
}
}
pub fn path(&self) -> &std::path::Path {
&self.path
}
/// `Ok(None)` only when the file is genuinely absent. Any other failure propagates.
pub fn read(&self) -> anyhow::Result<Option<String>> {
match fs::read_to_string(&self.path) {
Ok(v) => Ok(Some(v)),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(None),
Err(e) => Err(anyhow::anyhow!(
"failed to read {}: {e}",
self.path.display()
)),
}
}
/// Ownership is decided by an **exact** first line, not a substring.
///
/// `contains` accepted a file whose first line merely embedded the marker, so
/// `# NOT Managed by awg2-harness sysctl_tuning` counted as ours and became eligible
/// for overwrite and removal.
pub fn ownership(&self) -> anyhow::Result<Ownership> {
let Some(content) = self.read()? else {
return Ok(Ownership::Absent);
};
let first = content.lines().next().unwrap_or_default();
if first.trim_end() == self.marker.trim_end() {
Ok(Ownership::Managed)
} else {
Ok(Ownership::Foreign)
}
}
/// Writes `content`, refusing if the path holds a file we do not own.
///
/// Returns whether anything changed, so callers can stay quiet when reconciling an
/// already-correct host.
pub fn write(&self, content: &str) -> anyhow::Result<bool> {
match self.ownership()? {
Ownership::Foreign => {
return Err(anyhow::anyhow!(
"refusing to overwrite unmanaged file {}: it does not carry the marker {:?}",
self.path.display(),
self.marker
));
}
Ownership::Managed => {
if self.read()?.as_deref() == Some(content) {
return Ok(false);
}
}
Ownership::Absent => {}
}
atomic::write_atomic_no_follow(&self.path, content, MANAGED_MODE)?;
Ok(true)
}
/// Removes the file if we own it. Absent is success; foreign is an error.
pub fn remove(&self) -> anyhow::Result<bool> {
match self.ownership()? {
Ownership::Absent => Ok(false),
Ownership::Foreign => Err(anyhow::anyhow!(
"refusing to remove unmanaged file {}: it does not carry the marker {:?}",
self.path.display(),
self.marker
)),
Ownership::Managed => {
fs::remove_file(&self.path).map_err(|e| {
anyhow::anyhow!("failed to remove {}: {e}", self.path.display())
})?;
Ok(true)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
const MARKER: &str = "# Managed by awg2-harness test";
fn scratch(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("awg2_managed_{}_{}", name, std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("scratch dir");
dir
}
fn managed(dir: &Path) -> ManagedFile {
ManagedFile::new(dir.join("conf"), MARKER)
}
#[test]
fn absent_file_reads_as_none() {
let dir = scratch("absent");
let f = managed(&dir);
assert_eq!(f.read().expect("read"), None);
assert_eq!(f.ownership().expect("ownership"), Ownership::Absent);
}
/// A path that exists but cannot be read as text must not look like an absent file.
#[test]
fn unreadable_file_is_an_error() {
let dir = scratch("unreadable");
let f = ManagedFile::new(dir.clone(), MARKER);
assert!(f.read().is_err());
}
#[test]
fn write_then_ownership_is_managed() {
let dir = scratch("write");
let f = managed(&dir);
assert!(f.write(&format!("{MARKER}\nkey = 1\n")).expect("write"));
assert_eq!(f.ownership().expect("ownership"), Ownership::Managed);
}
#[test]
fn rewriting_identical_content_reports_no_change() {
let dir = scratch("idempotent");
let f = managed(&dir);
let content = format!("{MARKER}\nkey = 1\n");
assert!(f.write(&content).expect("first"));
assert!(!f.write(&content).expect("second"));
}
/// The guard that the swap module previously lacked entirely.
#[test]
fn foreign_file_is_never_overwritten() {
let dir = scratch("foreign_write");
let f = managed(&dir);
fs::write(dir.join("conf"), "# someone elses config\nkey = 9\n").expect("seed");
assert_eq!(f.ownership().expect("ownership"), Ownership::Foreign);
let err = f
.write(&format!("{MARKER}\nkey = 1\n"))
.expect_err("refuse");
assert!(err.to_string().contains("refusing to overwrite unmanaged"));
assert_eq!(
fs::read_to_string(dir.join("conf")).expect("intact"),
"# someone elses config\nkey = 9\n"
);
}
#[test]
fn foreign_file_is_never_removed() {
let dir = scratch("foreign_remove");
let f = managed(&dir);
fs::write(dir.join("conf"), "# someone elses config\n").expect("seed");
let err = f.remove().expect_err("refuse");
assert!(err.to_string().contains("refusing to remove unmanaged"));
assert!(dir.join("conf").exists());
}
#[test]
fn remove_is_idempotent_for_managed_and_absent() {
let dir = scratch("remove");
let f = managed(&dir);
f.write(&format!("{MARKER}\nkey = 1\n")).expect("write");
assert!(f.remove().expect("first remove"));
assert!(!f.remove().expect("second remove"));
}
#[test]
fn stale_temp_does_not_block_write() {
let dir = scratch("stale_temp");
let f = managed(&dir);
fs::write(dir.join("conf.awg2-harness.tmp"), "garbage").expect("stale");
assert!(f.write(&format!("{MARKER}\nkey = 1\n")).expect("write"));
assert!(!dir.join("conf.awg2-harness.tmp").exists());
}
/// `contains` used to accept any first line that embedded the marker, so a file
/// explicitly declaring it is *not* managed became eligible for overwrite.
#[test]
fn marker_must_match_the_whole_first_line() {
let dir = scratch("marker_exact");
let f = managed(&dir);
for first_line in [
format!("# NOT {}", MARKER.trim_start_matches("# ")),
format!("{MARKER} but actually hand written"),
format!("prefix {MARKER}"),
] {
fs::write(dir.join("conf"), format!("{first_line}\nkey = 1\n")).expect("seed");
assert_eq!(
f.ownership().expect("ownership"),
Ownership::Foreign,
"must not claim ownership of first line {first_line:?}"
);
}
}
/// Trailing whitespace on the marker line is not a reason to disown our own file.
#[test]
fn marker_comparison_ignores_trailing_whitespace() {
let dir = scratch("marker_trailing");
let f = managed(&dir);
fs::write(dir.join("conf"), format!("{MARKER} \nkey = 1\n")).expect("seed");
assert_eq!(f.ownership().expect("ownership"), Ownership::Managed);
}
}