Skip to main content

run_stack/
generate.rs

1//! The generated compose overlays.
2//!
3//! Three files are written into .run/ before every up: one shadowing every
4//! workspace package's node_modules with a named volume, one adding a service
5//! per extra app, and one carrying optional CPU/memory limits. They depend on
6//! what the workspace actually contains, so they are regenerated rather than
7//! committed.
8
9use std::fs;
10use std::path::Path;
11
12use anyhow::{Context, Result};
13
14use crate::config::key_of;
15use crate::env::Env;
16
17/// The built-in services that mount the frontend monorepo.
18const FRONTEND_SERVICES: &[&str] = &[
19    "frontend-deps",
20    "frontend-packages",
21    "frontend-sync",
22    "web",
23    "admin",
24    "landing",
25    "mobile-deps",
26    "mobile-packages",
27    "mobile-client",
28];
29
30const METRO_SERVICES: &[&str] = &["mobile-deps", "mobile-packages", "mobile-client"];
31
32pub fn all(run_dir: &Path, env: &Env, package_dir: &Path) -> Result<()> {
33    packages(run_dir, env)?;
34    apps(run_dir, env, package_dir)?;
35    root_apps_file(run_dir, env)?;
36    resources(run_dir, env)?;
37    Ok(())
38}
39
40/// An app that lives beside the frontend repo rather than inside its apps/.
41///
42/// `EXTRA_APPS` cannot reach these: those share the frontend bind mount and are
43/// started through `pnpm --filter`, and a sibling directory is in neither the
44/// mount nor the workspace. Each one gets its own mount and runs its own
45/// command verbatim.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct RootApp {
48    pub name: String,
49    pub dir: String,
50}
51
52/// `ROOT_APPS="seeder:althaqeel-seeder other"` - `name:dir`, or a bare name
53/// when the directory matches it. Paths are relative to the workspace root.
54pub fn root_apps(env: &Env) -> Vec<RootApp> {
55    env.get_or("ROOT_APPS", "")
56        .split_whitespace()
57        .filter_map(|entry| {
58            let (name, dir) = match entry.split_once(':') {
59                Some((name, dir)) if !name.is_empty() && !dir.is_empty() => (name, dir),
60                _ => (entry, entry),
61            };
62            if name.is_empty() {
63                return None;
64            }
65            Some(RootApp {
66                name: name.to_string(),
67                dir: dir.trim_end_matches('/').to_string(),
68            })
69        })
70        .collect()
71}
72
73fn root_app_port(env: &Env, app: &RootApp, index: usize) -> u16 {
74    let key = format!("{}_PORT", key_of(&app.name));
75    env.get(&key)
76        .and_then(|value| value.trim().parse().ok())
77        .unwrap_or(4500 + index as u16)
78}
79
80fn root_apps_file(run_dir: &Path, env: &Env) -> Result<()> {
81    let apps = root_apps(env);
82    let target = run_dir.join("docker-compose.root-apps.yml");
83    if apps.is_empty() {
84        let _ = fs::remove_file(&target);
85        return Ok(());
86    }
87    // .run/ sits in the workspace root, and the mount has to be absolute:
88    // compose resolves a relative path against its own project directory,
89    // which is the unpacked package, not this workspace.
90    let root = run_dir.parent().unwrap_or(run_dir);
91    write(target, &render_root_apps(env, root, &apps))
92}
93
94fn render_root_apps(env: &Env, root: &Path, apps: &[RootApp]) -> String {
95    let mut out = String::from("# Generated by run-stack - do not edit.\n");
96    out.push_str("# One service per ROOT_APPS entry: an app beside the frontend repo,\n");
97    out.push_str("# mounted on its own and started with its own command.\n");
98    out.push_str("services:\n");
99
100    for (index, app) in apps.iter().enumerate() {
101        let key = key_of(&app.name);
102        let port = root_app_port(env, app, index);
103        let volume = root_volume_name(&app.name);
104
105        out.push_str(&format!("  {}:\n", app.name));
106        out.push_str("    build:\n      context: ./docker/frontend\n");
107        out.push_str("    image: ${COMPOSE_PROJECT_NAME:-myapp}/frontend:local\n");
108        out.push_str("    working_dir: /app\n");
109        out.push_str("    environment:\n");
110        out.push_str(&format!("      APP_CMD: ${{{key}_CMD:-npm run dev}}\n"));
111        out.push_str(&format!("      PORT: \"{port}\"\n"));
112        out.push_str("    volumes:\n");
113        out.push_str(&format!("      - {}/{}:/app\n", root.display(), app.dir));
114        // The same mounts the frontend services get, so an entrypoint edit
115        // takes effect on restart instead of needing the image rebuilt.
116        out.push_str("      - ./docker/frontend/entrypoint.sh:/usr/local/bin/entrypoint.sh:ro\n");
117        out.push_str("      - ./docker/frontend/mobile-stack.sh:/usr/local/bin/mobile-stack.sh:ro\n");
118        out.push_str("      - pnpm_store:/pnpm-store\n");
119        // A sibling directory brings its own host node_modules, built for the
120        // host platform; the volume keeps the container's own copy separate.
121        out.push_str(&format!("      - {volume}:/app/node_modules\n"));
122        out.push_str(&format!(
123            "    command: [\"root-app\", \"{}\", \"{port}\"]\n",
124            app.name
125        ));
126        out.push_str(&format!("    ports:\n      - \"{port}:{port}\"\n"));
127        out.push_str("    restart: unless-stopped\n\n");
128    }
129
130    out.push_str("volumes:\n");
131    for app in apps {
132        out.push_str(&format!("  {}:\n", root_volume_name(&app.name)));
133    }
134    out
135}
136
137fn root_volume_name(name: &str) -> String {
138    format!("root_nm_{}", name.replace(['/', '-'], "_"))
139}
140
141/// Every directory holding a package.json, one level under a workspace root.
142fn package_dirs(frontend: &Path) -> Vec<String> {
143    let mut found = Vec::new();
144    for root in ["apps", "packages"] {
145        let Ok(entries) = fs::read_dir(frontend.join(root)) else {
146            continue;
147        };
148        for entry in entries.flatten() {
149            if entry.path().join("package.json").is_file() {
150                found.push(format!(
151                    "{root}/{}",
152                    entry.file_name().to_string_lossy()
153                ));
154            }
155        }
156    }
157    found.sort();
158    found
159}
160
161/// apps/mobile-client -> fe_nm_apps_mobile_client
162fn volume_name(dir: &str) -> String {
163    format!(
164        "fe_nm_{}",
165        dir.replace(['/', '-'], "_")
166    )
167}
168
169fn extra_apps(env: &Env) -> Vec<String> {
170    env.get_or("EXTRA_APPS", "")
171        .split_whitespace()
172        .map(|name| name.split(':').next().unwrap_or(name).to_string())
173        .collect()
174}
175
176fn mobile_enabled(env: &Env) -> bool {
177    env.is_true("RUN_MOBILE", false)
178}
179
180/// docker-compose.packages.yml
181fn packages(run_dir: &Path, env: &Env) -> Result<()> {
182    let frontend = Path::new(env.get_or("FRONTEND_DIR", ""));
183    let dirs = package_dirs(frontend);
184    let stack = env.get_or("BACKEND_STACK", "laravel").to_string();
185    let backend_in_frontend =
186        env.get_or("BACKEND_DIR", "").trim_end_matches('/') == env.get_or("FRONTEND_DIR", "").trim_end_matches('/');
187    let subdir = env.get_or("BACKEND_SUBDIR", "").trim_end_matches('/').to_string();
188
189    let mut services: Vec<String> = FRONTEND_SERVICES.iter().map(|s| s.to_string()).collect();
190    services.extend(extra_apps(env));
191
192    let mut out = String::from("# Generated by run-stack — do not edit.\nservices:\n");
193
194    // A Node backend needs the same treatment; Laravel keeps vendor/ in the
195    // bind mount, so there is nothing to shadow there.
196    if stack == "node" {
197        for service in ["backend", "queue", "scheduler"] {
198            out.push_str(&format!("  {service}:\n    volumes:\n"));
199            out.push_str("      - pnpm_store:/pnpm-store\n");
200            out.push_str("      - fe_nm_root:/app/node_modules\n");
201            if backend_in_frontend {
202                for dir in &dirs {
203                    out.push_str(&format!("      - {}:/app/{dir}/node_modules\n", volume_name(dir)));
204                }
205                out.push_str("    depends_on:\n      frontend-deps:\n        condition: service_completed_successfully\n");
206            } else if !subdir.is_empty() {
207                out.push_str(&format!("      - be_node_modules:/app/{subdir}/node_modules\n"));
208            }
209        }
210    }
211
212    for service in &services {
213        out.push_str(&format!("  {service}:\n"));
214        // With no mobile app, park Metro behind a profile nothing selects. The
215        // gate goes in this block: a second mapping for one service in one file
216        // is a duplicate key, which compose refuses to parse at all.
217        if !mobile_enabled(env) && METRO_SERVICES.contains(&service.as_str()) {
218            out.push_str("    profiles: [\"__no_mobile\"]\n");
219        }
220        if dirs.is_empty() {
221            continue;
222        }
223        out.push_str("    volumes:\n");
224        for dir in &dirs {
225            out.push_str(&format!("      - {}:/app/{dir}/node_modules\n", volume_name(dir)));
226        }
227    }
228
229    out.push_str("volumes:\n");
230    if stack == "node" && !backend_in_frontend && !subdir.is_empty() {
231        out.push_str("  be_node_modules:\n");
232    }
233    for dir in &dirs {
234        out.push_str(&format!("  {}:\n", volume_name(dir)));
235    }
236
237    write(run_dir.join("docker-compose.packages.yml"), &out)
238}
239
240/// docker-compose.extra.yml — one service per extra app.
241///
242/// The service definition is not written out by hand: the x-* anchor blocks are
243/// copied verbatim out of docker-compose.yml, so an extra app is always built
244/// from the same definition as web, admin and landing.
245fn apps(run_dir: &Path, env: &Env, package_dir: &Path) -> Result<()> {
246    let apps = extra_apps(env);
247    let target = run_dir.join("docker-compose.extra.yml");
248    if apps.is_empty() {
249        let _ = fs::remove_file(&target);
250        return Ok(());
251    }
252
253    let base = fs::read_to_string(package_dir.join("docker-compose.yml"))
254        .with_context(|| format!("reading {}", package_dir.join("docker-compose.yml").display()))?;
255    let anchors: String = base
256        .lines()
257        .skip_while(|line| !line.starts_with("x-"))
258        .take_while(|line| !line.starts_with("services:"))
259        .map(|line| format!("{line}\n"))
260        .collect();
261
262    let frontend = Path::new(env.get_or("FRONTEND_DIR", "")).to_path_buf();
263    let mut out = String::from("# Generated by run-stack — do not edit.\n");
264    out.push_str("# One service per EXTRA_APPS entry; edit run.config.toml and re-run up.\n");
265    out.push_str(&anchors);
266    out.push_str("services:\n");
267
268    // The shared install covers the built-in apps; EXTRA_DEPS_APPS is the hook
269    // that adds these to it.
270    out.push_str("  frontend-deps:\n    environment:\n      <<: *frontend-env\n");
271    out.push_str(&format!(
272        "      EXTRA_DEPS_APPS: \"${{EXTRA_DEPS_APPS:-}} {} \"\n\n",
273        apps.join(" ")
274    ));
275
276    let mut metro = Vec::new();
277    out.push_str("  dashboard:\n    environment:\n");
278    out.push_str(&format!("      EXTRA_APPS: \"{}\"\n", env.get_or("EXTRA_APPS", "")));
279    for app in &apps {
280        out.push_str(&format!(
281            "      {}_PORT: \"{}\"\n",
282            key_of(app),
283            port_of(env, app, &frontend)
284        ));
285        if is_metro_app(&frontend, app) {
286            metro.push(app.clone());
287        }
288    }
289    out.push_str(&format!("      EXTRA_APPS_MOBILE: \"{}\"\n\n", metro.join(" ")));
290
291    for app in &apps {
292        let key = key_of(app);
293        let port = port_of(env, app, &frontend);
294        out.push_str(&format!("  {app}:\n    <<: *frontend-service\n    environment:\n      <<: *frontend-env\n"));
295        out.push_str(&format!("      APP_CMD: ${{{key}_CMD:-}}\n"));
296        if metro.contains(app) {
297            // A second Metro, run exactly like mobile-client.
298            out.push_str(&format!("      MOBILE_APP: \"{app}\"\n"));
299            out.push_str(&format!("      MOBILE_STACK: ${{{key}_STACK:-auto}}\n"));
300            out.push_str("      EXPO_NO_TELEMETRY: \"1\"\n");
301            out.push_str("      EXPO_DEVTOOLS_LISTEN_ADDRESS: 0.0.0.0\n");
302            out.push_str("      REACT_NATIVE_PACKAGER_HOSTNAME: ${REACT_NATIVE_PACKAGER_HOSTNAME:-localhost}\n");
303            out.push_str(&format!("      RCT_METRO_PORT: \"{port}\"\n"));
304            out.push_str("      EXPO_PUBLIC_API_BASE_URL: ${EXPO_PUBLIC_API_BASE_URL:-http://localhost:8000/api}\n");
305            out.push_str(&format!("    command: [\"metro\", \"{app}\", \"{port}\"]\n"));
306        } else {
307            out.push_str(&format!("    command: [\"app\", \"{app}\", \"{port}\"]\n"));
308        }
309        out.push_str(&format!("    ports:\n      - \"{port}:{port}\"\n\n"));
310    }
311
312    write(target, &out)
313}
314
315/// An app is a Metro app when its own package.json depends on expo or
316/// react-native. Nothing in the config says so.
317fn is_metro_app(frontend: &Path, app: &str) -> bool {
318    let Some(dir) = app_dir(frontend, app) else {
319        return false;
320    };
321    let Ok(text) = fs::read_to_string(dir.join("package.json")) else {
322        return false;
323    };
324    let Ok(package) = serde_json::from_str::<serde_json::Value>(&text) else {
325        return false;
326    };
327    ["dependencies", "devDependencies"].iter().any(|section| {
328        package[section]
329            .as_object()
330            .is_some_and(|deps| {
331                deps.keys().any(|name| {
332                    name == "expo"
333                        || name.starts_with("expo-")
334                        || name == "react-native"
335                        || name.starts_with("react-native-")
336                })
337            })
338    })
339}
340
341fn app_dir(frontend: &Path, app: &str) -> Option<std::path::PathBuf> {
342    for root in ["apps", "packages"] {
343        let direct = frontend.join(root).join(app);
344        if direct.join("package.json").is_file() {
345            return Some(direct);
346        }
347        // The directory and the workspace name need not match.
348        if let Ok(entries) = fs::read_dir(frontend.join(root)) {
349            for entry in entries.flatten() {
350                let manifest = entry.path().join("package.json");
351                let Ok(text) = fs::read_to_string(&manifest) else {
352                    continue;
353                };
354                let named = serde_json::from_str::<serde_json::Value>(&text)
355                    .ok()
356                    .and_then(|package| package["name"].as_str().map(str::to_string));
357                if named.as_deref() == Some(app) {
358                    return Some(entry.path());
359                }
360            }
361        }
362    }
363    None
364}
365
366fn port_of(env: &Env, app: &str, frontend: &Path) -> u16 {
367    let key = format!("{}_PORT", key_of(app));
368    if let Some(port) = env.get(&key).and_then(|value| value.trim().parse().ok()) {
369        return port;
370    }
371    if is_metro_app(frontend, app) {
372        8082
373    } else {
374        5180
375    }
376}
377
378/// docker-compose.resources.yml — only when limits are actually set.
379fn resources(run_dir: &Path, env: &Env) -> Result<()> {
380    let target = run_dir.join("docker-compose.resources.yml");
381    let pick = |specific: &str, global: &str| -> String {
382        let value = env.get_or(specific, "");
383        if !value.is_empty() {
384            return value.to_string();
385        }
386        env.get_or(global, "").to_string()
387    };
388    let frontend = (
389        pick("FRONTEND_MEMORY_LIMIT", "DOCKER_MEMORY_LIMIT"),
390        pick("FRONTEND_CPU_LIMIT", "DOCKER_CPU_LIMIT"),
391    );
392    let backend = (
393        pick("BACKEND_MEMORY_LIMIT", "DOCKER_MEMORY_LIMIT"),
394        pick("BACKEND_CPU_LIMIT", "DOCKER_CPU_LIMIT"),
395    );
396    let postgres = (
397        pick("POSTGRES_MEMORY_LIMIT", "DOCKER_MEMORY_LIMIT"),
398        pick("POSTGRES_CPU_LIMIT", "DOCKER_CPU_LIMIT"),
399    );
400    let mysql = (
401        pick("MYSQL_MEMORY_LIMIT", "DOCKER_MEMORY_LIMIT"),
402        pick("MYSQL_CPU_LIMIT", "DOCKER_CPU_LIMIT"),
403    );
404
405    let nothing_set = [&frontend, &backend, &postgres, &mysql]
406        .iter()
407        .all(|(memory, cpu)| memory.is_empty() && cpu.is_empty());
408    if nothing_set {
409        // Compose rejects an empty cpus/mem_limit interpolation, so the file
410        // exists only when there is something to put in it.
411        let _ = fs::remove_file(&target);
412        return Ok(());
413    }
414
415    let mut out = String::from("# Generated by run-stack — do not edit.\nservices:\n");
416    let mut emit = |service: &str, limits: &(String, String)| {
417        if limits.0.is_empty() && limits.1.is_empty() {
418            return;
419        }
420        out.push_str(&format!("  {service}:\n"));
421        if !limits.0.is_empty() {
422            out.push_str(&format!("    mem_limit: {}\n", limits.0));
423        }
424        if !limits.1.is_empty() {
425            out.push_str(&format!("    cpus: {}\n", limits.1));
426        }
427    };
428    for service in FRONTEND_SERVICES.iter().chain(["desktop"].iter()) {
429        emit(service, &frontend);
430    }
431    for app in extra_apps(env) {
432        emit(&app, &frontend);
433    }
434    for service in ["backend", "queue", "scheduler"] {
435        emit(service, &backend);
436    }
437    emit("postgres", &postgres);
438    emit("mysql", &mysql);
439
440    write(target, &out)
441}
442
443fn write(path: std::path::PathBuf, contents: &str) -> Result<()> {
444    if let Some(parent) = path.parent() {
445        fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
446    }
447    fs::write(&path, contents).with_context(|| format!("writing {}", path.display()))
448}
449
450#[cfg(test)]
451mod root_app_tests {
452    use super::*;
453
454    fn env_with(root_apps: &str) -> Env {
455        let dir = tempfile::tempdir().unwrap();
456        let path = dir.path().join(".env");
457        fs::write(&path, format!("ROOT_APPS={root_apps}\n")).unwrap();
458        Env::load(&path).unwrap()
459    }
460
461    #[test]
462    fn a_bare_name_uses_a_directory_of_the_same_name() {
463        let apps = root_apps(&env_with("seeder"));
464
465        assert_eq!(apps, vec![RootApp { name: "seeder".into(), dir: "seeder".into() }]);
466    }
467
468    #[test]
469    fn a_name_and_directory_can_differ() {
470        let apps = root_apps(&env_with("seeder:althaqeel-seeder"));
471
472        assert_eq!(apps[0].name, "seeder");
473        assert_eq!(apps[0].dir, "althaqeel-seeder");
474    }
475
476    #[test]
477    fn several_apps_are_read_in_order() {
478        let apps = root_apps(&env_with("seeder:althaqeel-seeder tools"));
479
480        assert_eq!(apps.len(), 2);
481        assert_eq!(apps[1].name, "tools");
482    }
483
484    #[test]
485    fn an_empty_setting_yields_nothing() {
486        assert!(root_apps(&env_with("")).is_empty());
487    }
488
489    #[test]
490    fn each_app_is_mounted_from_the_workspace_root() {
491        // The frontend bind mount cannot reach a sibling directory, which is
492        // the whole reason these are not EXTRA_APPS.
493        let env = env_with("seeder:althaqeel-seeder");
494        let yaml = render_root_apps(&env, Path::new("/w"), &root_apps(&env));
495
496        assert!(yaml.contains("- /w/althaqeel-seeder:/app"));
497    }
498
499    #[test]
500    fn the_command_is_not_given_vite_flags() {
501        // `node server.js --host 0.0.0.0 --port 4500` passes those to the
502        // script, which is not what any plain Node app expects.
503        let env = env_with("seeder:althaqeel-seeder");
504        let yaml = render_root_apps(&env, Path::new("/w"), &root_apps(&env));
505
506        assert!(yaml.contains(r#"command: ["root-app", "seeder", "4500"]"#));
507        assert!(!yaml.contains("--strictPort"));
508    }
509
510    #[test]
511    fn a_configured_port_wins_over_the_default() {
512        let dir = tempfile::tempdir().unwrap();
513        let path = dir.path().join(".env");
514        fs::write(&path, "ROOT_APPS=seeder\nSEEDER_PORT=4600\n").unwrap();
515        let env = Env::load(&path).unwrap();
516
517        assert!(render_root_apps(&env, Path::new("/w"), &root_apps(&env)).contains("\"4600:4600\""));
518    }
519
520    #[test]
521    fn each_app_declares_its_own_node_modules_volume() {
522        let env = env_with("seeder:althaqeel-seeder");
523        let yaml = render_root_apps(&env, Path::new("/w"), &root_apps(&env));
524
525        assert!(yaml.contains("- root_nm_seeder:/app/node_modules"));
526        assert!(yaml.contains("volumes:\n  root_nm_seeder:"));
527    }
528
529    #[test]
530    fn the_overlay_is_removed_when_no_root_apps_remain() {
531        let dir = tempfile::tempdir().unwrap();
532        let target = dir.path().join("docker-compose.root-apps.yml");
533        fs::write(&target, "stale\n").unwrap();
534
535        root_apps_file(dir.path(), &env_with("")).unwrap();
536
537        assert!(!target.exists());
538    }
539}
540
541#[cfg(test)]
542mod root_app_mount_tests {
543    use super::*;
544
545    #[test]
546    fn a_root_app_mounts_the_entrypoint_like_the_frontend_does() {
547        // Without it the container runs whatever entrypoint the image was
548        // built with, and a new role looks like a missing command.
549        let dir = tempfile::tempdir().unwrap();
550        let path = dir.path().join(".env");
551        fs::write(&path, "ROOT_APPS=seeder\n").unwrap();
552        let env = Env::load(&path).unwrap();
553
554        let yaml = render_root_apps(&env, Path::new("/w"), &root_apps(&env));
555
556        assert!(yaml.contains("./docker/frontend/entrypoint.sh:/usr/local/bin/entrypoint.sh:ro"));
557    }
558}