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    resources(run_dir, env)?;
36    Ok(())
37}
38
39/// Every directory holding a package.json, one level under a workspace root.
40fn package_dirs(frontend: &Path) -> Vec<String> {
41    let mut found = Vec::new();
42    for root in ["apps", "packages"] {
43        let Ok(entries) = fs::read_dir(frontend.join(root)) else {
44            continue;
45        };
46        for entry in entries.flatten() {
47            if entry.path().join("package.json").is_file() {
48                found.push(format!(
49                    "{root}/{}",
50                    entry.file_name().to_string_lossy()
51                ));
52            }
53        }
54    }
55    found.sort();
56    found
57}
58
59/// apps/mobile-client -> fe_nm_apps_mobile_client
60fn volume_name(dir: &str) -> String {
61    format!(
62        "fe_nm_{}",
63        dir.replace(['/', '-'], "_")
64    )
65}
66
67fn extra_apps(env: &Env) -> Vec<String> {
68    env.get_or("EXTRA_APPS", "")
69        .split_whitespace()
70        .map(|name| name.split(':').next().unwrap_or(name).to_string())
71        .collect()
72}
73
74fn mobile_enabled(env: &Env) -> bool {
75    env.is_true("RUN_MOBILE", false)
76}
77
78/// docker-compose.packages.yml
79fn packages(run_dir: &Path, env: &Env) -> Result<()> {
80    let frontend = Path::new(env.get_or("FRONTEND_DIR", ""));
81    let dirs = package_dirs(frontend);
82    let stack = env.get_or("BACKEND_STACK", "laravel").to_string();
83    let backend_in_frontend =
84        env.get_or("BACKEND_DIR", "").trim_end_matches('/') == env.get_or("FRONTEND_DIR", "").trim_end_matches('/');
85    let subdir = env.get_or("BACKEND_SUBDIR", "").trim_end_matches('/').to_string();
86
87    let mut services: Vec<String> = FRONTEND_SERVICES.iter().map(|s| s.to_string()).collect();
88    services.extend(extra_apps(env));
89
90    let mut out = String::from("# Generated by run-stack — do not edit.\nservices:\n");
91
92    // A Node backend needs the same treatment; Laravel keeps vendor/ in the
93    // bind mount, so there is nothing to shadow there.
94    if stack == "node" {
95        for service in ["backend", "queue", "scheduler"] {
96            out.push_str(&format!("  {service}:\n    volumes:\n"));
97            out.push_str("      - pnpm_store:/pnpm-store\n");
98            out.push_str("      - fe_nm_root:/app/node_modules\n");
99            if backend_in_frontend {
100                for dir in &dirs {
101                    out.push_str(&format!("      - {}:/app/{dir}/node_modules\n", volume_name(dir)));
102                }
103                out.push_str("    depends_on:\n      frontend-deps:\n        condition: service_completed_successfully\n");
104            } else if !subdir.is_empty() {
105                out.push_str(&format!("      - be_node_modules:/app/{subdir}/node_modules\n"));
106            }
107        }
108    }
109
110    for service in &services {
111        out.push_str(&format!("  {service}:\n"));
112        // With no mobile app, park Metro behind a profile nothing selects. The
113        // gate goes in this block: a second mapping for one service in one file
114        // is a duplicate key, which compose refuses to parse at all.
115        if !mobile_enabled(env) && METRO_SERVICES.contains(&service.as_str()) {
116            out.push_str("    profiles: [\"__no_mobile\"]\n");
117        }
118        if dirs.is_empty() {
119            continue;
120        }
121        out.push_str("    volumes:\n");
122        for dir in &dirs {
123            out.push_str(&format!("      - {}:/app/{dir}/node_modules\n", volume_name(dir)));
124        }
125    }
126
127    out.push_str("volumes:\n");
128    if stack == "node" && !backend_in_frontend && !subdir.is_empty() {
129        out.push_str("  be_node_modules:\n");
130    }
131    for dir in &dirs {
132        out.push_str(&format!("  {}:\n", volume_name(dir)));
133    }
134
135    write(run_dir.join("docker-compose.packages.yml"), &out)
136}
137
138/// docker-compose.extra.yml — one service per extra app.
139///
140/// The service definition is not written out by hand: the x-* anchor blocks are
141/// copied verbatim out of docker-compose.yml, so an extra app is always built
142/// from the same definition as web, admin and landing.
143fn apps(run_dir: &Path, env: &Env, package_dir: &Path) -> Result<()> {
144    let apps = extra_apps(env);
145    let target = run_dir.join("docker-compose.extra.yml");
146    if apps.is_empty() {
147        let _ = fs::remove_file(&target);
148        return Ok(());
149    }
150
151    let base = fs::read_to_string(package_dir.join("docker-compose.yml"))
152        .with_context(|| format!("reading {}", package_dir.join("docker-compose.yml").display()))?;
153    let anchors: String = base
154        .lines()
155        .skip_while(|line| !line.starts_with("x-"))
156        .take_while(|line| !line.starts_with("services:"))
157        .map(|line| format!("{line}\n"))
158        .collect();
159
160    let frontend = Path::new(env.get_or("FRONTEND_DIR", "")).to_path_buf();
161    let mut out = String::from("# Generated by run-stack — do not edit.\n");
162    out.push_str("# One service per EXTRA_APPS entry; edit run.config.json and re-run up.\n");
163    out.push_str(&anchors);
164    out.push_str("services:\n");
165
166    // The shared install covers the built-in apps; EXTRA_DEPS_APPS is the hook
167    // that adds these to it.
168    out.push_str("  frontend-deps:\n    environment:\n      <<: *frontend-env\n");
169    out.push_str(&format!(
170        "      EXTRA_DEPS_APPS: \"${{EXTRA_DEPS_APPS:-}} {} \"\n\n",
171        apps.join(" ")
172    ));
173
174    let mut metro = Vec::new();
175    out.push_str("  dashboard:\n    environment:\n");
176    out.push_str(&format!("      EXTRA_APPS: \"{}\"\n", env.get_or("EXTRA_APPS", "")));
177    for app in &apps {
178        out.push_str(&format!(
179            "      {}_PORT: \"{}\"\n",
180            key_of(app),
181            port_of(env, app, &frontend)
182        ));
183        if is_metro_app(&frontend, app) {
184            metro.push(app.clone());
185        }
186    }
187    out.push_str(&format!("      EXTRA_APPS_MOBILE: \"{}\"\n\n", metro.join(" ")));
188
189    for app in &apps {
190        let key = key_of(app);
191        let port = port_of(env, app, &frontend);
192        out.push_str(&format!("  {app}:\n    <<: *frontend-service\n    environment:\n      <<: *frontend-env\n"));
193        out.push_str(&format!("      APP_CMD: ${{{key}_CMD:-}}\n"));
194        if metro.contains(app) {
195            // A second Metro, run exactly like mobile-client.
196            out.push_str(&format!("      MOBILE_APP: \"{app}\"\n"));
197            out.push_str(&format!("      MOBILE_STACK: ${{{key}_STACK:-auto}}\n"));
198            out.push_str("      EXPO_NO_TELEMETRY: \"1\"\n");
199            out.push_str("      EXPO_DEVTOOLS_LISTEN_ADDRESS: 0.0.0.0\n");
200            out.push_str("      REACT_NATIVE_PACKAGER_HOSTNAME: ${REACT_NATIVE_PACKAGER_HOSTNAME:-localhost}\n");
201            out.push_str(&format!("      RCT_METRO_PORT: \"{port}\"\n"));
202            out.push_str("      EXPO_PUBLIC_API_BASE_URL: ${EXPO_PUBLIC_API_BASE_URL:-http://localhost:8000/api}\n");
203            out.push_str(&format!("    command: [\"metro\", \"{app}\", \"{port}\"]\n"));
204        } else {
205            out.push_str(&format!("    command: [\"app\", \"{app}\", \"{port}\"]\n"));
206        }
207        out.push_str(&format!("    ports:\n      - \"{port}:{port}\"\n\n"));
208    }
209
210    write(target, &out)
211}
212
213/// An app is a Metro app when its own package.json depends on expo or
214/// react-native. Nothing in the config says so.
215fn is_metro_app(frontend: &Path, app: &str) -> bool {
216    let Some(dir) = app_dir(frontend, app) else {
217        return false;
218    };
219    let Ok(text) = fs::read_to_string(dir.join("package.json")) else {
220        return false;
221    };
222    let Ok(package) = serde_json::from_str::<serde_json::Value>(&text) else {
223        return false;
224    };
225    ["dependencies", "devDependencies"].iter().any(|section| {
226        package[section]
227            .as_object()
228            .is_some_and(|deps| {
229                deps.keys().any(|name| {
230                    name == "expo"
231                        || name.starts_with("expo-")
232                        || name == "react-native"
233                        || name.starts_with("react-native-")
234                })
235            })
236    })
237}
238
239fn app_dir(frontend: &Path, app: &str) -> Option<std::path::PathBuf> {
240    for root in ["apps", "packages"] {
241        let direct = frontend.join(root).join(app);
242        if direct.join("package.json").is_file() {
243            return Some(direct);
244        }
245        // The directory and the workspace name need not match.
246        if let Ok(entries) = fs::read_dir(frontend.join(root)) {
247            for entry in entries.flatten() {
248                let manifest = entry.path().join("package.json");
249                let Ok(text) = fs::read_to_string(&manifest) else {
250                    continue;
251                };
252                let named = serde_json::from_str::<serde_json::Value>(&text)
253                    .ok()
254                    .and_then(|package| package["name"].as_str().map(str::to_string));
255                if named.as_deref() == Some(app) {
256                    return Some(entry.path());
257                }
258            }
259        }
260    }
261    None
262}
263
264fn port_of(env: &Env, app: &str, frontend: &Path) -> u16 {
265    let key = format!("{}_PORT", key_of(app));
266    if let Some(port) = env.get(&key).and_then(|value| value.trim().parse().ok()) {
267        return port;
268    }
269    if is_metro_app(frontend, app) {
270        8082
271    } else {
272        5180
273    }
274}
275
276/// docker-compose.resources.yml — only when limits are actually set.
277fn resources(run_dir: &Path, env: &Env) -> Result<()> {
278    let target = run_dir.join("docker-compose.resources.yml");
279    let pick = |specific: &str, global: &str| -> String {
280        let value = env.get_or(specific, "");
281        if !value.is_empty() {
282            return value.to_string();
283        }
284        env.get_or(global, "").to_string()
285    };
286    let frontend = (
287        pick("FRONTEND_MEMORY_LIMIT", "DOCKER_MEMORY_LIMIT"),
288        pick("FRONTEND_CPU_LIMIT", "DOCKER_CPU_LIMIT"),
289    );
290    let backend = (
291        pick("BACKEND_MEMORY_LIMIT", "DOCKER_MEMORY_LIMIT"),
292        pick("BACKEND_CPU_LIMIT", "DOCKER_CPU_LIMIT"),
293    );
294    let postgres = (
295        pick("POSTGRES_MEMORY_LIMIT", "DOCKER_MEMORY_LIMIT"),
296        pick("POSTGRES_CPU_LIMIT", "DOCKER_CPU_LIMIT"),
297    );
298    let mysql = (
299        pick("MYSQL_MEMORY_LIMIT", "DOCKER_MEMORY_LIMIT"),
300        pick("MYSQL_CPU_LIMIT", "DOCKER_CPU_LIMIT"),
301    );
302
303    let nothing_set = [&frontend, &backend, &postgres, &mysql]
304        .iter()
305        .all(|(memory, cpu)| memory.is_empty() && cpu.is_empty());
306    if nothing_set {
307        // Compose rejects an empty cpus/mem_limit interpolation, so the file
308        // exists only when there is something to put in it.
309        let _ = fs::remove_file(&target);
310        return Ok(());
311    }
312
313    let mut out = String::from("# Generated by run-stack — do not edit.\nservices:\n");
314    let mut emit = |service: &str, limits: &(String, String)| {
315        if limits.0.is_empty() && limits.1.is_empty() {
316            return;
317        }
318        out.push_str(&format!("  {service}:\n"));
319        if !limits.0.is_empty() {
320            out.push_str(&format!("    mem_limit: {}\n", limits.0));
321        }
322        if !limits.1.is_empty() {
323            out.push_str(&format!("    cpus: {}\n", limits.1));
324        }
325    };
326    for service in FRONTEND_SERVICES.iter().chain(["desktop"].iter()) {
327        emit(service, &frontend);
328    }
329    for app in extra_apps(env) {
330        emit(&app, &frontend);
331    }
332    for service in ["backend", "queue", "scheduler"] {
333        emit(service, &backend);
334    }
335    emit("postgres", &postgres);
336    emit("mysql", &mysql);
337
338    write(target, &out)
339}
340
341fn write(path: std::path::PathBuf, contents: &str) -> Result<()> {
342    if let Some(parent) = path.parent() {
343        fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
344    }
345    fs::write(&path, contents).with_context(|| format!("writing {}", path.display()))
346}