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}