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    // The dashboard lists what the stack runs; without these it never learns
131    // these services exist.
132    out.push_str("  dashboard:\n    environment:\n");
133    out.push_str(&format!(
134        "      ROOT_APPS: \"{}\"\n",
135        apps.iter()
136            .map(|app| if app.name == app.dir {
137                app.name.clone()
138            } else {
139                format!("{}:{}", app.name, app.dir)
140            })
141            .collect::<Vec<_>>()
142            .join(" ")
143    ));
144    for (index, app) in apps.iter().enumerate() {
145        out.push_str(&format!(
146            "      {}_PORT: \"{}\"\n",
147            key_of(&app.name),
148            root_app_port(env, app, index)
149        ));
150    }
151    out.push('\n');
152
153    out.push_str("volumes:\n");
154    for app in apps {
155        out.push_str(&format!("  {}:\n", root_volume_name(&app.name)));
156    }
157    out
158}
159
160fn root_volume_name(name: &str) -> String {
161    format!("root_nm_{}", name.replace(['/', '-'], "_"))
162}
163
164/// Every directory holding a package.json, one level under a workspace root.
165fn package_dirs(frontend: &Path) -> Vec<String> {
166    let mut found = Vec::new();
167    for root in ["apps", "packages"] {
168        let Ok(entries) = fs::read_dir(frontend.join(root)) else {
169            continue;
170        };
171        for entry in entries.flatten() {
172            if entry.path().join("package.json").is_file() {
173                found.push(format!(
174                    "{root}/{}",
175                    entry.file_name().to_string_lossy()
176                ));
177            }
178        }
179    }
180    found.sort();
181    found
182}
183
184/// apps/mobile-client -> fe_nm_apps_mobile_client
185fn volume_name(dir: &str) -> String {
186    format!(
187        "fe_nm_{}",
188        dir.replace(['/', '-'], "_")
189    )
190}
191
192fn extra_apps(env: &Env) -> Vec<String> {
193    env.get_or("EXTRA_APPS", "")
194        .split_whitespace()
195        .map(|name| name.split(':').next().unwrap_or(name).to_string())
196        .collect()
197}
198
199fn mobile_enabled(env: &Env) -> bool {
200    env.is_true("RUN_MOBILE", false)
201}
202
203/// docker-compose.packages.yml
204fn packages(run_dir: &Path, env: &Env) -> Result<()> {
205    let frontend = Path::new(env.get_or("FRONTEND_DIR", ""));
206    let dirs = package_dirs(frontend);
207    let stack = env.get_or("BACKEND_STACK", "laravel").to_string();
208    let backend_in_frontend =
209        env.get_or("BACKEND_DIR", "").trim_end_matches('/') == env.get_or("FRONTEND_DIR", "").trim_end_matches('/');
210    let subdir = env.get_or("BACKEND_SUBDIR", "").trim_end_matches('/').to_string();
211
212    let mut services: Vec<String> = FRONTEND_SERVICES.iter().map(|s| s.to_string()).collect();
213    services.extend(extra_apps(env));
214
215    let mut out = String::from("# Generated by run-stack — do not edit.\nservices:\n");
216
217    // A Node backend needs the same treatment; Laravel keeps vendor/ in the
218    // bind mount, so there is nothing to shadow there.
219    if stack == "node" {
220        for service in ["backend", "queue", "scheduler"] {
221            out.push_str(&format!("  {service}:\n    volumes:\n"));
222            out.push_str("      - pnpm_store:/pnpm-store\n");
223            out.push_str("      - fe_nm_root:/app/node_modules\n");
224            if backend_in_frontend {
225                for dir in &dirs {
226                    out.push_str(&format!("      - {}:/app/{dir}/node_modules\n", volume_name(dir)));
227                }
228                out.push_str("    depends_on:\n      frontend-deps:\n        condition: service_completed_successfully\n");
229            } else if !subdir.is_empty() {
230                out.push_str(&format!("      - be_node_modules:/app/{subdir}/node_modules\n"));
231            }
232        }
233    }
234
235    for service in &services {
236        out.push_str(&format!("  {service}:\n"));
237        // With no mobile app, park Metro behind a profile nothing selects. The
238        // gate goes in this block: a second mapping for one service in one file
239        // is a duplicate key, which compose refuses to parse at all.
240        if !mobile_enabled(env) && METRO_SERVICES.contains(&service.as_str()) {
241            out.push_str("    profiles: [\"__no_mobile\"]\n");
242        }
243        if dirs.is_empty() {
244            continue;
245        }
246        out.push_str("    volumes:\n");
247        for dir in &dirs {
248            out.push_str(&format!("      - {}:/app/{dir}/node_modules\n", volume_name(dir)));
249        }
250    }
251
252    out.push_str("volumes:\n");
253    if stack == "node" && !backend_in_frontend && !subdir.is_empty() {
254        out.push_str("  be_node_modules:\n");
255    }
256    for dir in &dirs {
257        out.push_str(&format!("  {}:\n", volume_name(dir)));
258    }
259
260    write(run_dir.join("docker-compose.packages.yml"), &out)
261}
262
263/// docker-compose.extra.yml — one service per extra app.
264///
265/// The service definition is not written out by hand: the x-* anchor blocks are
266/// copied verbatim out of docker-compose.yml, so an extra app is always built
267/// from the same definition as web, admin and landing.
268fn apps(run_dir: &Path, env: &Env, package_dir: &Path) -> Result<()> {
269    let apps = extra_apps(env);
270    let target = run_dir.join("docker-compose.extra.yml");
271    if apps.is_empty() {
272        let _ = fs::remove_file(&target);
273        return Ok(());
274    }
275
276    let base = fs::read_to_string(package_dir.join("docker-compose.yml"))
277        .with_context(|| format!("reading {}", package_dir.join("docker-compose.yml").display()))?;
278    let anchors: String = base
279        .lines()
280        .skip_while(|line| !line.starts_with("x-"))
281        .take_while(|line| !line.starts_with("services:"))
282        .map(|line| format!("{line}\n"))
283        .collect();
284
285    let frontend = Path::new(env.get_or("FRONTEND_DIR", "")).to_path_buf();
286    let mut out = String::from("# Generated by run-stack — do not edit.\n");
287    out.push_str("# One service per EXTRA_APPS entry; edit run.config.toml and re-run up.\n");
288    out.push_str(&anchors);
289    out.push_str("services:\n");
290
291    // The shared install covers the built-in apps; EXTRA_DEPS_APPS is the hook
292    // that adds these to it.
293    out.push_str("  frontend-deps:\n    environment:\n      <<: *frontend-env\n");
294    out.push_str(&format!(
295        "      EXTRA_DEPS_APPS: \"${{EXTRA_DEPS_APPS:-}} {} \"\n\n",
296        apps.join(" ")
297    ));
298
299    let mut metro = Vec::new();
300    out.push_str("  dashboard:\n    environment:\n");
301    out.push_str(&format!("      EXTRA_APPS: \"{}\"\n", env.get_or("EXTRA_APPS", "")));
302    for app in &apps {
303        out.push_str(&format!(
304            "      {}_PORT: \"{}\"\n",
305            key_of(app),
306            port_of(env, app, &frontend)
307        ));
308        if is_metro_app(&frontend, app) {
309            metro.push(app.clone());
310        }
311    }
312    out.push_str(&format!("      EXTRA_APPS_MOBILE: \"{}\"\n\n", metro.join(" ")));
313
314    for app in &apps {
315        let key = key_of(app);
316        let port = port_of(env, app, &frontend);
317        out.push_str(&format!("  {app}:\n    <<: *frontend-service\n    environment:\n      <<: *frontend-env\n"));
318        out.push_str(&format!("      APP_CMD: ${{{key}_CMD:-}}\n"));
319        if metro.contains(app) {
320            // A second Metro, run exactly like mobile-client.
321            out.push_str(&format!("      MOBILE_APP: \"{app}\"\n"));
322            out.push_str(&format!("      MOBILE_STACK: ${{{key}_STACK:-auto}}\n"));
323            out.push_str("      EXPO_NO_TELEMETRY: \"1\"\n");
324            out.push_str("      EXPO_DEVTOOLS_LISTEN_ADDRESS: 0.0.0.0\n");
325            out.push_str("      REACT_NATIVE_PACKAGER_HOSTNAME: ${REACT_NATIVE_PACKAGER_HOSTNAME:-localhost}\n");
326            out.push_str(&format!("      RCT_METRO_PORT: \"{port}\"\n"));
327            out.push_str("      EXPO_PUBLIC_API_BASE_URL: ${EXPO_PUBLIC_API_BASE_URL:-http://localhost:8000/api}\n");
328            out.push_str(&format!("    command: [\"metro\", \"{app}\", \"{port}\"]\n"));
329        } else {
330            out.push_str(&format!("    command: [\"app\", \"{app}\", \"{port}\"]\n"));
331        }
332        out.push_str(&format!("    ports:\n      - \"{port}:{port}\"\n\n"));
333    }
334
335    write(target, &out)
336}
337
338/// An app is a Metro app when its own package.json depends on expo or
339/// react-native. Nothing in the config says so.
340fn is_metro_app(frontend: &Path, app: &str) -> bool {
341    let Some(dir) = app_dir(frontend, app) else {
342        return false;
343    };
344    let Ok(text) = fs::read_to_string(dir.join("package.json")) else {
345        return false;
346    };
347    let Ok(package) = serde_json::from_str::<serde_json::Value>(&text) else {
348        return false;
349    };
350    ["dependencies", "devDependencies"].iter().any(|section| {
351        package[section]
352            .as_object()
353            .is_some_and(|deps| {
354                deps.keys().any(|name| {
355                    name == "expo"
356                        || name.starts_with("expo-")
357                        || name == "react-native"
358                        || name.starts_with("react-native-")
359                })
360            })
361    })
362}
363
364fn app_dir(frontend: &Path, app: &str) -> Option<std::path::PathBuf> {
365    for root in ["apps", "packages"] {
366        let direct = frontend.join(root).join(app);
367        if direct.join("package.json").is_file() {
368            return Some(direct);
369        }
370        // The directory and the workspace name need not match.
371        if let Ok(entries) = fs::read_dir(frontend.join(root)) {
372            for entry in entries.flatten() {
373                let manifest = entry.path().join("package.json");
374                let Ok(text) = fs::read_to_string(&manifest) else {
375                    continue;
376                };
377                let named = serde_json::from_str::<serde_json::Value>(&text)
378                    .ok()
379                    .and_then(|package| package["name"].as_str().map(str::to_string));
380                if named.as_deref() == Some(app) {
381                    return Some(entry.path());
382                }
383            }
384        }
385    }
386    None
387}
388
389fn port_of(env: &Env, app: &str, frontend: &Path) -> u16 {
390    let key = format!("{}_PORT", key_of(app));
391    if let Some(port) = env.get(&key).and_then(|value| value.trim().parse().ok()) {
392        return port;
393    }
394    if is_metro_app(frontend, app) {
395        8082
396    } else {
397        5180
398    }
399}
400
401/// docker-compose.resources.yml — only when limits are actually set.
402fn resources(run_dir: &Path, env: &Env) -> Result<()> {
403    let target = run_dir.join("docker-compose.resources.yml");
404    let pick = |specific: &str, global: &str| -> String {
405        let value = env.get_or(specific, "");
406        if !value.is_empty() {
407            return value.to_string();
408        }
409        env.get_or(global, "").to_string()
410    };
411    let frontend = (
412        pick("FRONTEND_MEMORY_LIMIT", "DOCKER_MEMORY_LIMIT"),
413        pick("FRONTEND_CPU_LIMIT", "DOCKER_CPU_LIMIT"),
414    );
415    let backend = (
416        pick("BACKEND_MEMORY_LIMIT", "DOCKER_MEMORY_LIMIT"),
417        pick("BACKEND_CPU_LIMIT", "DOCKER_CPU_LIMIT"),
418    );
419    let postgres = (
420        pick("POSTGRES_MEMORY_LIMIT", "DOCKER_MEMORY_LIMIT"),
421        pick("POSTGRES_CPU_LIMIT", "DOCKER_CPU_LIMIT"),
422    );
423    let mysql = (
424        pick("MYSQL_MEMORY_LIMIT", "DOCKER_MEMORY_LIMIT"),
425        pick("MYSQL_CPU_LIMIT", "DOCKER_CPU_LIMIT"),
426    );
427
428    let nothing_set = [&frontend, &backend, &postgres, &mysql]
429        .iter()
430        .all(|(memory, cpu)| memory.is_empty() && cpu.is_empty());
431    if nothing_set {
432        // Compose rejects an empty cpus/mem_limit interpolation, so the file
433        // exists only when there is something to put in it.
434        let _ = fs::remove_file(&target);
435        return Ok(());
436    }
437
438    let mut out = String::from("# Generated by run-stack — do not edit.\nservices:\n");
439    let mut emit = |service: &str, limits: &(String, String)| {
440        if limits.0.is_empty() && limits.1.is_empty() {
441            return;
442        }
443        out.push_str(&format!("  {service}:\n"));
444        if !limits.0.is_empty() {
445            out.push_str(&format!("    mem_limit: {}\n", limits.0));
446        }
447        if !limits.1.is_empty() {
448            out.push_str(&format!("    cpus: {}\n", limits.1));
449        }
450    };
451    for service in FRONTEND_SERVICES.iter().chain(["desktop"].iter()) {
452        emit(service, &frontend);
453    }
454    for app in extra_apps(env) {
455        emit(&app, &frontend);
456    }
457    for service in ["backend", "queue", "scheduler"] {
458        emit(service, &backend);
459    }
460    emit("postgres", &postgres);
461    emit("mysql", &mysql);
462
463    write(target, &out)
464}
465
466fn write(path: std::path::PathBuf, contents: &str) -> Result<()> {
467    if let Some(parent) = path.parent() {
468        fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
469    }
470    fs::write(&path, contents).with_context(|| format!("writing {}", path.display()))
471}
472
473#[cfg(test)]
474mod root_app_tests {
475    use super::*;
476
477    fn env_with(root_apps: &str) -> Env {
478        let dir = tempfile::tempdir().unwrap();
479        let path = dir.path().join(".env");
480        fs::write(&path, format!("ROOT_APPS={root_apps}\n")).unwrap();
481        Env::load(&path).unwrap()
482    }
483
484    #[test]
485    fn a_bare_name_uses_a_directory_of_the_same_name() {
486        let apps = root_apps(&env_with("seeder"));
487
488        assert_eq!(apps, vec![RootApp { name: "seeder".into(), dir: "seeder".into() }]);
489    }
490
491    #[test]
492    fn a_name_and_directory_can_differ() {
493        let apps = root_apps(&env_with("seeder:althaqeel-seeder"));
494
495        assert_eq!(apps[0].name, "seeder");
496        assert_eq!(apps[0].dir, "althaqeel-seeder");
497    }
498
499    #[test]
500    fn several_apps_are_read_in_order() {
501        let apps = root_apps(&env_with("seeder:althaqeel-seeder tools"));
502
503        assert_eq!(apps.len(), 2);
504        assert_eq!(apps[1].name, "tools");
505    }
506
507    #[test]
508    fn an_empty_setting_yields_nothing() {
509        assert!(root_apps(&env_with("")).is_empty());
510    }
511
512    #[test]
513    fn each_app_is_mounted_from_the_workspace_root() {
514        // The frontend bind mount cannot reach a sibling directory, which is
515        // the whole reason these are not EXTRA_APPS.
516        let env = env_with("seeder:althaqeel-seeder");
517        let yaml = render_root_apps(&env, Path::new("/w"), &root_apps(&env));
518
519        assert!(yaml.contains("- /w/althaqeel-seeder:/app"));
520    }
521
522    #[test]
523    fn the_command_is_not_given_vite_flags() {
524        // `node server.js --host 0.0.0.0 --port 4500` passes those to the
525        // script, which is not what any plain Node app expects.
526        let env = env_with("seeder:althaqeel-seeder");
527        let yaml = render_root_apps(&env, Path::new("/w"), &root_apps(&env));
528
529        assert!(yaml.contains(r#"command: ["root-app", "seeder", "4500"]"#));
530        assert!(!yaml.contains("--strictPort"));
531    }
532
533    #[test]
534    fn a_configured_port_wins_over_the_default() {
535        let dir = tempfile::tempdir().unwrap();
536        let path = dir.path().join(".env");
537        fs::write(&path, "ROOT_APPS=seeder\nSEEDER_PORT=4600\n").unwrap();
538        let env = Env::load(&path).unwrap();
539
540        assert!(render_root_apps(&env, Path::new("/w"), &root_apps(&env)).contains("\"4600:4600\""));
541    }
542
543    #[test]
544    fn each_app_declares_its_own_node_modules_volume() {
545        let env = env_with("seeder:althaqeel-seeder");
546        let yaml = render_root_apps(&env, Path::new("/w"), &root_apps(&env));
547
548        assert!(yaml.contains("- root_nm_seeder:/app/node_modules"));
549        assert!(yaml.contains("volumes:\n  root_nm_seeder:"));
550    }
551
552    #[test]
553    fn the_overlay_is_removed_when_no_root_apps_remain() {
554        let dir = tempfile::tempdir().unwrap();
555        let target = dir.path().join("docker-compose.root-apps.yml");
556        fs::write(&target, "stale\n").unwrap();
557
558        root_apps_file(dir.path(), &env_with("")).unwrap();
559
560        assert!(!target.exists());
561    }
562}
563
564#[cfg(test)]
565mod root_app_mount_tests {
566    use super::*;
567
568    #[test]
569    fn a_root_app_mounts_the_entrypoint_like_the_frontend_does() {
570        // Without it the container runs whatever entrypoint the image was
571        // built with, and a new role looks like a missing command.
572        let dir = tempfile::tempdir().unwrap();
573        let path = dir.path().join(".env");
574        fs::write(&path, "ROOT_APPS=seeder\n").unwrap();
575        let env = Env::load(&path).unwrap();
576
577        let yaml = render_root_apps(&env, Path::new("/w"), &root_apps(&env));
578
579        assert!(yaml.contains("./docker/frontend/entrypoint.sh:/usr/local/bin/entrypoint.sh:ro"));
580    }
581}