Skip to main content

run_stack/
assets.rs

1//! The files a stack is built from, carried inside the binary.
2//!
3//! docker-compose.yml, the Dockerfiles, the container entrypoints and the env
4//! template are part of the tool, not of any workspace. They are embedded so
5//! the binary is self-contained, and unpacked on first use because docker needs
6//! a real directory for its build contexts.
7
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use anyhow::{Context, Result};
12use include_dir::{include_dir, Dir};
13
14static ASSETS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/assets");
15
16/// Where the unpacked copy lives: per version, so an upgrade cannot read a
17/// stale compose file, and several versions can coexist.
18fn cache_dir() -> Result<PathBuf> {
19    if let Some(dir) = std::env::var_os("RUN_ASSET_DIR") {
20        return Ok(PathBuf::from(dir));
21    }
22    let base = std::env::var_os("XDG_CACHE_HOME")
23        .map(PathBuf::from)
24        .or_else(|| dirs_home().map(|home| home.join("Library/Caches")))
25        .context("no cache directory — set RUN_ASSET_DIR")?;
26    Ok(base.join("run-stack").join(env!("CARGO_PKG_VERSION")))
27}
28
29fn dirs_home() -> Option<PathBuf> {
30    std::env::var_os("HOME").map(PathBuf::from)
31}
32
33/// The directory holding docker-compose.yml, unpacking it if needed.
34///
35/// RUN_PACKAGE_DIR still wins: it is how the port is checked against the shell
36/// version's own copy, and how someone patches a compose file locally.
37pub fn package_dir() -> Result<PathBuf> {
38    if let Some(dir) = std::env::var("RUN_PACKAGE_DIR").ok().filter(|d| !d.is_empty()) {
39        return Ok(PathBuf::from(dir));
40    }
41    let dir = cache_dir()?;
42    let stamp = dir.join(".unpacked");
43    if !stamp.is_file() {
44        unpack(&dir)?;
45        fs::write(&stamp, env!("CARGO_PKG_VERSION"))
46            .with_context(|| format!("writing {}", stamp.display()))?;
47    }
48    Ok(dir)
49}
50
51fn unpack(dir: &Path) -> Result<()> {
52    fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
53    ASSETS
54        .extract(dir)
55        .with_context(|| format!("unpacking the stack files into {}", dir.display()))?;
56
57    // Entrypoints are mounted into containers and executed; the archive does
58    // not carry a mode bit, and they are several directories deep.
59    #[cfg(unix)]
60    make_scripts_executable(&ASSETS, dir)?;
61    Ok(())
62}
63
64#[cfg(unix)]
65fn make_scripts_executable(source: &Dir<'_>, target: &Path) -> Result<()> {
66    use std::os::unix::fs::PermissionsExt;
67    for file in source.files() {
68        let executable = file
69            .path()
70            .extension()
71            .is_some_and(|extension| extension == "sh" || extension == "py");
72        if !executable {
73            continue;
74        }
75        let path = target.join(file.path());
76        let mut permissions = fs::metadata(&path)
77            .with_context(|| format!("reading {}", path.display()))?
78            .permissions();
79        permissions.set_mode(0o755);
80        fs::set_permissions(&path, permissions)
81            .with_context(|| format!("making {} executable", path.display()))?;
82    }
83    for nested in source.dirs() {
84        make_scripts_executable(nested, target)?;
85    }
86    Ok(())
87}
88
89/// One embedded file's contents, for reading without unpacking.
90pub fn read(path: &str) -> Option<&'static str> {
91    ASSETS.get_file(path).and_then(|file| file.contents_utf8())
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn carries_what_a_stack_is_built_from() {
100        assert!(read("docker-compose.yml").is_some_and(|f| f.contains("x-frontend-service")));
101        assert!(read(".env.example").is_some());
102        assert!(ASSETS.get_file("docker/frontend/entrypoint.sh").is_some());
103        assert!(ASSETS.get_file("docker/backend/laravel/Dockerfile").is_some());
104        assert!(ASSETS.get_file("docker/dashboard/server.py").is_some());
105    }
106
107    #[test]
108    fn unpacks_a_usable_tree() {
109        let dir = tempfile::tempdir().unwrap();
110        unpack(dir.path()).unwrap();
111        assert!(dir.path().join("docker-compose.yml").is_file());
112        #[cfg(unix)]
113        {
114            use std::os::unix::fs::PermissionsExt;
115            let entrypoint = dir.path().join("docker/frontend/entrypoint.sh");
116            let mode = fs::metadata(&entrypoint).unwrap().permissions().mode();
117            assert_eq!(mode & 0o111, 0o111, "entrypoints must stay executable");
118        }
119    }
120
121    /// The embedded copy and the shell version's must not drift apart while
122    /// both exist. Skipped when the shell checkout is not next to this one.
123    #[test]
124    fn matches_the_shell_version() {
125        let shell = Path::new(env!("CARGO_MANIFEST_DIR")).join("../run");
126        if !shell.join("docker-compose.yml").is_file() {
127            return;
128        }
129        for name in ["docker-compose.yml", ".env.example"] {
130            let theirs = fs::read_to_string(shell.join(name)).unwrap();
131            assert_eq!(
132                read(name).unwrap(),
133                theirs,
134                "{name} has drifted from ../run — copy it across"
135            );
136        }
137    }
138}