Skip to main content

cloud/reconciler/
dev_door.rs

1//! The dev-tier static surface: the same Worker the fleet serves, in front of
2//! the `s3` capability driver the mirror binds (W265, R584-F4).
3//!
4//! # What this replaces, and why the replacement is the point
5//!
6//! Until this module landed, a dev-tier `mesofact-static` component was served
7//! by `local-static`: the reconciler spawned `mesofact-dev`, which read the
8//! workload's built `dist/` **off the filesystem** and handed it to the
9//! browser. Every other tier published those same bytes into an object store
10//! and served them through a Worker — MinIO + miniflare at pond, R2 + a
11//! deployed Worker at cloud and ha.
12//!
13//! Two interfaces for one contract, which is the fork W265 exists to delete.
14//! It is not an abstract complaint: a `Content-Type` the disk server inferred
15//! differently, a route the Worker rewrites and the disk server does not, a
16//! bucket policy nobody at dev ever had to set — each is a thing the app
17//! discovers at the *next* tier up, which is the most expensive place to
18//! discover it.
19//!
20//! So the dev tier now does what pond does:
21//!
22//! ```text
23//!   dist/  ──publish_to_pond──▶  <s3 driver>/<bucket>  ──ASSET_ORIGIN──▶  miniflare (workerd)
24//! ```
25//!
26//! The only thing that varies across dev / pond / cloud is **which
27//! implementation of `s3` the mirror binds**, and that is declared rather than
28//! branched on. The publish step is literally
29//! [`super::pond_publish::publish_to_pond`], the door is literally
30//! [`super::pond::spawn_miniflare_child`], and the script both run is the same
31//! `worker/router.bundle.js` a fleet node serves.
32//!
33//! # Why there is no container here and one at pond
34//!
35//! miniflare was never the containerized half of pond — MinIO is. miniflare
36//! runs as a plain child process at both tiers (`bun`/`node` executing the
37//! shim, which spawns workerd). What dev drops is the MinIO container, because
38//! `yah-s3-fs` already gives the camp an S3 surface with no docker daemon
39//! anywhere in the picture. That is the whole difference between
40//! `miniflare-container` and `miniflare-native`, and it lives in the store
41//! binding rather than in the serving code.
42//!
43//! # The driver is camp-owned, not mirror-owned
44//!
45//! `yah-s3-fs` is brought up once per camp by `spawn_appliances`
46//! ([`super::s3_driver`]), not by this reconciler, and it is always-on for any
47//! camp with a dev mirror. A `mirror up` therefore *reads* its coordinates and
48//! fails with a pointer at the camp if they are missing — it does not race the
49//! camp to start a second store.
50
51use std::collections::BTreeMap;
52use std::path::PathBuf;
53
54use anyhow::{Context, Result};
55use tokio::sync::oneshot;
56use tracing::{info, warn};
57
58use local_driver::pond_minio::ensure_bucket_public;
59
60use super::pond::{
61    ensure_sim_port_free, run_miniflare_supervisor, spawn_miniflare_child, DoorScripts,
62    PondOptions,
63};
64use super::{into_running, slot_field_u16, ReconcileCtx, RunningWorkload};
65use crate::capability::Capability;
66use crate::config::Provider;
67use crate::reconciler::s3_driver::{running_endpoint, DEV_ACCESS_KEY, DEV_SECRET_KEY};
68
69/// Slot role the door is declared under, shared with the pond arm.
70const STATIC_SLOT: &str = "static";
71
72/// Default loopback port for the dev door — the port `local-static` served on
73/// before it retired, so an operator's bookmark still resolves.
74pub const DEFAULT_DEV_DOOR_PORT: u16 = 4321;
75
76/// `true` when this mirror binds the `s3` capability to the dev-tier driver,
77/// i.e. when [`up_dev_door`] is the right arm for its static slot.
78pub fn binds_dev_store(ctx: &ReconcileCtx<'_>) -> bool {
79    ctx.mirror
80        .driver(Capability::S3)
81        .and_then(|slot| slot.inline_kind())
82        == Some(Provider::LocalS3Fs)
83}
84
85/// Where the dev door keeps the two JS files it writes before spawning.
86///
87/// Under `.yah/infra/state/dev/`, beside the driver's own state, rather than
88/// under `.yah/infra/pond/` — the tiers are separate and a shared directory
89/// would make "is this camp's dev tier up?" unanswerable from the filesystem.
90fn door_dir(ctx: &ReconcileCtx<'_>) -> PathBuf {
91    ctx.workspace_root
92        .join(".yah/infra/state/dev/door")
93        .join(format!("{}-{}", ctx.service.name, ctx.component.id))
94}
95
96fn door_scripts(ctx: &ReconcileCtx<'_>) -> Result<DoorScripts> {
97    let dir = door_dir(ctx);
98    std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
99    Ok(DoorScripts {
100        worker_js: dir.join("worker.js"),
101        miniflare_shim: dir.join("miniflare-sim.mjs"),
102    })
103}
104
105/// Bucket this component's assets live in.
106///
107/// Defaults to the service name because that is what the camp driver creates
108/// (`s3_driver::declared_s3_buckets` names one bucket per service with a dev
109/// mirror) and what `S3_BUCKET` has meant to a dev app since R274-F5. A slot
110/// may still name its own — [`ensure_bucket_public`] creates whatever it is
111/// told, so an override is not a broken mirror.
112fn resolve_bucket(ctx: &ReconcileCtx<'_>, fields: &BTreeMap<String, toml::Value>) -> String {
113    fields
114        .get("bucket")
115        .and_then(|v| v.as_str())
116        .unwrap_or(ctx.service.name.as_str())
117        .to_string()
118}
119
120/// Resolve the store the mirror bound, or say why it cannot be reached.
121///
122/// The error is the useful half of this function. "connection refused on some
123/// port" is what an operator would otherwise get three steps later, from
124/// workerd, about a URL they never typed.
125fn store_endpoint(ctx: &ReconcileCtx<'_>) -> Result<String> {
126    running_endpoint(ctx.workspace_root).with_context(|| {
127        format!(
128            "the dev-tier s3 driver is not running — {} has no coordinates, so there is \
129             nowhere to publish {}/{}'s assets. The camp brings this driver up: start \
130             `yah camp` for this workspace (or attach it in the desktop).",
131            super::s3_driver::coords_path(ctx.workspace_root).display(),
132            ctx.service.name,
133            ctx.component.id,
134        )
135    })
136}
137
138/// Publish `dist/` into the bound bucket. Returns how many objects landed.
139///
140/// Shared with [`sync_dev_door`]; `up` runs it before the door starts routing,
141/// for the same reason the pond arm does — a first bring-up that skipped it
142/// would serve an empty bucket and 404 every request.
143async fn publish(ctx: &ReconcileCtx<'_>, endpoint: &str, bucket: &str) -> Result<usize> {
144    ensure_bucket_public(endpoint, bucket, DEV_ACCESS_KEY, DEV_SECRET_KEY)
145        .await
146        .with_context(|| format!("ensuring dev bucket {bucket} exists and is public-read"))?;
147
148    let out_dir = super::mesofact_static::read_workload_out_dir(&ctx.workload_dir())
149        .unwrap_or_else(|| "dist".to_string());
150    let dist_dir = ctx.workload_dir().join(&out_dir);
151    if !dist_dir.exists() {
152        warn!(
153            dist = %dist_dir.display(),
154            "no built dist to publish — serving existing bucket contents",
155        );
156        return Ok(0);
157    }
158
159    let report = super::pond_publish::publish_to_pond(
160        &dist_dir,
161        endpoint,
162        bucket,
163        DEV_ACCESS_KEY,
164        DEV_SECRET_KEY,
165        None,
166    )
167    .await
168    .with_context(|| format!("publishing {} to dev bucket {bucket}", dist_dir.display()))?;
169    Ok(report.uploaded.len())
170}
171
172/// Bring the dev-tier static surface up: publish, then serve.
173///
174/// `static_fields` are the `providers.static` slot's fields — `port` and an
175/// optional `bucket` override. `worker_script` is the compiled Worker bundle,
176/// injected by the caller so dev runs the same artifact prod does.
177pub async fn up_dev_door(
178    ctx: &ReconcileCtx<'_>,
179    options: &PondOptions,
180    static_fields: &BTreeMap<String, toml::Value>,
181    worker_script: &str,
182) -> Result<RunningWorkload> {
183    let door_port = slot_field_u16(static_fields, "port").unwrap_or(DEFAULT_DEV_DOOR_PORT);
184    let endpoint = store_endpoint(ctx)?;
185    let bucket = resolve_bucket(ctx, static_fields);
186    let scripts = door_scripts(ctx)?;
187
188    let uploaded = publish(ctx, &endpoint, &bucket).await?;
189    info!(
190        uploaded,
191        bucket = %bucket,
192        endpoint = %endpoint,
193        "dev publish complete",
194    );
195
196    let worker_mode =
197        super::mesofact_static::parse_worker_mode(&ctx.component.kind, static_fields);
198
199    let asset_origin = format!("{}/{}", endpoint.trim_end_matches('/'), bucket);
200    let (child, log_buf) = spawn_miniflare_child(
201        ctx,
202        options,
203        door_port,
204        &scripts,
205        &asset_origin,
206        worker_script,
207        &worker_mode,
208    )
209    .await?;
210
211    let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
212    // Unlike the pond arm there is nothing to tear down behind the door: the
213    // store is the camp's, outlives this mirror, and is shared with every other
214    // dev service. Stopping the mirror stops the door and nothing else.
215    let supervisor = tokio::spawn(async move {
216        run_miniflare_supervisor(child, shutdown_rx).await;
217        Ok(())
218    });
219
220    let dev_url = format!("http://127.0.0.1:{door_port}");
221    info!(
222        dev_url = %dev_url,
223        asset_origin = %asset_origin,
224        "dev door ready (miniflare + yah-s3-fs)",
225    );
226
227    Ok(into_running(
228        super::mesofact_static::WORKLOAD_KIND,
229        STATIC_SLOT,
230        Some(dev_url),
231        None,
232        Some(log_buf),
233        shutdown_tx,
234        supervisor,
235    ))
236}
237
238/// Re-publish a *running* dev mirror's `dist/` without touching the door —
239/// the `⟳` affordance, and the exact counterpart of
240/// [`super::pond::sync_pond`].
241///
242/// Re-running [`up_dev_door`] instead would collide on the already-bound door
243/// port, which is the same reason pond has a separate sync entry point.
244pub async fn sync_dev_door(
245    ctx: &ReconcileCtx<'_>,
246    static_fields: &BTreeMap<String, toml::Value>,
247) -> Result<usize> {
248    let endpoint = store_endpoint(ctx)?;
249    let bucket = resolve_bucket(ctx, static_fields);
250    let uploaded = publish(ctx, &endpoint, &bucket).await?;
251    info!(uploaded, bucket = %bucket, "dev re-publish complete");
252    Ok(uploaded)
253}
254
255/// Free the door port if this camp is holding it — used by callers that are
256/// about to re-bind it. Re-exported so the dev arm's failure modes stay in one
257/// module even though the implementation is shared with pond.
258pub async fn ensure_door_port_free(port: u16) -> Result<()> {
259    ensure_sim_port_free(port).await
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use crate::config::{MirrorConfig, ServiceComponent, ServiceConfig};
266    use crate::reconciler::ProviderScope;
267    use std::path::Path;
268
269    fn mirror(src: &str) -> MirrorConfig {
270        toml::from_str(src).expect("parse mirror")
271    }
272
273    fn service() -> ServiceConfig {
274        toml::from_str("schema_version = 1\nname = \"yah-marketing\"\n[address]\nkind = \"front-door\"\ndomain = \"yah.dev\"\n")
275            .expect("parse service")
276    }
277
278    fn component() -> ServiceComponent {
279        toml::from_str(
280            "id = \"site\"\nkind = \"mesofact-static\"\npath = \"app/yah/web\"\nrole = \"static\"\n",
281        )
282            .expect("parse component")
283    }
284
285    fn ctx<'a>(
286        root: &'a Path,
287        service: &'a ServiceConfig,
288        component: &'a ServiceComponent,
289        mirror: &'a MirrorConfig,
290    ) -> ReconcileCtx<'a> {
291        ReconcileCtx {
292            workspace_root: root,
293            service,
294            component,
295            mirror,
296            env: "dev",
297            scope: ProviderScope::singleton(),
298        }
299    }
300
301    #[test]
302    fn the_dev_arm_is_selected_by_the_s3_driver_binding() {
303        let svc = service();
304        let comp = component();
305        let bound = mirror(
306            "schema_version = 1\nshape = \"local\"\n\n\
307             [providers.static]\nkind = \"miniflare-native\"\nport = 4321\n\n\
308             [drivers.s3]\nkind = \"local-s3-fs\"\n",
309        );
310        assert!(binds_dev_store(&ctx(
311            Path::new("/tmp"),
312            &svc,
313            &comp,
314            &bound
315        )));
316
317        // A mirror with a static door but no s3 binding is not a dev mirror —
318        // the door has nothing to serve, and saying so here is what keeps the
319        // pond/prod arms reachable.
320        let unbound = mirror(
321            "schema_version = 1\nshape = \"local\"\n\n\
322             [providers.static]\nkind = \"miniflare-native\"\nport = 4321\n",
323        );
324        assert!(!binds_dev_store(&ctx(
325            Path::new("/tmp"),
326            &svc,
327            &comp,
328            &unbound
329        )));
330    }
331
332    #[test]
333    fn the_bucket_defaults_to_the_service_name_the_camp_created() {
334        let svc = service();
335        let comp = component();
336        let m = mirror("schema_version = 1\nshape = \"local\"\n");
337        let c = ctx(Path::new("/tmp"), &svc, &comp, &m);
338        assert_eq!(resolve_bucket(&c, &BTreeMap::new()), "yah-marketing");
339
340        let mut fields = BTreeMap::new();
341        fields.insert("bucket".to_string(), toml::Value::String("other".into()));
342        assert_eq!(resolve_bucket(&c, &fields), "other");
343    }
344
345    #[tokio::test]
346    async fn a_missing_driver_names_the_coords_file_and_the_camp() {
347        let dir = tempfile::tempdir().expect("tempdir");
348        let svc = service();
349        let comp = component();
350        let m = mirror("schema_version = 1\nshape = \"local\"\n");
351        let err = store_endpoint(&ctx(dir.path(), &svc, &comp, &m))
352            .expect_err("no coords written")
353            .to_string();
354        assert!(err.contains("coords.json"), "{err}");
355        assert!(err.contains("yah camp"), "{err}");
356    }
357}