Skip to main content

cloud/reconciler/
s3_driver.rs

1//! Bring-up for the dev-tier `s3` capability driver — the `yah-s3-fs`
2//! workload (W265, R584-F3).
3//!
4//! Fourth instance of the capability/driver shape behind [`super::pg_driver`]
5//! and [`super::smtp_driver`], and the same shape on purpose: a free function
6//! the camp daemon calls once at tier bring-up rather than a
7//! [`super::Reconciler`], because a driver is the *tier's* implementation of a
8//! capability rather than anybody's component. `pg_driver`'s module doc has
9//! the full argument and everything it says about kamaji's role applies here
10//! unchanged.
11//!
12//! # The one place this deliberately differs from pg and smtp
13//!
14//! **Activation is always-on, not keyed off a `[drivers.s3]` stanza.**
15//!
16//! pg and smtp both gate on a mirror declaring the binding, and that is right
17//! for them: most services never open a database and fewer still send mail, so
18//! a default-on driver would have every camp on the machine downloading and
19//! supervising something nobody opens. Neither reason holds here.
20//!
21//! - Object storage is what a *static* service needs, and static is the
22//!   default shape in this tree. [`Capability::for_component_kind`] already
23//!   returns `[S3]` for `mesofact-static`, `mesofact-spa` and `static-asset`
24//!   without anyone declaring anything.
25//! - The cost of being wrong is a few MB. This driver fetches nothing and
26//!   spawns nothing: it is one small Rust binary holding a listener and a
27//!   directory, where pg is a PostgreSQL install and smtp is a 10 MB download.
28//! - It is *replacing* something that was already unconditional. The camp
29//!   daemon has spawned an in-process S3 stub for every service with a dev
30//!   mirror since R274-F5. Making the de-embedded driver opt-in would be a
31//!   behaviour regression dressed as caution — every existing dev service
32//!   would lose its `S3_ENDPOINT`.
33//!
34//! So [`camp_needs_s3_driver`] is true for any camp with a dev mirror at all,
35//! and an explicit `[drivers.s3] kind = "local-s3-fs"` is documentation rather
36//! than a switch. The stanza still matters for the *other* tiers, where the
37//! same capability binds `minio-container` or `cloudflare-r2`.
38//!
39//! # Buckets
40//!
41//! One per service with a dev mirror, named after the service — which is the
42//! `S3_BUCKET` the camp has injected since R274-F5, so nothing downstream
43//! changes. They are created before `coords.json` is published, making the
44//! coords file mean "you can PUT now" rather than "the port is open".
45
46use std::collections::BTreeMap;
47use std::net::Ipv4Addr;
48use std::path::{Path, PathBuf};
49use std::sync::Arc;
50use std::time::{Duration, Instant};
51
52use anyhow::{Context, Result};
53use kamaji::native::NativeRuntime;
54use kamaji::{Kamaji, MeshAssignment, MeshIdent};
55use tracing::{info, warn};
56use workload_spec::MeshPort;
57
58use super::native_support::{native_spec, sanitize_ident};
59use crate::capability::Capability;
60use crate::config::{MirrorConfig, Provider, ServiceWithMirrors};
61
62/// Mesh ident of the camp's single S3 driver. Camp-scoped, not per-service —
63/// one store holds every service's bucket, the same way one MinIO serves every
64/// pond bucket. The stub this replaces ran one server *per service*, which was
65/// the one thing about it that did not match any other tier.
66pub const S3_DRIVER_IDENT: &str = "yah-s3-fs";
67
68/// Environment variable overriding the `yah-s3-fs` binary path, mirroring
69/// [`super::pg_driver::PG_DEV_BIN_ENV`].
70pub const S3_FS_BIN_ENV: &str = "YAH_S3_FS_BIN";
71
72/// Port name declared in `expose.mesh.ports`. kamaji uppercases it into
73/// `PORT_S3`, which the driver reads.
74pub const PORT_NAME_S3: &str = "s3";
75
76/// Dev-tier mirror env name. The driver is a dev-tier implementation; pond and
77/// cloud bind their own drivers for the same capability.
78const DEV_ENV: &str = "dev";
79
80/// Access key an in-camp consumer signs with.
81///
82/// Fixed strings rather than generated ones, and that is deliberate: the
83/// listener is loopback/veth-only and the driver does not verify signature
84/// *bytes* (W265 §"Signature bytes are not verified"), so what these buy is
85/// the presence of an `Authorization` header — which is what separates the
86/// publisher from a browser and therefore what makes the bucket-policy path
87/// mean anything. A consumer that needs to reach the store from outside the
88/// camp is a different tier's problem.
89pub const DEV_ACCESS_KEY: &str = "yahdev";
90
91/// Secret key paired with [`DEV_ACCESS_KEY`]. Not a credential in any
92/// meaningful sense — see that constant.
93pub const DEV_SECRET_KEY: &str = "yahdev-local-only";
94
95/// How the camp brings the driver up.
96#[derive(Debug, Clone, Default)]
97pub struct S3DriverOptions {
98    /// Explicit binary path. Falls back to [`S3_FS_BIN_ENV`], then to bare
99    /// `yah-s3-fs` resolved on `PATH` at spawn time.
100    pub binary: Option<PathBuf>,
101    /// How long to wait for `coords.json` after the workload is deployed.
102    /// Default 30s — far shorter than pg's 180s or smtp's 120s because there
103    /// is no cold path to wait out: bring-up is a `mkdir` and a `bind`, so
104    /// anything past a second here means the binary is missing or wedged, and
105    /// making the operator watch a three-minute spinner to find that out is
106    /// the wrong trade.
107    pub ready_timeout: Option<Duration>,
108}
109
110impl S3DriverOptions {
111    fn resolved_binary(&self) -> PathBuf {
112        if let Some(ref p) = self.binary {
113            return p.clone();
114        }
115        if let Some(p) = std::env::var_os(S3_FS_BIN_ENV) {
116            return PathBuf::from(p);
117        }
118        PathBuf::from("yah-s3-fs")
119    }
120
121    fn ready_timeout(&self) -> Duration {
122        self.ready_timeout.unwrap_or(Duration::from_secs(30))
123    }
124}
125
126/// A brought-up S3 driver.
127pub struct RunningS3Driver {
128    /// Port the S3 listener accepted on, read back out of `coords.json`.
129    pub port: u16,
130    /// Endpoint consumers dial, e.g. `http://127.0.0.1:51234`. This is the
131    /// string the camp injects as `S3_ENDPOINT`.
132    pub endpoint: String,
133    /// Buckets the driver was asked to create, sorted.
134    pub buckets: Vec<String>,
135    runtime: Arc<NativeRuntime>,
136    ident: MeshIdent,
137}
138
139impl RunningS3Driver {
140    /// Stop the driver. It retracts `coords.json` on the way out, so the next
141    /// bring-up cannot read a dead port off a stale file.
142    pub async fn teardown(&self) {
143        self.runtime.teardown_workload(&self.ident).await.ok();
144    }
145}
146
147/// `true` when this camp should run the dev-tier S3 driver.
148///
149/// Any service with a `dev` mirror counts — see the module doc for why this is
150/// deliberately weaker than pg's and smtp's test. A camp with no dev mirror at
151/// all has no dev tier to bring up, and gets nothing.
152pub fn camp_needs_s3_driver(services: &BTreeMap<String, ServiceWithMirrors>) -> bool {
153    services
154        .values()
155        .any(|svc| svc.mirrors.contains_key(DEV_ENV))
156}
157
158/// Buckets the dev tier should have: one per service with a `dev` mirror,
159/// named after the service.
160///
161/// Sorted and deduped so a camp brings the same buckets up in the same order
162/// every boot, which keeps `coords.json` byte-stable across restarts and makes
163/// a diff of it mean something.
164pub fn declared_s3_buckets(services: &BTreeMap<String, ServiceWithMirrors>) -> Vec<String> {
165    let mut out: Vec<String> = services
166        .iter()
167        .filter(|(_, svc)| svc.mirrors.contains_key(DEV_ENV))
168        .map(|(name, _)| name.clone())
169        .collect();
170    out.sort();
171    out.dedup();
172    out
173}
174
175/// `true` when this mirror explicitly binds `s3` to the dev-tier driver.
176///
177/// Not consulted by [`camp_needs_s3_driver`] — the driver is always-on — but
178/// kept because it is how a reader, and R584-F4's mesofact wiring, asks
179/// "which implementation does this tier name for s3".
180pub fn binds_local_s3_fs(mirror: &MirrorConfig) -> bool {
181    mirror
182        .driver(Capability::S3)
183        .and_then(|slot| slot.inline_kind())
184        == Some(Provider::LocalS3Fs)
185}
186
187/// Path of the driver's coordinates file. Mirrors `yah_s3_fs::coords_path`.
188pub fn coords_path(workspace_root: &Path) -> PathBuf {
189    workspace_root.join(".yah/infra/state/dev/s3/coords.json")
190}
191
192/// Where the camp's running S3 driver can be reached, read out of the
193/// `coords.json` it publishes at bring-up.
194///
195/// `None` means the driver is not up. That is a *diagnosable* state rather
196/// than an error here, because the caller (the mesofact dev arm) can say
197/// something far more useful than "file not found" — see
198/// [`super::pond::up_dev_door`].
199///
200/// Read structurally rather than through `yah_s3_fs::Coords` for the same
201/// reason [`read_coords`] is: `cloud` must not depend on a separately-built
202/// plugin crate.
203pub fn running_endpoint(workspace_root: &Path) -> Option<String> {
204    read_coords(&coords_path(workspace_root)).map(|c| c.endpoint)
205}
206
207/// Deploy `yah-s3-fs` on a kamaji [`NativeRuntime`] and wait for it to publish
208/// coordinates.
209pub async fn up_s3_driver(
210    workspace_root: &Path,
211    buckets: Vec<String>,
212    opts: &S3DriverOptions,
213) -> Result<RunningS3Driver> {
214    let binary = opts.resolved_binary();
215    let ident_str = sanitize_ident(S3_DRIVER_IDENT);
216    let ident = MeshIdent(ident_str.clone());
217
218    let mut argv: Vec<String> = vec![
219        binary.display().to_string(),
220        "serve".to_string(),
221        "--workspace".to_string(),
222        workspace_root.display().to_string(),
223    ];
224    for bucket in &buckets {
225        argv.push("--bucket".to_string());
226        argv.push(bucket.clone());
227    }
228
229    // Coordinates from a previous run describe a listener that may or may not
230    // still be up. Retract them first so `wait_for_coords` cannot succeed on a
231    // stale file and hand every app in the camp a dead `S3_ENDPOINT`.
232    let coords = coords_path(workspace_root);
233    let _ = std::fs::remove_file(&coords);
234
235    let mut spec = native_spec(&ident_str, argv, Vec::new());
236    // Name-only: kamaji allocates the number and remembers it per (workload,
237    // port name) across a supervisor restart, telling the driver via `PORT_S3`.
238    // A pinned number would be a collision waiting for the second camp on this
239    // laptop, and consumers read the real port out of `coords.json` anyway.
240    spec.expose.mesh.ports = vec![MeshPort::named(PORT_NAME_S3)];
241
242    let state_dir = workspace_root.join(".yah/jit/native");
243    let runtime = Arc::new(NativeRuntime::new(&state_dir));
244    let mesh = MeshAssignment::inlined(Ipv4Addr::LOCALHOST);
245
246    info!(
247        binary = %binary.display(),
248        buckets = buckets.len(),
249        ident = %ident_str,
250        "spawning yah-s3-fs (kamaji native backend)",
251    );
252
253    runtime
254        .deploy_workload(&spec, &mesh)
255        .await
256        .with_context(|| {
257            format!(
258                "deploying the dev-tier s3 driver via kamaji — install it with \
259                 `cargo install --path crates/yah/s3-fs` or point {S3_FS_BIN_ENV} \
260                 at the binary ({})",
261                binary.display(),
262            )
263        })?;
264
265    let timeout = opts.ready_timeout();
266    let Some(ready) = wait_for_coords(&coords, timeout).await else {
267        warn!(timeout = ?timeout, "yah-s3-fs did not publish coords; tearing down");
268        runtime.teardown_workload(&ident).await.ok();
269        let (_out, err) = super::native_support::capture_paths(&state_dir, &ident_str);
270        anyhow::bail!(
271            "the dev-tier s3 driver did not become ready within {timeout:?} — \
272             check {} for why",
273            err.display(),
274        );
275    };
276
277    info!(
278        port = ready.port,
279        endpoint = %ready.endpoint,
280        buckets = ready.buckets.len(),
281        "dev-tier s3 driver ready",
282    );
283    Ok(RunningS3Driver {
284        port: ready.port,
285        endpoint: ready.endpoint,
286        buckets: ready.buckets,
287        runtime,
288        ident,
289    })
290}
291
292/// The subset of the driver's `coords.json` the camp needs. Read structurally
293/// rather than by depending on `yah_s3_fs::Coords`, for the same reason
294/// `pg_driver` re-spells its database-name rule: `cloud` must not take a
295/// dependency on a separately-built plugin crate.
296#[derive(Debug, Clone, PartialEq, Eq)]
297struct ReadyCoords {
298    port: u16,
299    endpoint: String,
300    buckets: Vec<String>,
301}
302
303/// Poll for the driver's `coords.json`. See [`super::pg_driver`] for why this
304/// polls rather than watches.
305async fn wait_for_coords(path: &Path, timeout: Duration) -> Option<ReadyCoords> {
306    let deadline = Instant::now() + timeout;
307    loop {
308        if let Some(coords) = read_coords(path) {
309            return Some(coords);
310        }
311        if Instant::now() >= deadline {
312            return None;
313        }
314        tokio::time::sleep(Duration::from_millis(50)).await;
315    }
316}
317
318/// Coordinates from a *complete* `coords.json`, or `None` when the file is
319/// absent, half-written, or reports a zero port.
320fn read_coords(path: &Path) -> Option<ReadyCoords> {
321    let bytes = std::fs::read(path).ok()?;
322    let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
323    let port = u16::try_from(v.get("port")?.as_u64()?).ok()?;
324    if port == 0 {
325        return None;
326    }
327    let endpoint = v
328        .get("endpoint")
329        .and_then(|e| e.as_str())
330        .map(str::to_string)
331        .unwrap_or_else(|| format!("http://127.0.0.1:{port}"));
332    let buckets = v
333        .get("buckets")
334        .and_then(|b| b.as_array())
335        .map(|a| {
336            a.iter()
337                .filter_map(|b| b.as_str().map(str::to_string))
338                .collect()
339        })
340        .unwrap_or_default();
341    Some(ReadyCoords {
342        port,
343        endpoint,
344        buckets,
345    })
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use crate::config::ServiceConfig;
352
353    fn service(mirrors: &[(&str, &str)]) -> ServiceWithMirrors {
354        let service: ServiceConfig =
355            toml::from_str("schema_version = 1\nname = \"svc\"\ndomain = \"svc.example\"\n")
356                .expect("parse service");
357        ServiceWithMirrors {
358            service,
359            mirrors: mirrors
360                .iter()
361                .map(|(env, src)| {
362                    (
363                        (*env).to_string(),
364                        toml::from_str::<MirrorConfig>(src).expect("parse mirror"),
365                    )
366                })
367                .collect(),
368            component_transform_recipes: BTreeMap::new(),
369            passway_machines: BTreeMap::new(),
370        }
371    }
372
373    /// A mirror that declares nothing about s3 at all — the common case, and
374    /// the one that still has to activate the driver.
375    const PLAIN: &str = r#"
376schema_version = 1
377shape = "local"
378[providers.static]
379kind = "miniflare-native"
380port = 4321
381"#;
382
383    const BINDS_S3: &str = r#"
384schema_version = 1
385shape = "local"
386[drivers.s3]
387kind = "local-s3-fs"
388"#;
389
390    const CLOUD: &str = r#"
391schema_version = 1
392shape = "single-machine"
393"#;
394
395    fn services(entries: Vec<(&str, ServiceWithMirrors)>) -> BTreeMap<String, ServiceWithMirrors> {
396        entries
397            .into_iter()
398            .map(|(n, s)| (n.to_string(), s))
399            .collect()
400    }
401
402    /// THE deviation from R584-F1/F2, pinned: a dev mirror that says nothing
403    /// about s3 still gets the driver. If this ever flips to requiring the
404    /// stanza, every existing dev service silently loses `S3_ENDPOINT`.
405    #[test]
406    fn a_dev_mirror_activates_the_driver_without_declaring_anything() {
407        assert!(camp_needs_s3_driver(&services(vec![(
408            "marketing",
409            service(&[("dev", PLAIN)])
410        )])));
411    }
412
413    #[test]
414    fn an_explicit_binding_also_activates_it() {
415        assert!(camp_needs_s3_driver(&services(vec![(
416            "assets",
417            service(&[("dev", BINDS_S3)])
418        )])));
419    }
420
421    /// A camp with no dev tier has nothing to bring up.
422    #[test]
423    fn a_camp_with_no_dev_mirror_spawns_nothing() {
424        assert!(!camp_needs_s3_driver(&services(vec![(
425            "prod-only",
426            service(&[("prod", CLOUD)])
427        )])));
428        assert!(!camp_needs_s3_driver(&BTreeMap::new()));
429    }
430
431    #[test]
432    fn buckets_are_the_dev_services_sorted_and_nothing_else() {
433        let svcs = services(vec![
434            ("zeta", service(&[("dev", PLAIN)])),
435            ("alpha", service(&[("dev", BINDS_S3)])),
436            ("prod-only", service(&[("prod", CLOUD)])),
437        ]);
438        assert_eq!(declared_s3_buckets(&svcs), ["alpha", "zeta"]);
439    }
440
441    /// The bucket name is the service name verbatim, because that is the
442    /// `S3_BUCKET` the camp has injected since R274-F5. Changing it would
443    /// orphan every object an existing dev app has written.
444    #[test]
445    fn the_bucket_name_is_the_service_name_verbatim() {
446        let svcs = services(vec![("yah-marketing", service(&[("dev", PLAIN)]))]);
447        assert_eq!(declared_s3_buckets(&svcs), ["yah-marketing"]);
448    }
449
450    #[test]
451    fn the_explicit_binding_is_still_readable_for_tier_aware_callers() {
452        let with = service(&[("dev", BINDS_S3)]);
453        let without = service(&[("dev", PLAIN)]);
454        assert!(binds_local_s3_fs(with.mirrors.get("dev").unwrap()));
455        assert!(!binds_local_s3_fs(without.mirrors.get("dev").unwrap()));
456    }
457
458    #[test]
459    fn the_spec_declares_the_s3_listener_by_name() {
460        let mut spec = native_spec("yah-s3-fs", vec!["yah-s3-fs".to_string()], Vec::new());
461        spec.expose.mesh.ports = vec![MeshPort::named(PORT_NAME_S3)];
462        assert!(spec.expose.mesh.names().contains(&"s3"));
463        assert!(spec.expose.mesh.ports.iter().all(|p| p.number.is_none()));
464        workload_spec::validate::shape(&spec).expect("spec must validate");
465    }
466
467    #[tokio::test]
468    async fn coords_are_incomplete_until_a_real_port_is_published() {
469        let tmp = tempfile::tempdir().unwrap();
470        let path = tmp.path().join("coords.json");
471        let brief = Duration::from_millis(150);
472
473        assert_eq!(wait_for_coords(&path, brief).await, None);
474        // Bound-but-zero is the shape a half-initialised file has.
475        std::fs::write(&path, br#"{"port":0}"#).unwrap();
476        assert_eq!(wait_for_coords(&path, brief).await, None);
477        // Half-written.
478        std::fs::write(&path, br#"{"port":51"#).unwrap();
479        assert_eq!(wait_for_coords(&path, brief).await, None);
480    }
481
482    #[tokio::test]
483    async fn a_complete_coords_file_yields_the_endpoint_and_buckets() {
484        let tmp = tempfile::tempdir().unwrap();
485        let path = tmp.path().join("coords.json");
486        std::fs::write(
487            &path,
488            br#"{"port":51234,"endpoint":"http://127.0.0.1:51234",
489                 "data_dir":"/tmp/x","buckets":["alpha","zeta"]}"#,
490        )
491        .unwrap();
492        assert_eq!(
493            wait_for_coords(&path, Duration::from_secs(1)).await,
494            Some(ReadyCoords {
495                port: 51234,
496                endpoint: "http://127.0.0.1:51234".to_string(),
497                buckets: vec!["alpha".to_string(), "zeta".to_string()],
498            })
499        );
500    }
501
502    /// An older driver that published a port but no endpoint is still usable —
503    /// the endpoint is derivable from the port it did publish.
504    #[test]
505    fn a_missing_endpoint_is_derived_from_the_port() {
506        let tmp = tempfile::tempdir().unwrap();
507        let path = tmp.path().join("coords.json");
508        std::fs::write(&path, br#"{"port":51234}"#).unwrap();
509        assert_eq!(
510            read_coords(&path).map(|c| c.endpoint),
511            Some("http://127.0.0.1:51234".to_string())
512        );
513    }
514}