Skip to main content

mesofact_dev/
app.rs

1//! The **library-tier dev entry point** — [`serve_app`], the counterpart to
2//! [`mesofact::serve_app`] for a consumer whose routes are Rust handlers.
3//!
4//! This is the `mesofact_dev::serve(app::router())` that W225 §2's Consumer DX
5//! sketch promised and that nothing implemented (R832-T1). The two thin bin
6//! targets a library-tier project carries differ by one identifier:
7//!
8//! ```ignore
9//! // src/bin/yah-dashboard.rs      — the binary CI ships
10//! mesofact::serve_app(yah_dashboard::router(), addr).await
11//!
12//! // src/bin/yah-dashboard-dev.rs  — the binary you run locally
13//! mesofact_dev::serve_app(yah_dashboard::router(), addr).await
14//! ```
15//!
16//! # What the dev half of a library-tier consumer actually is
17//!
18//! R832-T1 existed because this was an open design question, and the answer is
19//! narrower than the standalone tier's dev binary. Recorded here rather than in
20//! a doc, because the next person to add a dev affordance needs it:
21//!
22//! **The watcher and live-reload do not carry over, and cannot.** Both are
23//! functions of a *built `dist/` tree*: [`crate::Watcher`] re-runs the bundler
24//! and rotates `dist` into `.mesofact-dev/gen-N`, and the SSR pool is
25//! re-spawned against the new generation. A library-tier consumer has no such
26//! tree — its routes are Rust functions, and the only edit that changes one is
27//! a `.rs` edit, which requires re-linking the very process that would have to
28//! perform the reload. No in-process affordance can close that loop. The loop
29//! is `cargo watch -x 'run --bin <name>-dev'`, and it lives outside the binary
30//! by necessity, not by omission.
31//!
32//! **The dev object store does carry over, and is the whole of the
33//! difference.** A handler that reads or writes R2 builds its store from
34//! environment coordinates. In prod those point at Cloudflare R2; in dev a
35//! running `yah camp` supplies a local dev-tier S3 driver and injects its
36//! coordinates (R584-T1, W265) — precisely the "local pond emulation" W225 §2
37//! puts in this crate to avoid re-deriving per consumer. [`DevServer::start`]
38//! resolves the store ([`DevStore::resolve`] — the camp's when a camp
39//! injected one, an embedded surface otherwise),
40//! [`DevServer::export_env`] republishes them under the `R2_*` names this
41//! process's own handlers expect, and `.mesofact-dev/s3.json` carries them for
42//! out-of-process tooling (`aws s3 --endpoint-url …`, a test harness).
43//!
44//! So `mesofact_dev::serve_app` is `mesofact::serve_app` plus a local R2 and a
45//! banner. **The smallness is the finding, not a shortfall.** What the two-bin
46//! pattern buys is the link-graph boundary (W225 §2, "always-release,
47//! two-binary pattern") — prod clean by construction because its dependency
48//! closure cannot reach this crate — and that boundary is worth having on day
49//! one, when the dev side adds a single service. Every dev affordance added
50//! here later reaches consumers without any of them changing a line.
51//!
52//! Not implemented, and deliberately not stubbed: permissive CORS and verbose
53//! error overlays. W225 §2 lists both among the dev affordances, but neither
54//! exists anywhere in mesofact today (no `tower_http::cors` use in either
55//! crate), so there is nothing to lift — writing them here would be new
56//! product, not an entry point over existing behaviour.
57//!
58//! @arch:see(.yah/docs/working/W225-mesofact-consumer-deployment-model.md)
59
60use std::net::SocketAddr;
61use std::path::{Path, PathBuf};
62
63use anyhow::{Context, Result};
64use axum::Router;
65use tracing::{info, warn};
66
67use crate::DevStore;
68
69/// Directory, relative to a project root, that holds dev-only scratch state —
70/// the S3 surface's backing store and its discovery file. The standalone tier's
71/// `mes dev` uses the same name, so a project that starts standalone and grows
72/// a Rust half keeps one `.gitignore` line.
73pub const DEV_STATE_DIR: &str = ".mesofact-dev";
74
75/// The dev-tier ambient services, started and ready to serve a caller's
76/// [`Router`].
77///
78/// Prefer the one-call [`serve_app`] unless your `router()` reads the store
79/// coordinates *at construction time* — see [`serve_app`]'s ordering note.
80pub struct DevServer {
81    s3: DevStore,
82    state_dir: PathBuf,
83}
84
85impl DevServer {
86    /// Bring up the dev-tier services for a project rooted at `root`.
87    ///
88    /// Resolves the dev S3 store ([`DevStore::resolve`]) and writes its
89    /// coordinates to `<root>/.mesofact-dev/s3.json`. Does **not** touch the
90    /// process environment — call [`export_env`](Self::export_env) for that,
91    /// before building any router that reads it. Errors only on a *half-set*
92    /// camp injection; with nothing injected it starts an embedded store under
93    /// the state dir, so a standalone `mes` works with no camp.
94    pub async fn start(root: impl AsRef<Path>) -> Result<Self> {
95        let root = root.as_ref();
96        // Canonicalize so the state dir does not move under a handler that
97        // changed the cwd, and so the logged path is the one on disk. A root
98        // that does not exist yet is left as given — `create_dir_all` below
99        // reports it far more clearly than `canonicalize` would.
100        let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
101        let state_dir = root.join(DEV_STATE_DIR);
102        tokio::fs::create_dir_all(&state_dir)
103            .await
104            .with_context(|| format!("creating dev state dir {}", state_dir.display()))?;
105
106        let s3 = DevStore::resolve(&state_dir).await?;
107        info!(
108            endpoint = %s3.endpoint,
109            bucket = %s3.bucket,
110            provenance = ?s3.provenance,
111            "mesofact-dev: dev object store resolved (stands in for R2)",
112        );
113
114        // Discovery file for out-of-process tooling. Best-effort on purpose:
115        // a read-only project dir is a reason to log, not to refuse to serve.
116        let discovery = state_dir.join("s3.json");
117        if let Err(e) = std::fs::write(
118            &discovery,
119            serde_json::json!({ "endpoint": s3.endpoint, "bucket": s3.bucket }).to_string(),
120        ) {
121            warn!(error = %e, path = %discovery.display(), "dev S3: could not write discovery file");
122        }
123
124        Ok(Self { s3, state_dir })
125    }
126
127    /// Coordinates of the running local object store.
128    pub fn s3(&self) -> &DevStore {
129        &self.s3
130    }
131
132    /// The `.mesofact-dev` directory backing this server's scratch state.
133    pub fn state_dir(&self) -> &Path {
134        &self.state_dir
135    }
136
137    /// Publish the store coordinates into this process's environment
138    /// (`R2_ENDPOINT`, `R2_BUCKET`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`
139    /// — see [`DevStore::env_vars`]), so a handler that builds its store from env
140    /// resolves against the local bucket with no dev-specific code.
141    ///
142    /// Existing values are **overwritten**: a dev binary that left a real
143    /// `R2_ENDPOINT` in place would write to production from a laptop, which is
144    /// the one outcome this surface exists to make impossible. Anything already
145    /// set is logged so the override is never silent.
146    pub fn export_env(&self) {
147        for (key, value) in self.s3.env_vars() {
148            if let Ok(prior) = std::env::var(&key) {
149                if prior != value {
150                    warn!(%key, %prior, "mesofact-dev: overriding inherited value with the local store's");
151                }
152            }
153            std::env::set_var(&key, &value);
154        }
155    }
156
157    /// Serve `app` with the standard mesofact stack — the `/livez` + `/readyz`
158    /// probes, the trace layer, and graceful shutdown — until Ctrl+C or
159    /// SIGTERM, exactly as [`mesofact::serve_app`] does in prod.
160    pub async fn serve(self, app: Router, addr: SocketAddr) -> Result<()> {
161        warn!(
162            addr = %addr,
163            s3_endpoint = %self.s3.endpoint,
164            "mesofact-dev: DEV BINARY — dev affordances are linked in; do not ship this target",
165        );
166        mesofact::serve_app(app, addr).await
167    }
168}
169
170/// Start the dev-tier services, publish their coordinates into the environment,
171/// and serve `app` until Ctrl+C or SIGTERM. The dev counterpart of
172/// [`mesofact::serve_app`], and the whole body of a library-tier project's
173/// `src/bin/<name>-dev.rs`.
174///
175/// State lands under `./.mesofact-dev`, relative to the process's current
176/// directory — the project root, when run via `cargo run`.
177///
178/// **Ordering note.** `app` is already built by the time this is called, so a
179/// `router()` that constructs its object store *at construction time* has
180/// already read an unset `R2_ENDPOINT`. Handlers that build the store per
181/// request (or lazily) are unaffected. If yours reads env eagerly, drive the
182/// two halves in order instead:
183///
184/// ```ignore
185/// let dev = mesofact_dev::DevServer::start(".").await?;
186/// dev.export_env();
187/// dev.serve(yah_dashboard::router(), addr).await
188/// ```
189pub async fn serve_app(app: Router, addr: SocketAddr) -> Result<()> {
190    let dev = DevServer::start(std::env::current_dir().context("reading current directory")?)
191        .await?;
192    dev.export_env();
193    dev.serve(app, addr).await
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::test_support::ENV_LOCK;
200    use tempfile::tempdir;
201
202    const REQUIRED_ENV: [&str; 4] = [
203        "S3_ENDPOINT",
204        "S3_BUCKET",
205        "S3_ACCESS_KEY_ID",
206        "S3_SECRET_ACCESS_KEY",
207    ];
208
209    fn set_camp_env() {
210        std::env::set_var("S3_ENDPOINT", "http://127.0.0.1:54321");
211        std::env::set_var("S3_BUCKET", "dev");
212        std::env::set_var("S3_ACCESS_KEY_ID", "ak");
213        std::env::set_var("S3_SECRET_ACCESS_KEY", "sk");
214    }
215
216    fn clear_camp_env() {
217        for var in REQUIRED_ENV {
218            std::env::remove_var(var);
219        }
220        for var in ["R2_ENDPOINT", "R2_BUCKET", "R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY"] {
221            std::env::remove_var(var);
222        }
223    }
224
225    #[tokio::test]
226    async fn start_reads_camp_coordinates_and_writes_discovery_file() {
227        let _guard = ENV_LOCK.lock().await;
228        clear_camp_env();
229        set_camp_env();
230
231        let root = tempdir().unwrap();
232        let dev = DevServer::start(root.path()).await.unwrap();
233
234        assert_eq!(dev.state_dir(), dev.state_dir().canonicalize().unwrap());
235        assert!(dev.state_dir().ends_with(DEV_STATE_DIR));
236        assert_eq!(dev.s3().endpoint, "http://127.0.0.1:54321");
237        assert_eq!(dev.s3().bucket, "dev");
238
239        let discovery: serde_json::Value = serde_json::from_slice(
240            &std::fs::read(dev.state_dir().join("s3.json")).expect("discovery file written"),
241        )
242        .unwrap();
243        assert_eq!(discovery["endpoint"], dev.s3().endpoint);
244        assert_eq!(discovery["bucket"], dev.s3().bucket);
245
246        clear_camp_env();
247    }
248
249    /// The restored embedded arm at the `DevServer` level: with no camp injecting anything,
250    /// `start` must still come up — on an embedded store — and must still
251    /// publish a discovery file, because the standalone tier of
252    /// `check-mesofact-new.sh` reads `.mesofact-dev/s3.json` and dials what it
253    /// finds there. R584-T1 made this case an error; that is what broke the
254    /// release gate.
255    #[tokio::test]
256    async fn start_comes_up_on_an_embedded_store_when_nothing_is_injected() {
257        let _guard = ENV_LOCK.lock().await;
258        clear_camp_env();
259
260        let root = tempdir().unwrap();
261        let dev = DevServer::start(root.path()).await.expect("no camp is fine");
262
263        assert_eq!(dev.s3().provenance, crate::StoreProvenance::Embedded);
264        assert_eq!(dev.s3().bucket, crate::EMBEDDED_BUCKET);
265        assert!(dev.state_dir().join("s3").join(crate::EMBEDDED_BUCKET).is_dir());
266
267        let discovery: serde_json::Value = serde_json::from_slice(
268            &std::fs::read(dev.state_dir().join("s3.json")).expect("discovery file written"),
269        )
270        .unwrap();
271        assert_eq!(discovery["endpoint"], dev.s3().endpoint);
272        // The advertised endpoint answers — the same assertion the smoke's
273        // library tier makes against this file.
274        let addr = dev.s3().endpoint.trim_start_matches("http://");
275        tokio::net::TcpStream::connect(addr)
276            .await
277            .expect("the advertised endpoint accepts");
278
279        clear_camp_env();
280    }
281
282    /// A half-set injection stays a hard error: the embedded arm is for "no
283    /// camp", not for "camp wired up wrong".
284    #[tokio::test]
285    async fn start_errors_on_a_half_set_injection() {
286        let _guard = ENV_LOCK.lock().await;
287        clear_camp_env();
288        std::env::set_var("S3_ENDPOINT", "http://127.0.0.1:54321");
289
290        let root = tempdir().unwrap();
291        let err = match DevServer::start(root.path()).await {
292            Ok(_) => panic!("expected an error on a half-set injection"),
293            Err(e) => e.to_string(),
294        };
295        assert!(err.contains("S3_BUCKET"), "{err}");
296        assert!(err.contains("half-set"), "{err}");
297
298        clear_camp_env();
299    }
300
301    #[tokio::test]
302    async fn export_env_publishes_r2_coordinates_from_camp_env() {
303        let _guard = ENV_LOCK.lock().await;
304        clear_camp_env();
305        set_camp_env();
306
307        let root = tempdir().unwrap();
308        let dev = DevServer::start(root.path()).await.unwrap();
309        dev.export_env();
310
311        assert_eq!(std::env::var("R2_ENDPOINT").unwrap(), "http://127.0.0.1:54321");
312        assert_eq!(std::env::var("R2_BUCKET").unwrap(), "dev");
313        assert_eq!(std::env::var("R2_ACCESS_KEY_ID").unwrap(), "ak");
314        assert_eq!(std::env::var("R2_SECRET_ACCESS_KEY").unwrap(), "sk");
315
316        clear_camp_env();
317    }
318}