Skip to main content

release_kit/devshell/
guard.rs

1//! The gates around the devshell transaction, so the `.envrc` line is
2//! safe on every directory entry.
3//!
4//! Each gate answers one question before anything fetches or spawns:
5//! whether the run is switched off, whether today's attempt already
6//! happened, whether another shell holds this checkout, and whether the
7//! two files carry uncommitted edits. The lock and the stamp live under
8//! the state root, keyed per checkout — not in the target, where an
9//! untracked file is noise, and not in `.git/`, which belongs to git.
10
11use std::fs;
12use std::io::Write as _;
13use std::path::PathBuf;
14
15use camino::Utf8Path;
16
17use super::{lock_path, stamp_path};
18use crate::maintenance::GIT_HOOK_VARS;
19use crate::probes::git_bin;
20
21/// A lock whose owner procfs cannot judge is taken over after this long.
22const LOCK_GRACE: std::time::Duration = std::time::Duration::from_secs(15 * 60);
23
24/// The variables a CI runner exports; any of them switches the sync off.
25pub const CI_VARS: [&str; 6] = [
26    "CI",
27    "GITHUB_ACTIONS",
28    "GITLAB_CI",
29    "BUILDKITE",
30    "CIRCLECI",
31    "TF_BUILD",
32];
33
34/// The operator's own off switch, read from the environment `.envrc.local`
35/// exports.
36pub const SWITCH_VAR: &str = "RK_DEVSHELL_SYNC";
37
38/// Whether the operator switched the sync off with `RK_DEVSHELL_SYNC=0`.
39#[must_use]
40pub fn switched_off() -> bool {
41    std::env::var(SWITCH_VAR).is_ok_and(|value| value.trim() == "0")
42}
43
44/// Whether a CI variable is set to anything but an explicit no.
45#[must_use]
46pub fn in_ci() -> bool {
47    CI_VARS.iter().any(|var| {
48        std::env::var(var).is_ok_and(|value| {
49            let value = value.trim().to_ascii_lowercase();
50            !(value.is_empty() || value == "0" || value == "false")
51        })
52    })
53}
54
55/// Today, as the first ten characters of the UTC clock.
56#[must_use]
57pub fn today() -> String {
58    crate::applog::now_utc()[..10].to_owned()
59}
60
61/// Stamp today's attempt for one checkout, before the attempt.
62///
63/// # Errors
64///
65/// Returns the I/O failure of the write.
66pub fn write_stamp(key: &str) -> std::io::Result<()> {
67    let Some(path) = stamp_path(key) else {
68        return Err(std::io::Error::other("no state root for the stamp"));
69    };
70    if let Some(parent) = path.parent() {
71        fs::create_dir_all(parent)?;
72    }
73    crate::atomic::write(&path, format!("{}\n", today()).as_bytes())
74}
75
76/// The held single-writer lock; removed on drop.
77#[derive(Debug)]
78pub struct Lock(PathBuf);
79
80impl Drop for Lock {
81    fn drop(&mut self) {
82        let _ = fs::remove_file(&self.0);
83    }
84}
85
86/// What acquisition found.
87#[derive(Debug)]
88pub enum Acquired {
89    /// This run holds the checkout.
90    Held(Lock),
91    /// Another live run holds it: normal, and skipped in silence.
92    Contended,
93    /// The lock cannot be taken at all: the mechanism itself is broken.
94    Unavailable(std::io::Error),
95}
96
97/// Take the checkout's lock, atomically through `O_EXCL`. A lock whose
98/// owner is provably gone — or, where procfs cannot judge, older than
99/// the grace period — is removed and the take retried once.
100#[must_use]
101pub fn acquire(key: &str) -> Acquired {
102    let Some(path) = lock_path(key) else {
103        return Acquired::Unavailable(std::io::Error::other(
104            "neither XDG_STATE_HOME nor HOME is set, so the lock has no root",
105        ));
106    };
107    if let Some(parent) = path.parent() {
108        if let Err(source) = fs::create_dir_all(parent) {
109            return Acquired::Unavailable(source);
110        }
111    }
112    match take(&path) {
113        Ok(lock) => Acquired::Held(lock),
114        Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => {
115            let pid = fs::read_to_string(&path).ok().and_then(|text| {
116                text.lines()
117                    .find_map(|line| line.strip_prefix("pid=")?.trim().parse::<u64>().ok())
118            });
119            if !super::txn::owner_gone_after(pid, &path, LOCK_GRACE) {
120                return Acquired::Contended;
121            }
122            // A stale lock that cannot be removed is not held by anyone:
123            // silence here would repeat forever without saying why.
124            match fs::remove_file(&path) {
125                Ok(()) => {}
126                Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
127                Err(source) => return Acquired::Unavailable(source),
128            }
129            match take(&path) {
130                Ok(lock) => Acquired::Held(lock),
131                Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => {
132                    Acquired::Contended
133                }
134                Err(source) => Acquired::Unavailable(source),
135            }
136        }
137        Err(source) => Acquired::Unavailable(source),
138    }
139}
140
141/// One exclusive create, holding the owner's process id and start time.
142fn take(path: &std::path::Path) -> std::io::Result<Lock> {
143    let mut file = fs::OpenOptions::new()
144        .write(true)
145        .create_new(true)
146        .open(path)?;
147    file.write_all(format!("pid={}\n", std::process::id()).as_bytes())?;
148    file.write_all(format!("started={}\n", crate::applog::now_utc()).as_bytes())?;
149    Ok(Lock(path.to_path_buf()))
150}
151
152/// Whether `flake.nix` or `flake.lock` carries uncommitted edits.
153///
154/// Judged by the target repository with the hook variables scrubbed, so
155/// a run from inside a git hook judges the target and not the hook's own
156/// repository. Fails closed: a git that did not run counts as dirty.
157#[must_use]
158pub fn two_files_dirty(target: &Utf8Path) -> bool {
159    let mut command = std::process::Command::new(git_bin());
160    for var in GIT_HOOK_VARS {
161        command.env_remove(var);
162    }
163    command
164        .arg("-C")
165        .arg(target.as_std_path())
166        .args(["status", "--porcelain", "--", "flake.nix", "flake.lock"])
167        .output()
168        .map_or(true, |probed| {
169            !probed.status.success() || !probed.stdout.is_empty()
170        })
171}
172
173#[cfg(test)]
174mod tests {
175    #![allow(clippy::expect_used)]
176
177    use super::{Acquired, take};
178
179    #[test]
180    fn the_lock_is_exclusive_and_released_on_drop() {
181        let dir = tempfile::tempdir().expect("a scratch dir exists");
182        let path = dir.path().join("k.lock");
183        let held = take(&path).expect("the first take holds");
184        assert!(path.exists());
185        let second = take(&path).expect_err("the second take refuses");
186        assert_eq!(second.kind(), std::io::ErrorKind::AlreadyExists);
187        drop(held);
188        assert!(!path.exists(), "the lock is removed on drop");
189        let again = take(&path).expect("the lock is free again");
190        assert!(
191            std::fs::read_to_string(&path)
192                .expect("reads")
193                .starts_with("pid="),
194            "the lock names its owner"
195        );
196        drop(again);
197    }
198
199    #[test]
200    fn the_acquired_vocabulary_is_three_states() {
201        let unavailable = Acquired::Unavailable(std::io::Error::other("x"));
202        assert!(matches!(unavailable, Acquired::Unavailable(_)));
203        assert!(matches!(Acquired::Contended, Acquired::Contended));
204    }
205}