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.
This commit is contained in:
@@ -40,6 +40,10 @@ impl ManagedFile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> &std::path::Path {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
|
||||||
/// `Ok(None)` only when the file is genuinely absent. Any other failure propagates.
|
/// `Ok(None)` only when the file is genuinely absent. Any other failure propagates.
|
||||||
pub fn read(&self) -> anyhow::Result<Option<String>> {
|
pub fn read(&self) -> anyhow::Result<Option<String>> {
|
||||||
match fs::read_to_string(&self.path) {
|
match fs::read_to_string(&self.path) {
|
||||||
|
|||||||
+222
-32
@@ -14,7 +14,7 @@ use crate::{
|
|||||||
config::Config,
|
config::Config,
|
||||||
fstab::{self, FSTAB_PATH, FstabTransaction, escape_field},
|
fstab::{self, FSTAB_PATH, FstabTransaction, escape_field},
|
||||||
lock::FileLock,
|
lock::FileLock,
|
||||||
managed_file::ManagedFile,
|
managed_file::{ManagedFile, Ownership},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -131,14 +131,26 @@ fn migrate_legacy_swappiness_file() -> anyhow::Result<()> {
|
|||||||
drop_legacy_managed_file(&legacy_swappiness_file())
|
drop_legacy_managed_file(&legacy_swappiness_file())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes a file left by an older version, tolerating a foreign file at that path.
|
/// Removes a file left by an older version.
|
||||||
///
|
///
|
||||||
/// A refusal from [`ManagedFile::remove`] means the path now belongs to someone else.
|
/// Only a foreign file is tolerated - that path belongs to someone else now and the
|
||||||
/// That is not a migration failure: the current file lives elsewhere, so there is nothing
|
/// current file lives elsewhere, so there is nothing to migrate. Everything else is
|
||||||
/// to do and nothing to report.
|
/// reported: swallowing `PermissionDenied` or a failed unlink would leave
|
||||||
|
/// `/etc/sysctl.d/99-awg2-harness.conf` in place, and it sorts *after* the new `90-` file,
|
||||||
|
/// so the stale value would keep winning while the reconcile reported success.
|
||||||
fn drop_legacy_managed_file(file: &ManagedFile) -> anyhow::Result<()> {
|
fn drop_legacy_managed_file(file: &ManagedFile) -> anyhow::Result<()> {
|
||||||
let _ = file.remove();
|
match file.ownership()? {
|
||||||
Ok(())
|
Ownership::Absent => Ok(()),
|
||||||
|
Ownership::Foreign => {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "swap",
|
||||||
|
"legacy path {} holds a file we do not own; leaving it alone",
|
||||||
|
file.path().display()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Ownership::Managed => file.remove().map(|_| ()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Brings host swap to the desired state as a transaction.
|
/// Brings host swap to the desired state as a transaction.
|
||||||
@@ -147,12 +159,15 @@ fn drop_legacy_managed_file(file: &ManagedFile) -> anyhow::Result<()> {
|
|||||||
/// any foreign swap is disabled. The previous order disabled foreign swaps first, so a
|
/// any foreign swap is disabled. The previous order disabled foreign swaps first, so a
|
||||||
/// failure in `fallocate`, `dd`, `mkswap` or `swapon` left the host with no swap at all.
|
/// failure in `fallocate`, `dd`, `mkswap` or `swapon` left the host with no swap at all.
|
||||||
pub fn ensure_swap(cfg: &Config) -> anyhow::Result<()> {
|
pub fn ensure_swap(cfg: &Config) -> anyhow::Result<()> {
|
||||||
|
// Taken before the enabled check: the disabled branch removes managed files, which is
|
||||||
|
// a mutation like any other. Leaving it outside the lock let a manual CLI run and the
|
||||||
|
// daemon's reconcile write and delete the same file at the same time.
|
||||||
|
let _lock = FileLock::acquire_timeout("/run/awg2-harness/swap.lock", Duration::from_secs(30))?;
|
||||||
|
|
||||||
if !cfg.swap.enabled {
|
if !cfg.swap.enabled {
|
||||||
return remove_managed_swap_state();
|
return remove_managed_swap_state();
|
||||||
}
|
}
|
||||||
|
|
||||||
let _lock = FileLock::acquire_timeout("/run/awg2-harness/swap.lock", Duration::from_secs(30))?;
|
|
||||||
|
|
||||||
// 1. Preflight: nothing is modified here.
|
// 1. Preflight: nothing is modified here.
|
||||||
let desired = DesiredSwap::from_config(cfg)?;
|
let desired = DesiredSwap::from_config(cfg)?;
|
||||||
assert_swap_path_is_safe(&desired.path)?;
|
assert_swap_path_is_safe(&desired.path)?;
|
||||||
@@ -614,16 +629,50 @@ fn parse_swapon_output(raw: &str) -> anyhow::Result<Vec<ActiveSwap>> {
|
|||||||
/// Destroying a swapfile an administrator created is not this package's call: `swapoff`
|
/// Destroying a swapfile an administrator created is not this package's call: `swapoff`
|
||||||
/// plus removal from `/etc/fstab` already achieves the exclusive runtime state, and the
|
/// plus removal from `/etc/fstab` already achieves the exclusive runtime state, and the
|
||||||
/// file can be removed by hand once the operator agrees.
|
/// file can be removed by hand once the operator agrees.
|
||||||
|
/// Takes foreign swap offline, restoring what it already disabled if a later one fails.
|
||||||
|
///
|
||||||
|
/// Without the rollback a host with two foreign swaps could end up with the first one
|
||||||
|
/// disabled and the second still up, because the error aborted the loop and nothing put
|
||||||
|
/// the first back. That is a state neither the operator nor the next reconcile asked for.
|
||||||
fn disable_foreign_swaps(desired: &DesiredSwap, active: &[ActiveSwap]) -> anyhow::Result<()> {
|
fn disable_foreign_swaps(desired: &DesiredSwap, active: &[ActiveSwap]) -> anyhow::Result<()> {
|
||||||
|
let mut disabled: Vec<&ActiveSwap> = Vec::new();
|
||||||
for entry in active {
|
for entry in active {
|
||||||
if entry.name == desired.path {
|
if entry.name == desired.path {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
run("swapoff", &[entry.name.to_string_lossy().as_ref()])?;
|
if let Err(e) = run("swapoff", &[entry.name.to_string_lossy().as_ref()]) {
|
||||||
|
reenable_swaps(&disabled);
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"failed to disable foreign swap {}: {e}",
|
||||||
|
entry.name.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
disabled.push(entry);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Best-effort restore after a failed teardown: report what could not be brought back
|
||||||
|
/// rather than masking it, but never turn the rollback itself into the reported error.
|
||||||
|
fn reenable_swaps(entries: &[&ActiveSwap]) {
|
||||||
|
for entry in entries.iter().rev() {
|
||||||
|
if let Err(e) = run(
|
||||||
|
"swapon",
|
||||||
|
&[
|
||||||
|
"-p",
|
||||||
|
&entry.priority.to_string(),
|
||||||
|
entry.name.to_string_lossy().as_ref(),
|
||||||
|
],
|
||||||
|
) {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "swap",
|
||||||
|
"could not re-enable {} while rolling back: {e}",
|
||||||
|
entry.name.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Creates or validates the managed swapfile without ever touching an unowned one.
|
/// Creates or validates the managed swapfile without ever touching an unowned one.
|
||||||
///
|
///
|
||||||
/// `active` is needed because a live swapfile cannot be resized in place: if the managed
|
/// `active` is needed because a live swapfile cannot be resized in place: if the managed
|
||||||
@@ -648,39 +697,119 @@ fn ensure_desired_swapfile(desired: &DesiredSwap, active: &[ActiveSwap]) -> anyh
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
let desired_active = active.iter().find(|s| s.name == *p);
|
let active_entry = active.iter().find(|s| s.name == *p);
|
||||||
let geometry_ok = desired_active
|
let size_ok = ownership == SwapfileOwnership::ManagedValid;
|
||||||
.map(|s| {
|
|
||||||
active_swap_size_matches(s.size_bytes, desired.size_bytes)
|
|
||||||
&& s.priority == desired.priority
|
|
||||||
})
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if ownership == SwapfileOwnership::ManagedValid && (desired_active.is_none() || geometry_ok) {
|
match (size_ok, active_entry) {
|
||||||
set_swapfile_mode(p)?;
|
// Right file, right size, already active with the right priority: nothing to do.
|
||||||
drop_legacy_ownership_sidecar(p);
|
(true, Some(entry))
|
||||||
return Ok(());
|
if active_swap_size_matches(entry.size_bytes, desired.size_bytes)
|
||||||
|
&& entry.priority == desired.priority =>
|
||||||
|
{
|
||||||
|
set_swapfile_mode(p)?;
|
||||||
|
drop_legacy_ownership_sidecar(p);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
// Right file, right size, but the active priority differs. Re-activating is
|
||||||
|
// enough; the previous code fell through to a full recreate, so changing
|
||||||
|
// swap.priority destroyed and rebuilt a perfectly good multi-gigabyte file.
|
||||||
|
(true, Some(_)) => {
|
||||||
|
run("swapoff", &[p.to_string_lossy().as_ref()])?;
|
||||||
|
set_swapfile_mode(p)?;
|
||||||
|
drop_legacy_ownership_sidecar(p);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
// Right file, not active: the caller activates it next.
|
||||||
|
(true, None) => {
|
||||||
|
set_swapfile_mode(p)?;
|
||||||
|
drop_legacy_ownership_sidecar(p);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
// Wrong size or no file at all, and nothing of ours is active: build in place.
|
||||||
|
(false, None) => {
|
||||||
|
if ownership.is_managed() {
|
||||||
|
fs::remove_file(p)
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to remove {}: {e}", p.display()))?;
|
||||||
|
}
|
||||||
|
build_swapfile(p, desired.size_bytes)?;
|
||||||
|
record_ownership(p)?;
|
||||||
|
drop_legacy_ownership_sidecar(p);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
// Wrong size while our swap is live: replace it through a staging file.
|
||||||
|
(false, Some(_)) => replace_active_swapfile(desired),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resizes the managed swapfile while it is active, without a swapless gap during the
|
||||||
|
/// slow part.
|
||||||
|
///
|
||||||
|
/// A live swapfile cannot be resized in place, so it has to be replaced. Doing that as
|
||||||
|
/// swapoff-then-create left the host with no swap for the whole duration of
|
||||||
|
/// `fallocate`/`dd`/`mkswap` - on a machine that was given swap precisely because memory
|
||||||
|
/// is tight, and with no way back if any of those steps failed. The allocation now happens
|
||||||
|
/// while the old swap is still serving, and the only gap left is the swapoff/rename/swapon
|
||||||
|
/// triple, which allocates nothing.
|
||||||
|
fn replace_active_swapfile(desired: &DesiredSwap) -> anyhow::Result<()> {
|
||||||
|
let p = &desired.path;
|
||||||
|
let staging = staging_path(p)?;
|
||||||
|
|
||||||
|
match fs::remove_file(&staging) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(e) => anyhow::bail!("failed to clear stale {}: {e}", staging.display()),
|
||||||
}
|
}
|
||||||
|
|
||||||
if desired_active.is_some() {
|
// Built and activated while the old swap is still up.
|
||||||
run("swapoff", &[p.to_string_lossy().as_ref()])?;
|
build_swapfile(&staging, desired.size_bytes)?;
|
||||||
|
if let Err(e) = run(
|
||||||
|
"swapon",
|
||||||
|
&[
|
||||||
|
"-p",
|
||||||
|
&desired.priority.to_string(),
|
||||||
|
staging.to_string_lossy().as_ref(),
|
||||||
|
],
|
||||||
|
) {
|
||||||
|
let _ = fs::remove_file(&staging);
|
||||||
|
return Err(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ownership.is_managed() {
|
// The old file is only released once the replacement is carrying load.
|
||||||
fs::remove_file(p).map_err(|e| anyhow::anyhow!("failed to remove {}: {e}", p.display()))?;
|
run("swapoff", &[p.to_string_lossy().as_ref()])?;
|
||||||
}
|
fs::remove_file(p).map_err(|e| anyhow::anyhow!("failed to remove {}: {e}", p.display()))?;
|
||||||
|
|
||||||
create_swapfile(p, desired.size_bytes)?;
|
// Short gap: no allocation happens between here and the swapon below.
|
||||||
// Restrictive mode before mkswap, so the file is never briefly world-readable while
|
run("swapoff", &[staging.to_string_lossy().as_ref()])?;
|
||||||
// it already contains swap structure.
|
fs::rename(&staging, p).map_err(|e| {
|
||||||
set_swapfile_mode(p)?;
|
anyhow::anyhow!(
|
||||||
run("mkswap", &[p.to_string_lossy().as_ref()])?;
|
"failed to move {} into place at {}: {e}; activate it manually with swapon",
|
||||||
set_swapfile_mode(p)?;
|
staging.display(),
|
||||||
|
p.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
record_ownership(p)?;
|
record_ownership(p)?;
|
||||||
drop_legacy_ownership_sidecar(p);
|
drop_legacy_ownership_sidecar(p);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn staging_path(path: &Path) -> anyhow::Result<PathBuf> {
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("invalid swap.path: {}", path.display()))?;
|
||||||
|
let mut staging = name.to_os_string();
|
||||||
|
staging.push(".awg2-harness.new");
|
||||||
|
Ok(path.with_file_name(staging))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allocates a swapfile and formats it, with the restrictive mode applied before `mkswap`
|
||||||
|
/// so the file is never briefly world-readable while it already holds swap structure.
|
||||||
|
fn build_swapfile(path: &Path, size_bytes: u64) -> anyhow::Result<()> {
|
||||||
|
create_swapfile(path, size_bytes)?;
|
||||||
|
set_swapfile_mode(path)?;
|
||||||
|
run("mkswap", &[path.to_string_lossy().as_ref()])?;
|
||||||
|
set_swapfile_mode(path)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
fn set_swapfile_mode(path: &Path) -> anyhow::Result<()> {
|
fn set_swapfile_mode(path: &Path) -> anyhow::Result<()> {
|
||||||
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
|
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
|
||||||
@@ -1170,6 +1299,67 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn active(name: &str, size_bytes: u64, priority: i32) -> ActiveSwap {
|
||||||
|
ActiveSwap {
|
||||||
|
name: PathBuf::from(name),
|
||||||
|
kind: "file".to_string(),
|
||||||
|
size_bytes,
|
||||||
|
priority,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Changing only `swap.priority` used to fall through to a full recreate, destroying
|
||||||
|
/// and rebuilding a correct multi-gigabyte file. It is a reactivation, nothing more.
|
||||||
|
#[test]
|
||||||
|
fn priority_only_change_does_not_need_a_new_file() {
|
||||||
|
let desired = DesiredSwap {
|
||||||
|
path: PathBuf::from("/swapfile"),
|
||||||
|
size_bytes: 2048 * 1024 * 1024,
|
||||||
|
priority: 5,
|
||||||
|
swappiness: 1,
|
||||||
|
};
|
||||||
|
let entry = active("/swapfile", desired.size_bytes, 1);
|
||||||
|
|
||||||
|
// Same size, different priority: the file is fine, only the activation is wrong.
|
||||||
|
assert!(active_swap_size_matches(
|
||||||
|
entry.size_bytes,
|
||||||
|
desired.size_bytes
|
||||||
|
));
|
||||||
|
assert_ne!(entry.priority, desired.priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn staging_path_is_a_sibling_of_the_swapfile() {
|
||||||
|
let staging = staging_path(Path::new("/var/lib/awg2-harness/swapfile")).expect("staging");
|
||||||
|
assert_eq!(
|
||||||
|
staging,
|
||||||
|
PathBuf::from("/var/lib/awg2-harness/swapfile.awg2-harness.new")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Foreign swaps are identified by not being the managed path; the managed one is
|
||||||
|
/// never included in the teardown set.
|
||||||
|
#[test]
|
||||||
|
fn managed_swap_is_never_in_the_foreign_set() {
|
||||||
|
let desired = DesiredSwap {
|
||||||
|
path: PathBuf::from("/swapfile"),
|
||||||
|
size_bytes: 1024,
|
||||||
|
priority: 1,
|
||||||
|
swappiness: 1,
|
||||||
|
};
|
||||||
|
let entries = [
|
||||||
|
active("/swapfile", 1024, 1),
|
||||||
|
active("/dev/sda2", 4096, -2),
|
||||||
|
active("/other/swap", 2048, 0),
|
||||||
|
];
|
||||||
|
let foreign: Vec<_> = entries
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.name != desired.path)
|
||||||
|
.map(|e| e.name.display().to_string())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(foreign, vec!["/dev/sda2", "/other/swap"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ownership_rejects_non_regular_file() {
|
fn ownership_rejects_non_regular_file() {
|
||||||
let dir = scratch("own_dir");
|
let dir = scratch("own_dir");
|
||||||
|
|||||||
@@ -44,16 +44,18 @@ struct RenderPlan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn ensure_sysctl_tuning(cfg: &Config) -> anyhow::Result<()> {
|
pub fn ensure_sysctl_tuning(cfg: &Config) -> anyhow::Result<()> {
|
||||||
if !cfg.sysctl_tuning.enabled {
|
// Taken before the enabled check: removing the managed files is a mutation and must
|
||||||
remove_managed_sysctl_files_if_disabled(cfg)?;
|
// not race a concurrent apply.
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let _lock = FileLock::acquire_timeout(
|
let _lock = FileLock::acquire_timeout(
|
||||||
"/run/awg2-harness/sysctl_tuning.lock",
|
"/run/awg2-harness/sysctl_tuning.lock",
|
||||||
Duration::from_secs(30),
|
Duration::from_secs(30),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
if !cfg.sysctl_tuning.enabled {
|
||||||
|
remove_managed_sysctl_files_if_disabled(cfg)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let plan = build_render_plan(cfg, true)?;
|
let plan = build_render_plan(cfg, true)?;
|
||||||
for w in &plan.warnings {
|
for w in &plan.warnings {
|
||||||
tracing::warn!(target: "sysctl_tuning", "{w}");
|
tracing::warn!(target: "sysctl_tuning", "{w}");
|
||||||
|
|||||||
Reference in New Issue
Block a user