1use 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
69const STATIC_SLOT: &str = "static";
71
72pub const DEFAULT_DEV_DOOR_PORT: u16 = 4321;
75
76pub 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
85fn 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
105fn 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
120fn 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
138async 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
172pub 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 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
238pub 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
255pub 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 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}