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;
18use crate::worker::deployed_binary::lowercase_hex;
19
20use super::error::SupervisionError;
21use super::status::SpawnedBinary;
22
23/// The executable a managed worker instance launches.
24#[derive(Clone, Debug, Eq, PartialEq)]
25pub enum ManagedExecutable {
26    /// This server's own binary, resolved through the operating system at each
27    /// spawn. The production choice, and the only one a `builtin` artifact
28    /// reference can mean.
29    CurrentServer,
30    /// An explicit executable path. The seam supervision tests drive, so a test
31    /// never risks re-executing the test binary itself.
32    Path(PathBuf),
33}
34
35impl ManagedExecutable {
36    /// Resolve the path to launch.
37    ///
38    /// # Errors
39    ///
40    /// Returns [`SupervisionError::ExecutableUnresolved`] when the operating
41    /// system cannot identify this server's own executable.
42    pub fn resolve(&self) -> Result<PathBuf, SupervisionError> {
43        match self {
44            Self::CurrentServer => {
45                std::env::current_exe().map_err(|source| SupervisionError::ExecutableUnresolved {
46                    detail: source.to_string(),
47                })
48            }
49            Self::Path(path) => Ok(path.clone()),
50        }
51    }
52
53    /// Capture the identity of `path` as this spawn will run it.
54    ///
55    /// The build stamp is reported only for this server's own binary: it is the
56    /// only executable whose version and commit this process actually knows.
57    ///
58    /// # Errors
59    ///
60    /// Returns [`SupervisionError::ExecutableUnreadable`] when the bytes cannot
61    /// be read, which is also the honest report for a deployment naming a path
62    /// that no longer exists.
63    pub fn identify(&self, path: &Path) -> Result<SpawnedBinary, SupervisionError> {
64        let bytes =
65            std::fs::read(path).map_err(|source| SupervisionError::ExecutableUnreadable {
66                path: path.display().to_string(),
67                detail: source.to_string(),
68            })?;
69        let content_hash = lowercase_hex(&Sha256::digest(bytes));
70        let stamp = match self {
71            Self::CurrentServer => Some(BuildIdentity::current()),
72            Self::Path(_) => None,
73        };
74        Ok(SpawnedBinary {
75            path: path.display().to_string(),
76            content_hash,
77            version: stamp.as_ref().map(|identity| identity.version.to_owned()),
78            commit: stamp.as_ref().map(|identity| identity.commit.to_owned()),
79            dirty: stamp.as_ref().map(|identity| identity.dirty.to_owned()),
80        })
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use std::io::Write;
87
88    use super::ManagedExecutable;
89
90    type TestResult = Result<(), Box<dyn std::error::Error>>;
91
92    /// The hash is of the bytes on disk at the moment of capture, so rewriting
93    /// the file between spawns changes it. That is the mechanism the
94    /// "restart across an upgrade is visible" ruling rests on; if the hash were
95    /// cached, an upgrade would report the old identity and read as calm.
96    #[test]
97    fn identity_follows_the_bytes_on_disk_at_each_capture() -> TestResult {
98        let directory = tempfile::tempdir()?;
99        let path = directory.path().join("worker");
100        let mut file = std::fs::File::create(&path)?;
101        file.write_all(b"first build")?;
102        drop(file);
103
104        let executable = ManagedExecutable::Path(path.clone());
105        let first = executable.identify(&path)?;
106
107        std::fs::write(&path, b"second build")?;
108        let second = executable.identify(&path)?;
109
110        assert_ne!(first.content_hash, second.content_hash);
111        assert_eq!(first.path, path.display().to_string());
112        // An explicit path carries no build stamp: this process cannot know one.
113        assert_eq!(first.version, None);
114        assert_eq!(first.commit, None);
115        assert_eq!(first.dirty, None);
116        Ok(())
117    }
118
119    #[test]
120    fn a_missing_executable_is_a_typed_unreadable_refusal() -> TestResult {
121        let directory = tempfile::tempdir()?;
122        let path = directory.path().join("absent");
123        let error = ManagedExecutable::Path(path.clone())
124            .identify(&path)
125            .err()
126            .ok_or("identifying an absent executable must fail")?;
127        assert!(
128            error.to_string().contains(&path.display().to_string()),
129            "the refusal must name the path it could not read: {error}"
130        );
131        Ok(())
132    }
133
134    #[test]
135    fn the_server_binary_carries_its_build_stamp() -> TestResult {
136        let executable = ManagedExecutable::CurrentServer;
137        let path = executable.resolve()?;
138        let identity = executable.identify(&path)?;
139        assert!(identity.version.is_some());
140        assert!(identity.commit.is_some());
141        assert!(identity.dirty.is_some());
142        assert!(!identity.content_hash.is_empty());
143        Ok(())
144    }
145}