Merge watchdog idunn-topology-lock: no tick waits on another uid's lock
Eureka idunn-watchdog cut idunn-topology-lock, attempts 1-2. Soul verdict idunn-watchdog:verdict:cut-idunn-topology-lock.s2 holds. Topology and incident writes share one private-lock exchange that replaces a non-private lock by rename; a contended publish stays in phase and retries. Measured tick with a held lock: 0.09 s (was 5.07 s). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
cb6096512b
5 changed files with 803 additions and 133 deletions
|
|
@ -77,7 +77,10 @@ ReadWritePaths=/etc/ufw
|
|||
# idunn-projection is deliberately outside Idunn's own 0750 directory: the
|
||||
# topology store is Idunn's *published* surface, and every managed target must
|
||||
# read it to verify its own Expected incarnation against the Idunn anchor. It
|
||||
# was inside the private root, where no workload could traverse to it.
|
||||
# was inside the private root, where no workload could traverse to it. The
|
||||
# published topology store is read lock-free (Odin) and incidents.cc is read by
|
||||
# Bifrost's reader; managed targets get Expected from their runtime bundle. Only
|
||||
# the stores are readable: each .lock beside them is Idunn's alone, 0600.
|
||||
ReadWritePaths=/var/lib/gamecult/idunn /var/lib/gamecult/idunn-authority /var/lib/gamecult/idunn-projection
|
||||
ReadWritePaths=/srv/build
|
||||
# Each managed target gets only its bound roots. A target Idunn cannot write is
|
||||
|
|
|
|||
|
|
@ -520,7 +520,7 @@ started, ran, and then died on a file it could not open:
|
|||
| `runtime_root` and every directory above it | traversable by the workload: `o+x`, or group-owned by the state group with `g+x` | the workload is a `DynamicUser` and owns nothing, and `ReadOnlyPaths=` binds the bundle's leaf into the namespace without granting traversal above it. Idunn now checks the whole chain and names the closed directory |
|
||||
| `runtime_root` | setgid, group-owned by the state group (`2750 root:<state_group>`) | `harden_root_authority_file` requires the write-lease record's group to equal its parent's, and only setgid makes a newly created record inherit it. Without it the record lands `root:root` and the workload cannot read its own lease |
|
||||
| the write-lease record | `root:<state_group>`, mode `0640` | follows from the setgid parent; the workload reads it through the state group. Idunn writes it, so fixing an existing wrong-group record means deleting it, and that is safe only when no transaction is live |
|
||||
| `/var/lib/gamecult/idunn-projection` | default ACL `u::rw-,g::r--,o::r--` | Idunn runs with `UMask=027`, so each publish creates the store and its `.lock` at `0640`. The code re-opens them after every write, but that leaves a window; a default ACL sets the mode at creation and closes it |
|
||||
| `/var/lib/gamecult/idunn-projection` | default ACL `u::rw-,g::r--,o::r--` | Idunn runs with `UMask=027`, so a store is created `0640` and each publish re-opens it `0644`; the default ACL sets the mode at creation and closes that window for stores. The `.lock` is not published and no reader opens it: Idunn creates it `0600` (replacing one found wider, so a descriptor another uid already holds is orphaned), and the ACL never decides its mode |
|
||||
| `/etc/nginx/idunn-stream-routes` | `root:root`, not group-writable | "route authority parent is not canonical root-owned and nonwritable". It was `root:idunn 0775` from the generation where Idunn ran unprivileged and needed to write it as a group member |
|
||||
|
||||
Two shapes are worth stating plainly because they are easy to get backwards.
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ use crate::deployment_plan::{
|
|||
use crate::drivers::{
|
||||
ChallengeFailure, CultCacheTopologyDriver, CultCacheWriteLeaseDriver, DockerRunnerDriver, FrozenSourceReceipt,
|
||||
GitSourceDriver, InstalledReleaseObservation, IsolationEvidence, NginxRouteDriver,
|
||||
ProcessIdentity, RouteActuation, RouteActuationGate, RouteActuationRefused, RouteActuators,
|
||||
LockContended, ProcessIdentity, RouteActuation, RouteActuationGate, RouteActuationRefused, RouteActuators,
|
||||
RouteObservation, RoutePreflightReceipt, RunnerPort, SourcePort,
|
||||
SystemdTransientWorkloadDriver, TopologyPort, WorkloadObservation, WorkloadPort,
|
||||
WriteLeasePort,
|
||||
|
|
@ -5576,7 +5576,14 @@ impl Engine {
|
|||
// Before the fence an abort that cannot finish a step is
|
||||
// resumable, never a post-fence abort: that path refuses a
|
||||
// pre-fence phase, so choosing it only wedged the tick.
|
||||
if latest.value.pre_fencing_abort.is_none() {
|
||||
//
|
||||
// A publish that lost to a held lock is not a failed step: the
|
||||
// holder lets go, so the transaction stays in its phase with
|
||||
// the error recorded and the backoff above retries it. The
|
||||
// phase deadline, not an abort, bounds the wait.
|
||||
if latest.value.pre_fencing_abort.is_none()
|
||||
&& error.downcast_ref::<LockContended>().is_none()
|
||||
{
|
||||
self.begin_pre_fencing_abort(latest, error)?;
|
||||
} else {
|
||||
self.record_resumable_error(latest, &error)?;
|
||||
|
|
@ -15496,7 +15503,7 @@ mod tests {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn a_held_incident_lock_changes_nothing_continuity_decides() -> Result<()> {
|
||||
use crate::incident::tests::{STUCK_AFTER, hold_lock_of, run_while_held};
|
||||
|
|
@ -15510,7 +15517,8 @@ mod tests {
|
|||
let world = EngineFixture::with_workload(workload.clone())?;
|
||||
admit_incumbent(&world)?;
|
||||
let path = incident_path(&world);
|
||||
let actual = run_while_held(hold_lock_of(&path), || {
|
||||
// Three ticks reach the open of an exhausted target, one exchange each.
|
||||
let actual = run_while_held(hold_lock_of(&path), 3, || {
|
||||
continuity_decisions(&world, &world.engine, &workload)
|
||||
})?;
|
||||
assert_eq!(actual, expected, "a held incident lock changed a continuity decision");
|
||||
|
|
@ -15540,6 +15548,171 @@ mod tests {
|
|||
assert_control_store_holds_no_incident(&world)
|
||||
}
|
||||
|
||||
/// A publish that could not land is not done, at the tick: the demotion
|
||||
/// continuity needs before a restart is one exchange on the topology lock,
|
||||
/// and another holder of it defers the restart instead of waiting for it.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn a_held_topology_lock_defers_continuity_and_mints_nothing() -> Result<()> {
|
||||
use crate::incident::tests::{hold_lock_of, run_while_held};
|
||||
|
||||
let workload = SwitchWorkload::new();
|
||||
let world = EngineFixture::with_workload(workload.clone())?;
|
||||
let incumbent = admit_incumbent(&world)?;
|
||||
project_admitted(&world, &incumbent)?;
|
||||
let store = world.engine.options.topology_store.clone();
|
||||
let projected = projected_under(&world, &incumbent.expected)?;
|
||||
assert!(projected.contains(&IdunnRuntimeActivationRecord::TYPE.to_owned()));
|
||||
let before = std::fs::read(&store)?;
|
||||
workload.kill();
|
||||
|
||||
// One exchange: the demotion. The window is not spent, so continuity
|
||||
// would restart if the projection could be demoted.
|
||||
let moved = run_while_held(hold_lock_of(&store), 1, || {
|
||||
world.engine.supervise_one_admitted_generation()
|
||||
})?;
|
||||
assert!(moved, "the deferral is a write the tick made");
|
||||
assert_eq!(std::fs::read(&store)?, before, "a contended publish changed the projection");
|
||||
let snapshot = ControlSnapshot::read(&world.state_store)?;
|
||||
assert!(snapshot.transactions.is_empty(), "continuity minted over a projection it could not demote");
|
||||
let recorded = snapshot.supervision_for("service").context("no deferral recorded")?.value.clone();
|
||||
assert!(recorded.continuity_restarts.is_empty(), "a deferral spent an attempt");
|
||||
let reason = recorded.continuity_deferral_reason.clone().context("no deferral reason")?;
|
||||
assert!(reason.contains("locked by another holder"), "{reason}");
|
||||
assert!(!reason.contains(&world.root.display().to_string()), "{reason}");
|
||||
|
||||
// The holder is gone and the deferral has run out: the demotion lands
|
||||
// and the restart proceeds.
|
||||
let mut ran_out = recorded;
|
||||
ran_out.continuity_deferred_until = Some(1);
|
||||
set_meters(&world, &ran_out)?;
|
||||
assert!(world.engine.supervise_one_admitted_generation()?);
|
||||
assert_eq!(ControlSnapshot::read(&world.state_store)?.transactions.len(), 1);
|
||||
assert_eq!(projected_under(&world, &incumbent.expected)?, expected_only());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A deployment phase records a publication digest only after the publish
|
||||
/// returned: the digest is the claim that it landed.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn a_contended_expected_publish_is_not_recorded_as_published() -> Result<()> {
|
||||
use crate::incident::tests::{hold_lock_of, run_while_held};
|
||||
|
||||
let world = EngineFixture::with_workload(Arc::new(StillWorkload))?;
|
||||
let seeded = transaction_at(&world, DeploymentPhase::Warming)?;
|
||||
let stored = resident(&world)?;
|
||||
let mut starting = seeded.clone();
|
||||
starting.phase = DeploymentPhase::Starting;
|
||||
starting.expected_publication_sha256 = None;
|
||||
starting.activation = None;
|
||||
starting.workload = None;
|
||||
starting.activation_publication_sha256 = None;
|
||||
starting.warming = None;
|
||||
replace_transaction(&world.state_store, &stored, &starting)?;
|
||||
let store = world.engine.options.topology_store.clone();
|
||||
let stored = resident(&world)?;
|
||||
|
||||
let error = run_while_held(hold_lock_of(&store), 1, || {
|
||||
world.engine.advance_transaction(&stored)
|
||||
})
|
||||
.expect_err("a contended publish must not advance the phase");
|
||||
assert!(format!("{error:#}").contains("locked by another holder"), "{error:#}");
|
||||
assert!(!store.exists(), "a contended publish wrote the projection");
|
||||
let after = resident(&world)?.value;
|
||||
assert_eq!(after.phase, DeploymentPhase::Starting);
|
||||
assert_eq!(after.expected_publication_sha256, None);
|
||||
|
||||
// Free again: the same step publishes and records what it published.
|
||||
world.engine.advance_transaction(&resident(&world)?)?;
|
||||
let expected = seeded.expected.context("seeded transaction has no Expected")?;
|
||||
assert_eq!(
|
||||
resident(&world)?.value.expected_publication_sha256,
|
||||
Some(expected.canonical_sha256()?)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A Continuity transaction parked in Starting with nothing published yet,
|
||||
/// plus the stored record the tick will see.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parked_in_starting(world: &EngineFixture) -> Result<DeploymentTransaction> {
|
||||
let seeded = transaction_at(world, DeploymentPhase::Warming)?;
|
||||
let stored = resident(world)?;
|
||||
let mut starting = seeded.clone();
|
||||
starting.phase = DeploymentPhase::Starting;
|
||||
starting.expected_publication_sha256 = None;
|
||||
starting.activation = None;
|
||||
starting.workload = None;
|
||||
starting.activation_publication_sha256 = None;
|
||||
starting.warming = None;
|
||||
replace_transaction(&world.state_store, &stored, &starting)?;
|
||||
Ok(seeded)
|
||||
}
|
||||
|
||||
/// A publish that lost to a held lock is transient: before the fence the
|
||||
/// transaction stays in its phase with the error recorded, no abort is
|
||||
/// begun, and the next tick after the release lands the publish. A
|
||||
/// continuity restart is a Continuity transaction on this same path, so
|
||||
/// its attempt is minted once and a contended publish spends no second one.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn a_contended_starting_publish_is_retried_on_a_later_tick_not_aborted() -> Result<()> {
|
||||
use crate::incident::tests::{hold_lock_of, run_while_held};
|
||||
|
||||
let world = EngineFixture::with_workload(Arc::new(StillWorkload))?;
|
||||
let seeded = parked_in_starting(&world)?;
|
||||
let mut meters = TargetSupervision::new("service");
|
||||
meters.continuity_restarts = vec![now_millis()?];
|
||||
set_meters(&world, &meters)?;
|
||||
let store = world.engine.options.topology_store.clone();
|
||||
|
||||
for tick in 0..2 {
|
||||
run_while_held(hold_lock_of(&store), 1, || world.engine.resume_one_transaction())?;
|
||||
world.engine.resume_backoff.lock().unwrap().clear();
|
||||
let held = resident(&world)?.value;
|
||||
assert_eq!(held.phase, DeploymentPhase::Starting, "tick {tick}");
|
||||
assert!(held.pre_fencing_abort.is_none(), "tick {tick}: a held lock aborted the transaction");
|
||||
assert!(!held.is_terminal(), "tick {tick}");
|
||||
assert_eq!(held.expected_publication_sha256, None, "tick {tick}");
|
||||
let error = held.last_error.context("a contended publish left no last_error")?;
|
||||
assert!(error.contains("locked by another holder"), "{error}");
|
||||
assert!(!store.exists(), "a contended publish wrote the projection");
|
||||
}
|
||||
|
||||
// Released: the next tick lands the publish and records its digest.
|
||||
world.engine.resume_one_transaction()?;
|
||||
let landed = resident(&world)?.value;
|
||||
assert_eq!(landed.phase, DeploymentPhase::Starting);
|
||||
assert!(landed.pre_fencing_abort.is_none());
|
||||
assert_eq!(
|
||||
landed.expected_publication_sha256,
|
||||
Some(seeded.expected.context("seeded transaction has no Expected")?.canonical_sha256()?)
|
||||
);
|
||||
assert_eq!(meters_of(&world, "service")?.continuity_restarts.len(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Only contention is transient: a publish that fails for any other reason
|
||||
/// still ends the transaction before the fence, as it always did.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn a_non_contention_publish_error_still_aborts_before_the_fence() -> Result<()> {
|
||||
let world = EngineFixture::with_workload(Arc::new(StillWorkload))?;
|
||||
parked_in_starting(&world)?;
|
||||
// A directory where the store should be: the snapshot cannot be read.
|
||||
std::fs::create_dir_all(&world.engine.options.topology_store)?;
|
||||
world.engine.resume_one_transaction()?;
|
||||
let after = resident(&world)?.value;
|
||||
assert!(after.pre_fencing_abort.is_some(), "a non-contention error did not abort");
|
||||
assert!(
|
||||
!after.last_error.as_deref().unwrap_or_default().contains("locked by another holder"),
|
||||
"{:?}",
|
||||
after.last_error
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_prints_an_open_incident_under_its_target() -> Result<()> {
|
||||
let workload = SwitchWorkload::new();
|
||||
|
|
|
|||
486
src/drivers.rs
486
src/drivers.rs
|
|
@ -4247,6 +4247,10 @@ impl WriteLeasePort for CultCacheWriteLeaseDriver {
|
|||
/// facts here. Service presence is absent by construction; only Odin may
|
||||
/// correlate these records with signed runtime observation into Present/Ready.
|
||||
///
|
||||
/// The store is published 0644 and read lock-free by Odin and Idunn; its lock
|
||||
/// is Idunn's alone (`exchange_behind_private_lock`), so no other uid can hold
|
||||
/// it and stall a publish.
|
||||
///
|
||||
/// Every record is keyed by the incarnation it describes, never by the target
|
||||
/// alone. A target being replaced has two incarnations at once -- the admitted
|
||||
/// incumbent and the sealed candidate -- and both are projected side by side.
|
||||
|
|
@ -4390,22 +4394,28 @@ impl CultCacheTopologyDriver {
|
|||
/// Apply one whole-snapshot mutation with compare-and-swap. `mutate`
|
||||
/// returns `None` when the snapshot already has the shape it wants, and
|
||||
/// the replacement set otherwise; every writer below is one of these.
|
||||
///
|
||||
/// Never waits: a publish is done exactly when this returns `Ok`. Only a
|
||||
/// lost race (the store moved on) is retried at once; a held lock is an
|
||||
/// error and a no-op, retried by the caller's own retry.
|
||||
fn mutate<F>(&self, mutate: F) -> Result<()>
|
||||
where
|
||||
F: Fn(&[CultCacheEnvelope]) -> Result<Option<Vec<CultCacheEnvelope>>>,
|
||||
{
|
||||
if let Some(parent) = self.projection_store.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let store = SingleFileMessagePackBackingStore::new(&self.projection_store);
|
||||
for _ in 0..8 {
|
||||
let entries = self.snapshot()?;
|
||||
let Some(replacement) = mutate(&entries)? else {
|
||||
return Ok(());
|
||||
};
|
||||
if store.compare_exchange_snapshot(&entries, &replacement)? {
|
||||
publish_projection_mode(&self.projection_store)?;
|
||||
return Ok(());
|
||||
match exchange_behind_private_lock(&self.projection_store, &entries, &replacement)? {
|
||||
TryCompareExchangeSnapshotOutcome::Exchanged => {
|
||||
publish_file_mode(&self.projection_store)?;
|
||||
return Ok(());
|
||||
}
|
||||
TryCompareExchangeSnapshotOutcome::Mismatch => {}
|
||||
TryCompareExchangeSnapshotOutcome::LockContended => {
|
||||
return Err(LockContended { store: "topology projection" }.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
bail!("CultCache projection changed repeatedly during publication")
|
||||
|
|
@ -5097,6 +5107,22 @@ pub enum RouteActuation {
|
|||
Survival,
|
||||
}
|
||||
|
||||
/// A publish that lost to a held projection-directory lock. Transient by
|
||||
/// kind: the holder lets go, and the caller's own retry decides what a later
|
||||
/// attempt does. It names the store kind, never its path.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct LockContended {
|
||||
pub(crate) store: &'static str,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LockContended {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(formatter, "the {} is locked by another holder", self.store)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for LockContended {}
|
||||
|
||||
/// A forward route actuation the ceiling refused. `reopens_at_unix_millis` is
|
||||
/// when the oldest counted actuation leaves the window.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
|
@ -7710,19 +7736,95 @@ pub(crate) fn publish_file_mode(path: &Path) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// The topology store is Idunn's *published* surface: every managed target
|
||||
/// reads it to verify its own Expected incarnation against the Idunn anchor.
|
||||
/// The one write primitive for a store in the projection directory: one
|
||||
/// nonblocking compare-exchange of the whole store against the snapshot it was
|
||||
/// decided on, behind a lock only Idunn can hold.
|
||||
///
|
||||
/// Idunn runs with `UMask=027`, which is right for its private state and wrong
|
||||
/// for this one file -- it lands `0640 root:root`, and a `DynamicUser` workload
|
||||
/// gets EACCES. Integrity here comes from the signatures over the records, not
|
||||
/// from the mode, so the published copy is readable. The lock sibling too:
|
||||
/// CultCache opens it alongside the store, so a 0640 lock denies the read just
|
||||
/// as surely as a 0640 store, and it is created fresh under Idunn's umask on
|
||||
/// every publish.
|
||||
fn publish_projection_mode(path: &Path) -> Result<()> {
|
||||
publish_file_mode(path)?;
|
||||
publish_file_mode(&authority_lock_path(path))
|
||||
/// Readers of a published store take no lock, so nobody but Idunn needs to
|
||||
/// open its lock, and a lock another uid can open is a lock another uid can
|
||||
/// hold to stall every publish. CultCache creates a lock with mode 0666, so
|
||||
/// the directory's default ACL would decide who may (Yggdrasil's lands it
|
||||
/// 0644). This makes the lock private first, by this rule: an absent lock is
|
||||
/// created `O_EXCL` 0600; a lock that is not (a regular file, ours, 0600) is
|
||||
/// replaced by a fresh 0600 file renamed over it, because a chmod does not
|
||||
/// revoke a descriptor another uid already holds, while the rename leaves that
|
||||
/// holder only an orphaned inode. A private lock is opened as it is. No
|
||||
/// existing lock is ever chmodded. Nothing here names a path in an error.
|
||||
pub(crate) fn exchange_behind_private_lock(
|
||||
store: &Path,
|
||||
expected: &[CultCacheEnvelope],
|
||||
replacements: &[CultCacheEnvelope],
|
||||
) -> Result<TryCompareExchangeSnapshotOutcome> {
|
||||
make_lock_private(store)?;
|
||||
SingleFileMessagePackBackingStore::new(store)
|
||||
.try_compare_exchange_snapshot(expected, replacements)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn make_lock_private(store: &Path) -> Result<()> {
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
|
||||
let lock = authority_lock_path(store);
|
||||
if let Some(parent) = lock.parent() {
|
||||
fs::create_dir_all(parent).context("creating the store directory")?;
|
||||
}
|
||||
// SAFETY: geteuid has no preconditions.
|
||||
let euid = unsafe { libc::geteuid() };
|
||||
// The daemon is the only writer, so a lost create race is defensive only.
|
||||
for _ in 0..3 {
|
||||
match fs::symlink_metadata(&lock) {
|
||||
Ok(metadata)
|
||||
if metadata.is_file()
|
||||
&& metadata.uid() == euid
|
||||
&& metadata.permissions().mode() & 0o777 == 0o600 =>
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
Ok(_) => return replace_with_private_lock(&lock),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => {
|
||||
match create_private_file(&lock) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(error) if error.kind() == ErrorKind::AlreadyExists => {}
|
||||
Err(error) => return Err(error).context("creating a private store lock"),
|
||||
}
|
||||
}
|
||||
Err(error) => return Err(error).context("inspecting a store lock"),
|
||||
}
|
||||
}
|
||||
bail!("a store lock kept changing while it was made private")
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn make_lock_private(store: &Path) -> Result<()> {
|
||||
let _ = store;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A new file nothing else has open, mode 0600 (the create mode is intersected
|
||||
/// with the umask and the directory's default ACL, which only narrow it).
|
||||
#[cfg(unix)]
|
||||
fn create_private_file(path: &Path) -> std::io::Result<()> {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.map(drop)
|
||||
}
|
||||
|
||||
/// Stage a fresh private file beside `lock` and rename it over the lock.
|
||||
#[cfg(unix)]
|
||||
fn replace_with_private_lock(lock: &Path) -> Result<()> {
|
||||
let mut staged = lock.as_os_str().to_os_string();
|
||||
staged.push(format!(".{}", Uuid::new_v4().simple()));
|
||||
let staged = PathBuf::from(staged);
|
||||
create_private_file(&staged).context("staging a private store lock")?;
|
||||
fs::rename(&staged, lock).map_err(|error| {
|
||||
let _ = fs::remove_file(&staged);
|
||||
anyhow::Error::new(error).context("replacing a store lock with a private one")
|
||||
})
|
||||
}
|
||||
|
||||
fn build_machine_id(workspace: &Path) -> Result<String> {
|
||||
|
|
@ -9670,27 +9772,335 @@ Content-Le".to_vec()), |_| {}),
|
|||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_published_projection_is_readable_by_a_workload() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
/// A native process that is the Expected's: what `publish_observed_activation`
|
||||
/// checks the activation against.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn observation_of(
|
||||
expected: &IdunnExpectedIncarnationRecord,
|
||||
activation: &IdunnRuntimeActivationRecord,
|
||||
) -> WorkloadObservation {
|
||||
let mut host = host_audit_observation(1, 1);
|
||||
host.runtime_instance_id = activation.runtime_instance_id.clone();
|
||||
host.executable_sha256 = expected.artifact_sha256.clone();
|
||||
WorkloadObservation::Host(host)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn mode_of(path: &Path) -> u32 {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::metadata(path).unwrap().permissions().mode() & 0o777
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn inode_of(path: &Path) -> u64 {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
fs::metadata(path).unwrap().ino()
|
||||
}
|
||||
|
||||
/// Idunn's `UMask=027` alone lands a file 0640: the store still has to be
|
||||
/// published, and its lock has to stay private.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn a_published_store_is_readable_by_a_workload_and_its_lock_is_not() -> Result<()> {
|
||||
use crate::incident::tests::under_service_umask;
|
||||
|
||||
let temp = tempfile::tempdir()?;
|
||||
let (expected, _, _, anchor) = authenticated_warming(temp.path())?;
|
||||
let driver = topology_driver(temp.path(), "topology");
|
||||
under_service_umask(|| driver.publish_expected(&expected, &anchor))?;
|
||||
assert_eq!(mode_of(&driver.projection_store), 0o644);
|
||||
assert_eq!(mode_of(&authority_lock_path(&driver.projection_store)), 0o600);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The body's condition: the projection directory carries the default ACL
|
||||
/// `u::rw g::r o::r`, under which the umask is ignored and a lock CultCache
|
||||
/// creates lands 0644. Every writer publishes the store 0644 and leaves
|
||||
/// the lock 0600, and another uid cannot open the lock.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn under_a_default_acl_the_projection_is_published_and_its_lock_is_private() -> Result<()> {
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
use crate::incident::tests::{give_default_acl, readable_by_another_uid, under_service_umask};
|
||||
|
||||
let temp = tempfile::tempdir()?;
|
||||
let (expected, activation, _, anchor) = authenticated_warming(temp.path())?;
|
||||
let observation = observation_of(&expected, &activation);
|
||||
let dir = temp.path().join("projection");
|
||||
fs::create_dir(&dir)?;
|
||||
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755))?;
|
||||
give_default_acl(&dir);
|
||||
let driver = topology_driver(&dir, "topology");
|
||||
let store = driver.projection_store.clone();
|
||||
let lock = authority_lock_path(&store);
|
||||
|
||||
// Control: the lock CultCache would have made here. Without the ACL in
|
||||
// force this reads 0640 and the assertions below prove nothing.
|
||||
let control = dir.join("control.lock");
|
||||
under_service_umask(|| {
|
||||
OpenOptions::new().create(true).write(true).mode(0o666).open(&control)
|
||||
})?;
|
||||
assert_eq!(mode_of(&control), 0o644, "the default ACL is not in force");
|
||||
|
||||
let published = |step: &str| {
|
||||
assert_eq!(mode_of(&store), 0o644, "the store after {step}");
|
||||
assert_eq!(mode_of(&lock), 0o600, "the lock after {step}");
|
||||
};
|
||||
under_service_umask(|| driver.publish_expected(&expected, &anchor))?;
|
||||
published("publish_expected");
|
||||
let private_lock = inode_of(&lock);
|
||||
under_service_umask(|| {
|
||||
driver.publish_observed_activation(&expected, &activation, &observation)
|
||||
})?;
|
||||
published("publish_observed_activation");
|
||||
under_service_umask(|| {
|
||||
driver.demote_to_expected_only(&expected, &anchor, &activation, None)
|
||||
})?;
|
||||
published("demote_to_expected_only");
|
||||
under_service_umask(|| driver.withdraw_incarnation(&expected, &anchor, None, None))?;
|
||||
published("withdraw_incarnation");
|
||||
assert_eq!(inode_of(&lock), private_lock, "a private lock was replaced");
|
||||
|
||||
// The consequence: another uid cannot open the lock, so it cannot hold
|
||||
// it. Provable only as root with setpriv, and only if that uid can
|
||||
// read the control file, which shows the switch itself works.
|
||||
if readable_by_another_uid(&control) == Some(true) {
|
||||
assert_eq!(readable_by_another_uid(&lock), Some(false));
|
||||
} else {
|
||||
eprintln!("UNPROVEN: no uid switch here; the lock modes above stand alone");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Another process holds the lock: the publish is an error and a no-op, and
|
||||
/// it lands once the lock is free. Nothing waits (`run_while_held` counts
|
||||
/// the opens of the lock), and the error names the store kind, not a path.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn a_topology_publish_never_waits_on_a_held_lock_and_lands_when_freed() -> Result<()> {
|
||||
use crate::incident::tests::{eventually, hold_lock_of, run_while_held};
|
||||
|
||||
let temp = tempfile::tempdir()?;
|
||||
let (expected, activation, _, anchor) = authenticated_warming(temp.path())?;
|
||||
let observation = observation_of(&expected, &activation);
|
||||
let driver = topology_driver(temp.path(), "topology");
|
||||
|
||||
let error = run_while_held(hold_lock_of(&driver.projection_store), 1, || {
|
||||
driver.publish_expected(&expected, &anchor)
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(error.to_string(), "the topology projection is locked by another holder");
|
||||
assert!(!driver.projection_store.exists(), "a contended publish wrote the store");
|
||||
|
||||
eventually(|| driver.publish_expected(&expected, &anchor));
|
||||
let landed = fs::read(&driver.projection_store)?;
|
||||
|
||||
// An existing store is unchanged by a contended write of any kind.
|
||||
let error = run_while_held(hold_lock_of(&driver.projection_store), 1, || {
|
||||
driver.publish_observed_activation(&expected, &activation, &observation)
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(error.to_string(), "the topology projection is locked by another holder");
|
||||
assert_eq!(fs::read(&driver.projection_store)?, landed);
|
||||
eventually(|| driver.publish_observed_activation(&expected, &activation, &observation));
|
||||
assert_ne!(fs::read(&driver.projection_store)?, landed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The load-bearing rule (a chmod does not revoke a holder): a wider lock
|
||||
/// that another process already holds is replaced by a fresh private one,
|
||||
/// so the holder keeps only an orphaned inode and cannot block a publish.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn a_holder_of_a_wider_lock_cannot_block_a_publish() -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use crate::incident::tests::hold_lock_of;
|
||||
|
||||
let temp = tempfile::tempdir()?;
|
||||
let (expected, _, _, anchor) = authenticated_warming(temp.path())?;
|
||||
let driver = topology_driver(temp.path(), "topology");
|
||||
let lock = authority_lock_path(&driver.projection_store);
|
||||
fs::write(&lock, b"")?;
|
||||
fs::set_permissions(&lock, fs::Permissions::from_mode(0o644))?;
|
||||
let wider = inode_of(&lock);
|
||||
let holder = hold_lock_of(&driver.projection_store);
|
||||
|
||||
driver.publish_expected(&expected, &anchor)?;
|
||||
assert_eq!(mode_of(&lock), 0o600);
|
||||
assert_ne!(inode_of(&lock), wider, "the wider lock was chmodded, not replaced");
|
||||
assert!(holder.is_held(), "the publish ended the holder instead of outliving it");
|
||||
assert_eq!(driver.snapshot()?.len(), 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every lock that is not a regular file, ours, mode 0600 is replaced, and
|
||||
/// a private one is left alone.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn every_lock_that_is_not_private_is_replaced_and_a_private_one_is_not() -> Result<()> {
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
|
||||
let temp = tempfile::tempdir()?;
|
||||
let (expected, activation, _, anchor) = authenticated_warming(temp.path())?;
|
||||
let observation = observation_of(&expected, &activation);
|
||||
let driver = topology_driver(temp.path(), "topology");
|
||||
let lock = authority_lock_path(&driver.projection_store);
|
||||
driver.publish_expected(&expected, &anchor)?;
|
||||
// Each call changes the projection, so each one exchanges.
|
||||
let toggle = || -> Result<()> {
|
||||
if driver.projected_activation_is_present(&expected)? {
|
||||
driver
|
||||
.demote_to_expected_only(&expected, &anchor, &activation, None)
|
||||
.map(drop)
|
||||
} else {
|
||||
driver
|
||||
.publish_observed_activation(&expected, &activation, &observation)
|
||||
.map(drop)
|
||||
}
|
||||
};
|
||||
// SAFETY: geteuid has no preconditions.
|
||||
let euid = unsafe { libc::geteuid() };
|
||||
|
||||
for (shape, mode, owner) in [
|
||||
("0640", 0o640, None),
|
||||
("0666", 0o666, None),
|
||||
("0700", 0o700, None),
|
||||
("0400", 0o400, None),
|
||||
("0604", 0o604, None),
|
||||
("0600 owned by another uid", 0o600, Some(65534)),
|
||||
] {
|
||||
if owner.is_some() && euid != 0 {
|
||||
eprintln!("UNPROVEN: not root, so a lock owned by another uid cannot be made");
|
||||
continue;
|
||||
}
|
||||
fs::set_permissions(&lock, fs::Permissions::from_mode(mode))?;
|
||||
if let Some(uid) = owner {
|
||||
std::os::unix::fs::chown(&lock, Some(uid), Some(uid))?;
|
||||
}
|
||||
let before = inode_of(&lock);
|
||||
toggle()?;
|
||||
assert_ne!(inode_of(&lock), before, "a {shape} lock was kept");
|
||||
assert_eq!(mode_of(&lock), 0o600, "after a {shape} lock");
|
||||
assert_eq!(fs::metadata(&lock)?.uid(), euid, "after a {shape} lock");
|
||||
}
|
||||
|
||||
// A lock that is a symlink is replaced, never followed.
|
||||
let target = temp.path().join("elsewhere");
|
||||
fs::write(&target, b"")?;
|
||||
fs::set_permissions(&target, fs::Permissions::from_mode(0o600))?;
|
||||
fs::remove_file(&lock)?;
|
||||
std::os::unix::fs::symlink(&target, &lock)?;
|
||||
toggle()?;
|
||||
assert!(!fs::symlink_metadata(&lock)?.file_type().is_symlink());
|
||||
assert_eq!(mode_of(&lock), 0o600);
|
||||
assert!(target.exists());
|
||||
|
||||
// A private lock is opened as it is: two more exchanges, one inode.
|
||||
let kept = inode_of(&lock);
|
||||
toggle()?;
|
||||
toggle()?;
|
||||
assert_eq!(inode_of(&lock), kept);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A lock that cannot be inspected is an inspection error, and one that
|
||||
/// cannot be created is a creation error: neither is mistaken for the
|
||||
/// other, and neither is retried as if another creator had won.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn a_lock_that_cannot_be_inspected_or_created_says_which() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("topology.cc");
|
||||
std::fs::write(&path, b"x").unwrap();
|
||||
// What Idunn's UMask=027 leaves behind.
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap();
|
||||
let lock = temp.path().join("topology.cc.lock");
|
||||
std::fs::write(&lock, b"").unwrap();
|
||||
std::fs::set_permissions(&lock, std::fs::Permissions::from_mode(0o640)).unwrap();
|
||||
publish_projection_mode(&path).unwrap();
|
||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(
|
||||
mode, 0o644,
|
||||
"every target must be able to read the projection"
|
||||
);
|
||||
let lock_mode = std::fs::metadata(&lock).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(lock_mode, 0o644, "the lock is opened alongside the store");
|
||||
// The lock's name is one byte past NAME_MAX: inspecting it fails with
|
||||
// an error that is not "not found".
|
||||
let long = temp.path().join("a".repeat(251));
|
||||
let error = exchange_behind_private_lock(&long, &[], &[]).unwrap_err();
|
||||
assert!(format!("{error:#}").contains("inspecting a store lock"), "{error:#}");
|
||||
|
||||
// Absent, but nothing can be created beside it: procfs refuses.
|
||||
let error = exchange_behind_private_lock(Path::new("/proc/self/idunn-store.cc"), &[], &[])
|
||||
.unwrap_err();
|
||||
assert!(format!("{error:#}").contains("creating a private store lock"), "{error:#}");
|
||||
}
|
||||
|
||||
/// Many first publishers race to create the same lock. Whoever loses finds
|
||||
/// a private lock and carries on: no call fails for having lost.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn publishers_racing_to_create_the_lock_all_find_it_private() {
|
||||
use std::sync::Barrier;
|
||||
|
||||
const PUBLISHERS: usize = 8;
|
||||
for round in 0..100 {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let store = temp.path().join("topology.cc");
|
||||
let start = Barrier::new(PUBLISHERS);
|
||||
let outcomes = thread::scope(|scope| {
|
||||
(0..PUBLISHERS)
|
||||
.map(|_| {
|
||||
scope.spawn(|| {
|
||||
start.wait();
|
||||
exchange_behind_private_lock(&store, &[], &[]).map(drop)
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.map(|publisher| publisher.join().unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
for outcome in outcomes {
|
||||
outcome.unwrap_or_else(|error| panic!("round {round}: {error:#}"));
|
||||
}
|
||||
assert_eq!(mode_of(&authority_lock_path(&store)), 0o600);
|
||||
}
|
||||
}
|
||||
|
||||
/// A lost race is retried at once, against the store as it now is; a race
|
||||
/// lost every time is an error rather than a loop.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn a_lost_race_is_retried_against_the_new_store_and_never_forever() -> Result<()> {
|
||||
use std::cell::Cell;
|
||||
|
||||
let temp = tempfile::tempdir()?;
|
||||
let driver = topology_driver(temp.path(), "topology");
|
||||
let rival = |key: &str| CultCacheEnvelope {
|
||||
key: key.into(),
|
||||
r#type: "test.rival".into(),
|
||||
payload: vec![0x90],
|
||||
stored_at: "2026-01-01T00:00:00+00:00".into(),
|
||||
schema_id: Some("test.rival.v1".into()),
|
||||
};
|
||||
|
||||
// Lost once: a rival writes between the snapshot and the exchange.
|
||||
let calls = Cell::new(0);
|
||||
driver.mutate(|entries| {
|
||||
calls.set(calls.get() + 1);
|
||||
if calls.get() == 1 {
|
||||
upsert_record(&driver.projection_store, rival("first")).unwrap();
|
||||
}
|
||||
let mut next = entries.to_vec();
|
||||
next.push(rival(&format!("mine-{}", calls.get())));
|
||||
Ok(Some(next))
|
||||
})?;
|
||||
assert_eq!(calls.get(), 2);
|
||||
let keys = driver.snapshot()?.into_iter().map(|e| e.key).collect::<Vec<_>>();
|
||||
assert_eq!(keys, ["first", "mine-2"]);
|
||||
|
||||
// Lost every time: bounded, and the store is the rival's.
|
||||
let calls = Cell::new(0);
|
||||
let error = driver
|
||||
.mutate(|entries| {
|
||||
calls.set(calls.get() + 1);
|
||||
upsert_record(&driver.projection_store, rival(&format!("rival-{}", calls.get()))).unwrap();
|
||||
let mut next = entries.to_vec();
|
||||
next.push(rival("never"));
|
||||
Ok(Some(next))
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("changed repeatedly"), "{error}");
|
||||
assert_eq!(calls.get(), 8);
|
||||
assert!(driver.snapshot()?.iter().all(|entry| entry.key != "never"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
|
|
|
|||
270
src/incident.rs
270
src/incident.rs
|
|
@ -20,7 +20,7 @@ use cultcache_rs::{
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::control_plane::{decode_record, require_id, typed_envelope};
|
||||
use crate::drivers::publish_file_mode;
|
||||
use crate::drivers::{LockContended, exchange_behind_private_lock, publish_file_mode};
|
||||
|
||||
const INCIDENT_SCHEMA: &str = "idunn.operator_incident.v1";
|
||||
|
||||
|
|
@ -177,8 +177,9 @@ impl Snapshot {
|
|||
/// scheduler tick, so no write waits: each is one nonblocking compare-exchange
|
||||
/// of the whole file against the snapshot it was decided on. A lost race or a
|
||||
/// held lock is an error and a no-op, retried on a later pass. The lock beside
|
||||
/// the file is not published and is created 0600 (`secure_lock`): the readers
|
||||
/// of `incidents.cc` take no lock, so only Idunn can ever hold it.
|
||||
/// the file is not published and is Idunn's alone
|
||||
/// (`exchange_behind_private_lock`): the readers of `incidents.cc` take no
|
||||
/// lock, so only Idunn can ever hold it.
|
||||
pub(crate) struct IncidentStore {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
|
@ -231,16 +232,13 @@ impl IncidentStore {
|
|||
expected: &[CultCacheEnvelope],
|
||||
replacements: &[CultCacheEnvelope],
|
||||
) -> Result<()> {
|
||||
secure_lock(path)?;
|
||||
match SingleFileMessagePackBackingStore::new(path)
|
||||
.try_compare_exchange_snapshot(expected, replacements)?
|
||||
{
|
||||
match exchange_behind_private_lock(path, expected, replacements)? {
|
||||
TryCompareExchangeSnapshotOutcome::Exchanged => Ok(()),
|
||||
TryCompareExchangeSnapshotOutcome::Mismatch => {
|
||||
bail!("the incident store changed while it was being written")
|
||||
}
|
||||
TryCompareExchangeSnapshotOutcome::LockContended => {
|
||||
bail!("the incident store is locked by another holder")
|
||||
Err(LockContended { store: "incident store" }.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -335,46 +333,6 @@ impl IncidentStore {
|
|||
}
|
||||
}
|
||||
|
||||
/// Make the lock beside `store` Idunn's alone, before CultCache opens it.
|
||||
///
|
||||
/// CultCache creates a lock with mode 0666, so the published directory decides
|
||||
/// who may open it: under Yggdrasil's default ACL (`u::rw g::r o::r`) that is
|
||||
/// 0644, and any uid could hold the lock and keep every incident from being
|
||||
/// recorded. Created here with 0600 the create mode is intersected with the
|
||||
/// ACL and still holds, and no wider file ever exists. A lock left wider by an
|
||||
/// earlier run is tightened on the descriptor just opened (a descriptor some
|
||||
/// other uid already holds is not revoked). Readers of the store take no lock,
|
||||
/// so nothing but Idunn needs to open this file.
|
||||
fn secure_lock(store: &Path) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
|
||||
let mut name = store
|
||||
.file_name()
|
||||
.context("the incident store has no file name")?
|
||||
.to_owned();
|
||||
name.push(".lock");
|
||||
let lock = store.with_file_name(name);
|
||||
if let Some(parent) = lock.parent() {
|
||||
std::fs::create_dir_all(parent).context("creating the incident store directory")?;
|
||||
}
|
||||
std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.open(&lock)
|
||||
.context("opening the incident lock")?
|
||||
.set_permissions(std::fs::Permissions::from_mode(0o600))
|
||||
.context("making the incident lock private")?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = store;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The lines `idunn status` prints for one target's incidents: each open one,
|
||||
/// and each closed one still inside retention.
|
||||
pub(crate) fn render_incidents(records: &[IncidentRecord], target: &str, now: u64) -> Vec<String> {
|
||||
|
|
@ -399,7 +357,7 @@ pub(crate) fn render_incidents(records: &[IncidentRecord], target: &str, now: u6
|
|||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
#[cfg(unix)]
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
|
@ -644,7 +602,7 @@ pub(crate) mod tests {
|
|||
/// Idunn runs with `UMask=027`. The umask is process-wide, so it is held
|
||||
/// only around each call and only one test at a time may hold it.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn under_service_umask<T>(write: impl FnOnce() -> T) -> T {
|
||||
pub(crate) fn under_service_umask<T>(write: impl FnOnce() -> T) -> T {
|
||||
static UMASK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
let _held = UMASK.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
// SAFETY: umask only swaps the process file-creation mask.
|
||||
|
|
@ -658,7 +616,7 @@ pub(crate) mod tests {
|
|||
/// `u::rw g::r o::r` that Yggdrasil's idunn-projection directory has. Under
|
||||
/// it the umask is ignored and a file created with mode 0666 lands 0644.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn give_default_acl(dir: &Path) {
|
||||
pub(crate) fn give_default_acl(dir: &Path) {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
// posix_acl_xattr: version 2, then (tag, perm, id) entries. The tags
|
||||
|
|
@ -691,7 +649,7 @@ pub(crate) mod tests {
|
|||
/// Whether uid 65534 can read `path`; `None` when the uid cannot be
|
||||
/// switched (not root, or no setpriv), which leaves the question unproven.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn readable_by_another_uid(path: &Path) -> Option<bool> {
|
||||
pub(crate) fn readable_by_another_uid(path: &Path) -> Option<bool> {
|
||||
std::process::Command::new("setpriv")
|
||||
.args(["--reuid=65534", "--regid=65534", "--clear-groups", "cat"])
|
||||
.arg(path)
|
||||
|
|
@ -758,47 +716,154 @@ pub(crate) mod tests {
|
|||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_lock_left_wider_by_an_earlier_run_is_tightened_before_it_is_used() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fn a_lock_left_wider_by_an_earlier_run_is_replaced_before_it_is_used() {
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
|
||||
let temp = TempDir::new().unwrap();
|
||||
let path = temp.path().join("incidents.cc");
|
||||
let lock = temp.path().join("incidents.cc.lock");
|
||||
std::fs::write(&lock, b"").unwrap();
|
||||
std::fs::set_permissions(&lock, std::fs::Permissions::from_mode(0o666)).unwrap();
|
||||
let before = std::fs::metadata(&lock).unwrap().ino();
|
||||
|
||||
assert!(
|
||||
IncidentStore::new(&path)
|
||||
.open(IncidentCondition::ContinuityExhausted, "service", OPENED)
|
||||
.unwrap()
|
||||
);
|
||||
let mode = std::fs::metadata(&lock).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
let after = std::fs::metadata(&lock).unwrap();
|
||||
assert_eq!(after.permissions().mode() & 0o777, 0o600);
|
||||
assert_ne!(after.ino(), before, "the wider lock was chmodded, not replaced");
|
||||
}
|
||||
|
||||
/// An exclusive lock on a store's lock file, held through another file
|
||||
/// description. flock conflicts between descriptions, so the holder need
|
||||
/// not be another process or another uid: the test does not depend on who
|
||||
/// it runs as.
|
||||
#[cfg(unix)]
|
||||
pub(crate) struct HeldLock(#[allow(dead_code)] std::fs::File);
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn hold_lock_of(store: &Path) -> HeldLock {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
|
||||
/// The lock beside a store, spelled independently of the code under test.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn lock_beside(store: &Path) -> PathBuf {
|
||||
let mut name = store.file_name().unwrap().to_owned();
|
||||
name.push(".lock");
|
||||
let file = std::fs::OpenOptions::new()
|
||||
store.with_file_name(name)
|
||||
}
|
||||
|
||||
/// An exclusive flock on a store's lock, held by a SEPARATE PROCESS
|
||||
/// (`flock -x <lock> sleep`), so the holder shares no descriptor, thread
|
||||
/// or address space with the code under test. A watch on the lock counts
|
||||
/// every open of it from the moment it is held.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) struct HeldLock {
|
||||
holder: std::sync::Arc<std::sync::Mutex<std::process::Child>>,
|
||||
watch: std::os::fd::OwnedFd,
|
||||
}
|
||||
|
||||
/// Hold the lock of `store`. An absent lock is created as production keeps
|
||||
/// it (0600, ours); a lock a test put there first is held as it is, never
|
||||
/// chmodded.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn hold_lock_of(store: &Path) -> HeldLock {
|
||||
use std::os::fd::{FromRawFd, OwnedFd};
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::os::unix::io::AsRawFd;
|
||||
|
||||
let lock = lock_beside(store);
|
||||
std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.truncate(false)
|
||||
.open(store.with_file_name(name))
|
||||
.mode(0o600)
|
||||
.open(&lock)
|
||||
.unwrap();
|
||||
// SAFETY: flock on a descriptor this function owns.
|
||||
assert_eq!(unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) }, 0);
|
||||
HeldLock(file)
|
||||
let mut holder = std::process::Command::new("flock")
|
||||
.args(["--no-fork", "--exclusive"])
|
||||
.arg(&lock)
|
||||
.args(["sleep", "3600"])
|
||||
.spawn()
|
||||
.expect("flock (util-linux) must be installed to hold a lock from another process");
|
||||
|
||||
// Held once a nonblocking exclusive flock from here is refused.
|
||||
let deadline = Instant::now() + STUCK_AFTER;
|
||||
loop {
|
||||
assert!(holder.try_wait().unwrap().is_none(), "the lock holder exited");
|
||||
let probe = std::fs::File::open(&lock).unwrap();
|
||||
// SAFETY: flock on a descriptor this function owns.
|
||||
let refused = unsafe { libc::flock(probe.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0;
|
||||
if refused {
|
||||
assert_eq!(
|
||||
std::io::Error::last_os_error().kind(),
|
||||
std::io::ErrorKind::WouldBlock
|
||||
);
|
||||
break;
|
||||
}
|
||||
drop(probe);
|
||||
assert!(Instant::now() < deadline, "the lock was never held");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
|
||||
// Open and close alternate, so two opens are never identical adjacent
|
||||
// events for the kernel to coalesce into one.
|
||||
// SAFETY: plain syscalls on descriptors and strings this function owns.
|
||||
let watch = unsafe {
|
||||
let fd = libc::inotify_init1(libc::IN_CLOEXEC | libc::IN_NONBLOCK);
|
||||
assert!(fd >= 0, "{}", std::io::Error::last_os_error());
|
||||
let watch = OwnedFd::from_raw_fd(fd);
|
||||
let path = std::ffi::CString::new(lock.as_os_str().as_bytes()).unwrap();
|
||||
let wd = libc::inotify_add_watch(
|
||||
fd,
|
||||
path.as_ptr(),
|
||||
libc::IN_OPEN | libc::IN_CLOSE_WRITE | libc::IN_CLOSE_NOWRITE,
|
||||
);
|
||||
assert!(wd >= 0, "{}", std::io::Error::last_os_error());
|
||||
watch
|
||||
};
|
||||
HeldLock {
|
||||
holder: std::sync::Arc::new(std::sync::Mutex::new(holder)),
|
||||
watch,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl HeldLock {
|
||||
/// Whether the holder process is still running and so still holds.
|
||||
pub(crate) fn is_held(&self) -> bool {
|
||||
self.holder.lock().unwrap().try_wait().unwrap().is_none()
|
||||
}
|
||||
|
||||
/// How many times the lock has been opened since it was held.
|
||||
fn opens(&self) -> usize {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
|
||||
let mut opens = 0;
|
||||
let mut buffer = [0u8; 4096];
|
||||
loop {
|
||||
// SAFETY: reads into a live buffer of the stated length.
|
||||
let read = unsafe {
|
||||
libc::read(self.watch.as_raw_fd(), buffer.as_mut_ptr().cast(), buffer.len())
|
||||
};
|
||||
if read <= 0 {
|
||||
return opens;
|
||||
}
|
||||
let read = usize::try_from(read).unwrap();
|
||||
let mut at = 0;
|
||||
// struct inotify_event: wd i32, mask u32, cookie u32, len u32, name[len].
|
||||
while at + 16 <= read {
|
||||
let word = |offset: usize| {
|
||||
u32::from_ne_bytes(buffer[at + offset..at + offset + 4].try_into().unwrap())
|
||||
};
|
||||
if word(4) & libc::IN_OPEN != 0 {
|
||||
opens += 1;
|
||||
}
|
||||
at += 16 + usize::try_from(word(12)).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Drop for HeldLock {
|
||||
fn drop(&mut self) {
|
||||
let mut holder = self.holder.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let _ = holder.kill();
|
||||
let _ = holder.wait();
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a test lets a held lock, or a released lock's last straggler,
|
||||
|
|
@ -806,40 +871,57 @@ pub(crate) mod tests {
|
|||
/// spends it: the code under test finishes in milliseconds, and the limit
|
||||
/// is only what stops a blocked write from hanging the suite. No
|
||||
/// assertion compares elapsed time to a bound the machine's load can move.
|
||||
#[cfg(unix)]
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) const STUCK_AFTER: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Run `work` while `held` is held, and fail if it waited on it. The holder
|
||||
/// lets go only when `work` has returned, or after `STUCK_AFTER`: work that
|
||||
/// waits on the lock is freed by that second release and is then seen to
|
||||
/// have waited, so the test fails instead of hanging.
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn run_while_held<T>(held: HeldLock, work: impl FnOnce() -> T) -> T {
|
||||
/// Run `work` while `held` is held, and fail if it waited on it. The rule
|
||||
/// is observed where it is decided, at the lock, not on a clock: every
|
||||
/// exchange opens the lock exactly once, so `exchanges` is the number of
|
||||
/// exchanges `work` makes against this lock, and a wait of any length (a
|
||||
/// sleep and retry, a poll, a second attempt) opens it more often. A wait
|
||||
/// that never ends is freed by the holder's release after `STUCK_AFTER`,
|
||||
/// and is then seen to have waited, so the test fails instead of hanging.
|
||||
/// The holder is still holding when `work` returns, and is released on the
|
||||
/// way out.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn run_while_held<T>(
|
||||
held: HeldLock,
|
||||
exchanges: usize,
|
||||
work: impl FnOnce() -> T,
|
||||
) -> T {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, mpsc};
|
||||
|
||||
let (finished, work_done) = mpsc::channel::<()>();
|
||||
let gave_up = Arc::new(AtomicBool::new(false));
|
||||
let holder = {
|
||||
let watchdog = {
|
||||
let gave_up = Arc::clone(&gave_up);
|
||||
let holder = Arc::clone(&held.holder);
|
||||
std::thread::spawn(move || {
|
||||
if work_done.recv_timeout(STUCK_AFTER) == Err(mpsc::RecvTimeoutError::Timeout) {
|
||||
gave_up.store(true, Ordering::SeqCst);
|
||||
let _ = holder.lock().unwrap().kill();
|
||||
}
|
||||
drop(held);
|
||||
})
|
||||
};
|
||||
let result = work();
|
||||
let still_held = held.is_held();
|
||||
let _ = finished.send(());
|
||||
holder.join().unwrap();
|
||||
watchdog.join().unwrap();
|
||||
assert!(!gave_up.load(Ordering::SeqCst), "the work waited on a held lock");
|
||||
assert!(still_held, "the holder let go before the work returned");
|
||||
assert_eq!(
|
||||
held.opens(),
|
||||
exchanges,
|
||||
"the work opened the held lock other than once per exchange: it waited or retried"
|
||||
);
|
||||
result
|
||||
}
|
||||
|
||||
/// Retry `attempt` until it lands. A released lock can stay held for a
|
||||
/// moment by a child another test forked while it was held.
|
||||
#[cfg(unix)]
|
||||
fn eventually<T>(mut attempt: impl FnMut() -> Result<T>) -> T {
|
||||
/// Retry `attempt` until it lands. A lock just released can take a moment
|
||||
/// to be seen free.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn eventually<T>(mut attempt: impl FnMut() -> Result<T>) -> T {
|
||||
let deadline = Instant::now() + STUCK_AFTER;
|
||||
loop {
|
||||
match attempt() {
|
||||
|
|
@ -850,7 +932,7 @@ pub(crate) mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn no_incident_write_waits_on_a_held_lock_and_each_lands_when_it_is_freed() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
|
@ -863,15 +945,17 @@ pub(crate) mod tests {
|
|||
let before = std::fs::read(&path).unwrap();
|
||||
let retired = OPENED + 1 + INCIDENT_RETENTION_MILLIS + 1;
|
||||
|
||||
let outcomes = run_while_held(hold_lock_of(&path), || {
|
||||
// One exchange each: open, close, and the retirement's store delete.
|
||||
let outcomes = run_while_held(hold_lock_of(&path), 3, || {
|
||||
(
|
||||
store.open(condition, "new", CLOSED),
|
||||
store.close(condition, "open", CloseReason::Recovered, CLOSED),
|
||||
store.retire_closed(retired),
|
||||
)
|
||||
});
|
||||
for outcome in [outcomes.0, outcomes.1, outcomes.2] {
|
||||
assert!(outcome.is_err());
|
||||
for outcome in [outcomes.0.map(drop), outcomes.1.map(drop), outcomes.2.map(drop)] {
|
||||
let error = outcome.unwrap_err().to_string();
|
||||
assert_eq!(error, "the incident store is locked by another holder");
|
||||
}
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before, "a contended write changed the file");
|
||||
|
||||
|
|
@ -885,7 +969,7 @@ pub(crate) mod tests {
|
|||
assert_eq!(history.pull_all_read_only_snapshot().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn a_held_history_lock_keeps_the_incident_where_it_is() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
|
@ -898,7 +982,7 @@ pub(crate) mod tests {
|
|||
let before = std::fs::read(&path).unwrap();
|
||||
let retired = OPENED + 1 + INCIDENT_RETENTION_MILLIS + 1;
|
||||
|
||||
let outcome = run_while_held(hold_lock_of(&history), || store.retire_closed(retired));
|
||||
let outcome = run_while_held(hold_lock_of(&history), 1, || store.retire_closed(retired));
|
||||
assert!(outcome.is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before);
|
||||
assert!(eventually(|| store.retire_closed(retired)));
|
||||
|
|
|
|||
Loading…
Reference in a new issue