Skip to main content

aion_server/worker/supervisor/
executable.rs

1//! Which executable a builtin deployment launches, and what it is.
2//!
3//! `WorkerArtifactRef::Builtin { verb }` names the server's OWN binary plus an
4//! operator-declared argv tail. The tail is the whole launch contract: nothing
5//! here appends a flag, an endpoint, or an environment variable the operator
6//! did not write. An inferred argument is an argument nobody can read off the
7//! deployment record, and this record is meant to be the answer to "what will
8//! actually run".
9//!
10//! Identity is captured at EVERY spawn, not once at deploy: that is what makes
11//! a restart across an upgrade measurable rather than assumed.
12
13use std::path::{Path, PathBuf};
14
15use sha2::{Digest, Sha256};
16
17use crate::build_identity::BuildIdentity;
18
19use super::error::SupervisionError;
20use super::status::SpawnedBinary;
21
22/// The executable a managed worker instance launches.
23#[derive(Clone, Debug, Eq, PartialEq)]
24pub enum ManagedExecutable {
25    /// This server's own binary, resolved through the operating system at each
26    /// spawn. The production choice, and the only one a `builtin` artifact
27    /// reference can mean.
28    CurrentServer,
29    /// An explicit executable path. The seam supervision tests drive, so a test
30    /// never risks re-executing the test binary itself.
31    Path(PathBuf),
32}
33
34impl ManagedExecutable {
35    /// Resolve the path to launch.
36    ///
37    /// # Errors
38    ///
39    /// Returns [`SupervisionError::ExecutableUnresolved`] when the operating
40    /// system cannot identify this server's own executable.
41    pub fn resolve(&self) -> Result<PathBuf, SupervisionError> {
42        match self {
43            Self::CurrentServer => {
44                std::env::current_exe().map_err(|source| SupervisionError::ExecutableUnresolved {
45                    detail: source.to_string(),
46                })
47            }
48            Self::Path(path) => Ok(path.clone()),
49        }
50    }
51
52    /// Capture the identity of `path` as this spawn will run it.
53    ///
54    /// The build stamp is reported only for this server's own binary: it is the
55    /// only executable whose version and commit this process actually knows.
56    ///
57    /// # Errors
58    ///
59    /// Returns [`SupervisionError::ExecutableUnreadable`] when the bytes cannot
60    /// be read, which is also the honest report for a deployment naming a path
61    /// that no longer exists.
62    pub fn identify(&self, path: &Path) -> Result<SpawnedBinary, SupervisionError> {
63        let bytes =
64            std::fs::read(path).map_err(|source| SupervisionError::ExecutableUnreadable {
65                path: path.display().to_string(),
66                detail: source.to_string(),
67            })?;
68        let content_hash = lowercase_hex(&Sha256::digest(bytes));
69        let stamp = match self {
70            Self::CurrentServer => Some(BuildIdentity::current()),
71            Self::Path(_) => None,
72        };
73        Ok(SpawnedBinary {
74            path: path.display().to_string(),
75            content_hash,
76            version: stamp.as_ref().map(|identity| identity.version.to_owned()),
77            commit: stamp.as_ref().map(|identity| identity.commit.to_owned()),
78            dirty: stamp.as_ref().map(|identity| identity.dirty.to_owned()),
79        })
80    }
81}
82
83fn lowercase_hex(bytes: &[u8]) -> String {
84    const DIGITS: &[u8; 16] = b"0123456789abcdef";
85    let mut encoded = String::with_capacity(bytes.len().saturating_mul(2));
86    for byte in bytes {
87        encoded.push(char::from(DIGITS[usize::from(byte >> 4)]));
88        encoded.push(char::from(DIGITS[usize::from(byte & 0x0f)]));
89    }
90    encoded
91}
92
93#[cfg(test)]
94mod tests {
95    use std::io::Write;
96
97    use super::ManagedExecutable;
98
99    type TestResult = Result<(), Box<dyn std::error::Error>>;
100
101    /// The hash is of the bytes on disk at the moment of capture, so rewriting
102    /// the file between spawns changes it. That is the mechanism the
103    /// "restart across an upgrade is visible" ruling rests on; if the hash were
104    /// cached, an upgrade would report the old identity and read as calm.
105    #[test]
106    fn identity_follows_the_bytes_on_disk_at_each_capture() -> TestResult {
107        let directory = tempfile::tempdir()?;
108        let path = directory.path().join("worker");
109        let mut file = std::fs::File::create(&path)?;
110        file.write_all(b"first build")?;
111        drop(file);
112
113        let executable = ManagedExecutable::Path(path.clone());
114        let first = executable.identify(&path)?;
115
116        std::fs::write(&path, b"second build")?;
117        let second = executable.identify(&path)?;
118
119        assert_ne!(first.content_hash, second.content_hash);
120        assert_eq!(first.path, path.display().to_string());
121        // An explicit path carries no build stamp: this process cannot know one.
122        assert_eq!(first.version, None);
123        assert_eq!(first.commit, None);
124        assert_eq!(first.dirty, None);
125        Ok(())
126    }
127
128    #[test]
129    fn a_missing_executable_is_a_typed_unreadable_refusal() -> TestResult {
130        let directory = tempfile::tempdir()?;
131        let path = directory.path().join("absent");
132        let error = ManagedExecutable::Path(path.clone())
133            .identify(&path)
134            .err()
135            .ok_or("identifying an absent executable must fail")?;
136        assert!(
137            error.to_string().contains(&path.display().to_string()),
138            "the refusal must name the path it could not read: {error}"
139        );
140        Ok(())
141    }
142
143    #[test]
144    fn the_server_binary_carries_its_build_stamp() -> TestResult {
145        let executable = ManagedExecutable::CurrentServer;
146        let path = executable.resolve()?;
147        let identity = executable.identify(&path)?;
148        assert!(identity.version.is_some());
149        assert!(identity.commit.is_some());
150        assert!(identity.dirty.is_some());
151        assert!(!identity.content_hash.is_empty());
152        Ok(())
153    }
154}