Skip to main content

local_driver/
pond_ssr_runtime.rs

1//! Pond SSR runtime container bring-up primitives (R434-F4).
2//!
3//! Mirrors the shape of [`crate::pond_minio`] for a long-lived
4//! `mesofact-static.ssr_runtime` companion container — the Fetch-handler
5//! origin that miniflare proxies SSR-prefix requests to. The canonical image
6//! is now `mesofact-serve` (deno_core, W174 pillar 4 / R449-F3,
7//! `oss/mesofact/Dockerfile.ssr-runtime`), which replaces the prior
8//! `oven/bun:1` + `bun run src/ssr.ts` runtime; the bring-up below stays
9//! image-agnostic, so the workload's `ssr_runtime.image` / `.command` select
10//! the runtime.
11//!
12//! The companion is declared in `workload.toml`'s
13//! `MesofactStaticWorkload.ssr_runtime: Option<WorkloadSpec>` (R256-F7).
14//! Camp lowers the workload spec into the simpler shape this module accepts —
15//! see [`SsrRuntimeSpec`] — and yubaba owns the lifecycle alongside MinIO
16//! + miniflare (R374-F3 / R374-F4 pattern).
17//!
18//! Bring-up: pull image, run container with port mapping + env + optional
19//! bind mounts, wait for HTTP readiness on the host-mapped port. Used by:
20//!
21//! - `yubaba::pond::ssr_runtime::SsrRuntimeReconciler` — supervisor + restart
22//!   loop that owns the running container, kept in sync with the rest of the
23//!   pond registry.
24//! - `cloud::reconciler::pond::up_pond` — eventual cloud-direct path; today
25//!   only the yubaba path consumes this (camp builds the spec then POSTs).
26
27use std::collections::BTreeMap;
28use std::path::PathBuf;
29use std::time::Duration;
30
31use anyhow::{bail, Context, Result};
32use reqwest::StatusCode;
33use serde::{Deserialize, Serialize};
34use tracing::info;
35use workload_spec::WorkloadSpec;
36
37use crate::{ContainerLauncher, ContainerRunSpec};
38
39/// Default port the SSR container binds inside its image. Bun/Node frameworks
40/// default to `3000`; the workload spec can override via
41/// `expose.mesh.ports[0]` and that wins.
42pub const DEFAULT_SSR_CONTAINER_PORT: u16 = 3000;
43
44/// Caller-supplied SSR-runtime bring-up parameters. Decoupled from
45/// `WorkloadSpec` so yubaba's HTTP body stays narrow — camp lowers the full
46/// spec to this struct before POSTing.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct SsrRuntimeSpec {
49    /// Per-cell docker bridge network to attach to (R455-F1). Sibling
50    /// containers (miniflare, MinIO) reach this container via
51    /// [`network_alias`] — typically `http://ssr:3000` from inside the
52    /// Worker. `None` keeps the legacy host-port-only shape.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub network: Option<String>,
55    /// DNS alias on the bridge (e.g. `"ssr"`). Ignored when [`network`]
56    /// is `None`.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub network_alias: Option<String>,
59    /// Container image to pull (e.g. `"oven/bun:1"`).
60    pub image: String,
61    /// Override the image's CMD (e.g. `["bun", "run", "src/ssr-entry.ts"]`).
62    /// `None` leaves the image default.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub cmd: Option<Vec<String>>,
65    /// Env vars passed to the container.
66    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
67    pub env: BTreeMap<String, String>,
68    /// Host port mapped to [`container_port`] inside the container. Miniflare's
69    /// `SSR_ORIGIN` binding points at `http://127.0.0.1:{host_port}`.
70    pub host_port: u16,
71    /// Port the SSR process binds inside the container. Defaults to
72    /// [`DEFAULT_SSR_CONTAINER_PORT`]; override via the workload's
73    /// `expose.mesh.ports[0]` at lowering time.
74    pub container_port: u16,
75    /// Volume mounts as (host_path, container_path) pairs.
76    #[serde(default, skip_serializing_if = "Vec::is_empty")]
77    pub volumes: Vec<(PathBuf, PathBuf)>,
78    /// Container name (canonical `yah-pond-<svc>-<env>-ssr` form).
79    pub container_name: String,
80    /// Docker `--label` value for filtering (`yah.pond=<svc>:<env>:ssr`).
81    pub container_label: String,
82    /// Timeout for the initial port + HTTP-ready probes.
83    #[serde(with = "duration_secs_serde")]
84    pub ready_timeout: Duration,
85    /// HTTP path used for readiness probing. Set from the workload's
86    /// `healthcheck` when it declares an `HttpGet` probe; otherwise
87    /// [`default_ready_path`].
88    #[serde(default = "default_ready_path")]
89    pub ready_path: String,
90}
91
92/// `/readyz` — the Kubernetes-conventional readiness path, which the canonical
93/// `mesofact-serve` image serves (`oss/mesofact/crates/mesofact/src/health.rs`).
94///
95/// This used to be `/`, which for an SSR workload means "render the catch-all
96/// route", i.e. it proved the process was listening and nothing more. That is
97/// liveness, and admitting on it is how a pond mirror starts routing to a
98/// container whose isolate has not booted.
99///
100/// Safe for the foreign images this module stays agnostic about
101/// (`oven/bun:1`, a hand-rolled Node origin): [`wait_for_http_ready`] fails
102/// only on 5xx, so an image with no `/readyz` answers 404 and passes — exactly
103/// the "it accepted a connection" signal `/` gave. An image that *does* serve
104/// `/readyz` gets its 503s honored. Nothing regresses; mesofact gets correct.
105/// Declare a `healthcheck` on the workload to name a different path.
106fn default_ready_path() -> String {
107    "/readyz".to_string()
108}
109
110mod duration_secs_serde {
111    use serde::{Deserialize, Deserializer, Serialize, Serializer};
112    use std::time::Duration;
113
114    pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
115        d.as_secs().serialize(s)
116    }
117    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
118        let secs = u64::deserialize(d)?;
119        Ok(Duration::from_secs(secs))
120    }
121}
122
123/// Coordinates of a running SSR-runtime container returned by
124/// [`ensure_ssr_runtime_running`].
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct SsrRuntimeRunning {
127    /// URL miniflare points its `SSR_ORIGIN` binding at — host-side, never
128    /// includes a trailing slash. Format: `http://127.0.0.1:{host_port}`.
129    pub origin_url: String,
130    pub container_name: String,
131}
132
133/// Bring the SSR-runtime container up: pull image, run, probe TCP readiness,
134/// probe HTTP readiness on `ready_path`. Idempotent end-to-end — re-running
135/// against an already-Running container of the same name replaces it
136/// (`LocalRuntime::run` is idempotent).
137pub async fn ensure_ssr_runtime_running(
138    runtime: &impl ContainerLauncher,
139    spec: &SsrRuntimeSpec,
140) -> Result<SsrRuntimeRunning> {
141    runtime
142        .ensure_image(&spec.image)
143        .await
144        .with_context(|| format!("pulling {}", spec.image))?;
145
146    let volumes_str: Vec<(PathBuf, String)> = spec
147        .volumes
148        .iter()
149        .map(|(host, target)| (host.clone(), target.display().to_string()))
150        .collect();
151    let network_aliases = match (&spec.network, &spec.network_alias) {
152        (Some(_), Some(alias)) => vec![alias.clone()],
153        _ => vec![],
154    };
155    let run_spec = ContainerRunSpec {
156        name: spec.container_name.clone(),
157        image: spec.image.clone(),
158        label: spec.container_label.clone(),
159        ports: vec![(spec.host_port, spec.container_port)],
160        env: spec.env.clone(),
161        volumes: volumes_str,
162        cmd: spec.cmd.clone().unwrap_or_default(),
163        cap_add: vec![],
164        cgroupns: None,
165        network: spec.network.clone(),
166        network_aliases,
167        extra_hosts: vec![],
168    };
169    runtime
170        .run(&run_spec)
171        .await
172        .with_context(|| format!("starting SSR-runtime container {}", spec.container_name))?;
173
174    // Probe via `pond_probe_host()` (host.docker.internal inside the
175    // containerized pond yubaba); the recorded origin_url stays host-facing.
176    let probe_host = crate::pond_probe_host();
177    if !wait_for_port(&probe_host, spec.host_port, spec.ready_timeout).await {
178        let _ = runtime
179            .stop_and_remove(&spec.container_name, Duration::from_secs(2))
180            .await;
181        bail!(
182            "SSR-runtime container did not bind {probe_host}:{} within {:?}",
183            spec.host_port,
184            spec.ready_timeout,
185        );
186    }
187
188    let origin_url = format!("http://127.0.0.1:{}", spec.host_port);
189    let probe_url = format!(
190        "http://{probe_host}:{port}{path}",
191        port = spec.host_port,
192        path = spec.ready_path
193    );
194    if !wait_for_http_ready(&probe_url, spec.ready_timeout).await {
195        let _ = runtime
196            .stop_and_remove(&spec.container_name, Duration::from_secs(2))
197            .await;
198        bail!(
199            "SSR-runtime container at {origin_url} did not pass {probe_url} within {:?}",
200            spec.ready_timeout,
201        );
202    }
203
204    info!(
205        origin_url = %origin_url,
206        container = %spec.container_name,
207        "pond SSR-runtime container ready",
208    );
209
210    Ok(SsrRuntimeRunning {
211        origin_url,
212        container_name: spec.container_name.clone(),
213    })
214}
215
216/// Lower a [`WorkloadSpec`] (camp's source-of-truth shape, carried in
217/// `MesofactStaticWorkload.ssr_runtime`) into the focused [`SsrRuntimeSpec`]
218/// the bring-up primitive consumes.
219///
220/// The mapping is intentionally narrow: most `WorkloadSpec` fields (mesh
221/// identity, raft, depends_on, secrets, resource limits) are yubaba-cloud
222/// concerns that don't apply to a single host-side companion. We pull:
223///
224/// - `image` → composed `"{registry}/{repository}:{tag}"`
225/// - `command` → `cmd` (overrides image CMD when set)
226/// - `env` → literal env vars only (SecretRef / MeshRef rejected at lowering
227///   for pond; operator must inline the value or use a different secret mount
228///   path when those land in pond)
229/// - `volumes` → host-side bind mounts only (named volumes deferred)
230/// - `expose.mesh.ports[0]` → `container_port` (defaults to
231///   [`DEFAULT_SSR_CONTAINER_PORT`])
232/// - `healthcheck` → `ready_path`, when it declares an `HttpGet` probe. Only
233///   the path is taken: the probe's port is the container's own, while this
234///   bring-up probes the host-mapped port, and the interval/threshold fields
235///   belong to a supervisor loop this one-shot wait has no analogue for. The
236///   other probe kinds (`Exec`, `TcpConnect`) name no path, so they fall
237///   through to [`default_ready_path`] — `wait_for_port` above already covers
238///   the TCP case.
239///
240/// `host_port`, `container_name`, `container_label`, and `ready_timeout` are
241/// supplied by the caller — they're tied to the pond mirror, not the workload
242/// spec itself.
243pub fn lower_workload_spec(
244    ws: &WorkloadSpec,
245    host_port: u16,
246    container_name: String,
247    container_label: String,
248    ready_timeout: Duration,
249) -> Result<SsrRuntimeSpec> {
250    let image_str = compose_image_ref(&ws.image);
251
252    let mut env_map = BTreeMap::new();
253    for var in &ws.env {
254        let (key, value) = lower_env_var(var).with_context(|| {
255            format!("lowering env var for SSR runtime {}", ws.name)
256        })?;
257        env_map.insert(key, value);
258    }
259
260    let volumes = ws
261        .volumes
262        .iter()
263        .filter_map(lower_volume_mount)
264        .collect();
265
266    let container_port = ws
267        .expose
268        .mesh
269        .numbers()
270        .first()
271        .copied()
272        .unwrap_or(DEFAULT_SSR_CONTAINER_PORT);
273
274    Ok(SsrRuntimeSpec {
275        network: None,
276        network_alias: None,
277        image: image_str,
278        cmd: ws.command.clone(),
279        env: env_map,
280        host_port,
281        container_port,
282        volumes,
283        container_name,
284        container_label,
285        ready_timeout,
286        ready_path: ready_path_for(ws),
287    })
288}
289
290/// The workload's declared `HttpGet` health path, else [`default_ready_path`].
291fn ready_path_for(ws: &WorkloadSpec) -> String {
292    match ws.healthcheck.as_ref().map(|h| &h.probe) {
293        Some(workload_spec::HealthProbe::HttpGet { path, .. }) => path.clone(),
294        _ => default_ready_path(),
295    }
296}
297
298/// Compose `"{registry}/{repository}:{tag}"`, preferring `@digest` when set.
299fn compose_image_ref(image: &workload_spec::ImageRef) -> String {
300    let repo = if image.registry.is_empty() {
301        image.repository.clone()
302    } else {
303        format!("{}/{}", image.registry, image.repository)
304    };
305    // ImageRef.digest is structurally required (R438-T3); always emit the
306    // tag@digest pair. Defensive empty-string check guards against a
307    // hand-constructed ImageRef that bypassed the type-level requirement.
308    if image.digest.is_empty() {
309        format!("{repo}:{}", image.tag)
310    } else {
311        format!("{repo}:{}@{}", image.tag, image.digest)
312    }
313}
314
315/// Lower a workload-spec `EnvVar` to a literal `(key, value)` pair. Only
316/// `Literal` variants are accepted at this seam — `FromSecret` and `FromMesh`
317/// rely on yubaba's secret store + mesh registry, neither of which is wired
318/// for pond. Reject with a clear error so misconfiguration surfaces at
319/// spec-build time rather than as a confusing container-side failure.
320fn lower_env_var(var: &workload_spec::EnvVar) -> Result<(String, String)> {
321    match &var.value {
322        workload_spec::EnvValue::Literal { value } => {
323            Ok((var.name.clone(), value.clone()))
324        }
325        workload_spec::EnvValue::FromSecret { .. } => bail!(
326            "env var {:?}: FromSecret not yet supported in pond SSR-runtime lowering",
327            var.name
328        ),
329        workload_spec::EnvValue::FromMesh { .. } => bail!(
330            "env var {:?}: FromMesh not yet supported in pond SSR-runtime lowering",
331            var.name
332        ),
333    }
334}
335
336/// Lower a workload-spec `VolumeMount` to a (host_path, container_path) pair.
337/// Returns `None` for non-`Bind` variants — Named volumes are yubaba-managed
338/// and Tmpfs is in-memory; neither maps cleanly to a host-side bind mount.
339fn lower_volume_mount(
340    mount: &workload_spec::VolumeMount,
341) -> Option<(PathBuf, PathBuf)> {
342    match &mount.source {
343        workload_spec::VolumeSource::Bind { host_path } => {
344            Some((host_path.clone(), mount.target.clone()))
345        }
346        _ => None,
347    }
348}
349
350/// Wait for a TCP port to start accepting connections. Host + port form so
351/// DNS names like `host.docker.internal` resolve (containerized yubaba).
352async fn wait_for_port(host: &str, port: u16, timeout: Duration) -> bool {
353    let deadline = tokio::time::Instant::now() + timeout;
354    loop {
355        if tokio::net::TcpStream::connect((host, port)).await.is_ok() {
356            return true;
357        }
358        if tokio::time::Instant::now() >= deadline {
359            return false;
360        }
361        tokio::time::sleep(Duration::from_millis(50)).await;
362    }
363}
364
365/// Wait for an HTTP endpoint to return a 2xx (or 404 — the SSR app may have
366/// no route at `/` but still be up). We accept any non-5xx as ready.
367async fn wait_for_http_ready(url: &str, timeout: Duration) -> bool {
368    let client = reqwest::Client::builder()
369        .timeout(Duration::from_secs(2))
370        .build()
371        .unwrap_or_else(|_| reqwest::Client::new());
372    let deadline = tokio::time::Instant::now() + timeout;
373    loop {
374        if let Ok(resp) = client.get(url).send().await {
375            let s = resp.status();
376            if s != StatusCode::SERVICE_UNAVAILABLE
377                && s != StatusCode::BAD_GATEWAY
378                && s != StatusCode::GATEWAY_TIMEOUT
379                && !s.is_server_error()
380            {
381                return true;
382            }
383        }
384        if tokio::time::Instant::now() >= deadline {
385            return false;
386        }
387        tokio::time::sleep(Duration::from_millis(100)).await;
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use workload_spec::{
395        EnvValue, EnvVar, ExposeSpec, ImageRef, MeshExpose, MeshIdent, ResourceLimits,
396        RestartPolicy, SchemaVersion, StopPolicy, TierTag, VolumeMount, VolumeSource,
397    };
398
399    fn minimal_workload_spec() -> WorkloadSpec {
400        WorkloadSpec {
401            schema_version: SchemaVersion::V1,
402            name: "ssr-runtime".into(),
403            image: ImageRef {
404                registry: "docker.io".into(),
405                repository: "oven/bun".into(),
406                tag: "1".into(),
407                digest: workload_spec::testing::test_digest(),
408            },
409            tier: TierTag("service".into()),
410            tenant: workload_spec::TenantId::singleton(),
411            namespace: workload_spec::NamespaceId::singleton(),
412            replicas: 1,
413            command: Some(vec!["bun".into(), "run".into(), "src/ssr.ts".into()]),
414            entrypoint: None,
415            workdir: None,
416            user: None,
417            env: vec![EnvVar {
418                name: "NODE_ENV".into(),
419                value: EnvValue::Literal {
420                    value: "production".into(),
421                },
422            }],
423            secrets: vec![],
424            volumes: vec![VolumeMount {
425                source: VolumeSource::Bind {
426                    host_path: PathBuf::from("/host/src"),
427                },
428                target: PathBuf::from("/app/src"),
429                read_only: false,
430            }],
431            resources: ResourceLimits {
432                memory_mb: 256,
433                cpu_millis: 512,
434                ephemeral_storage_mb: 256,
435            },
436            depends_on: vec![],
437            healthcheck: None,
438            restart_policy: RestartPolicy::Always,
439            archetype: None,
440            stop_policy: StopPolicy {
441                signal: 15,
442                grace_period: workload_spec::Millis::from_secs(10),
443            },
444            expose: ExposeSpec {
445                mesh: MeshExpose {
446                    identity: MeshIdent("ssr-runtime".into()),
447                    ports: MeshExpose::anonymous_ports([3000]),
448                    allow_from: vec![],
449                },
450                public: None,
451                operator: None,
452            },
453            labels: Default::default(),
454            annotations: Default::default(),
455        }
456    }
457
458    #[test]
459    fn lower_composes_image_ref_with_tag_and_digest() {
460        let ws = minimal_workload_spec();
461        let spec = lower_workload_spec(
462            &ws,
463            14321,
464            "yah-pond-svc-pond-ssr".into(),
465            "svc:pond:ssr".into(),
466            Duration::from_secs(30),
467        )
468        .unwrap();
469        assert_eq!(
470            spec.image,
471            format!("docker.io/oven/bun:1@{}", workload_spec::testing::TEST_DIGEST)
472        );
473    }
474
475    #[test]
476    fn lower_emits_explicit_digest() {
477        let mut ws = minimal_workload_spec();
478        ws.image.digest = "sha256:abc123".into();
479        let spec = lower_workload_spec(
480            &ws,
481            14321,
482            "yah-pond-svc-pond-ssr".into(),
483            "svc:pond:ssr".into(),
484            Duration::from_secs(30),
485        )
486        .unwrap();
487        assert_eq!(spec.image, "docker.io/oven/bun:1@sha256:abc123");
488    }
489
490    #[test]
491    fn lower_copies_cmd_and_env_literals() {
492        let ws = minimal_workload_spec();
493        let spec = lower_workload_spec(
494            &ws,
495            14321,
496            "yah-pond-svc-pond-ssr".into(),
497            "svc:pond:ssr".into(),
498            Duration::from_secs(30),
499        )
500        .unwrap();
501        assert_eq!(spec.cmd.as_deref(), Some(&["bun".to_string(), "run".to_string(), "src/ssr.ts".to_string()][..]));
502        assert_eq!(spec.env.get("NODE_ENV").map(String::as_str), Some("production"));
503    }
504
505    #[test]
506    fn lower_rejects_from_secret_env() {
507        let mut ws = minimal_workload_spec();
508        ws.env.push(EnvVar {
509            name: "SECRET".into(),
510            value: EnvValue::FromSecret {
511                secret: "stripe-key".into(),
512                key: "value".into(),
513            },
514        });
515        let err = lower_workload_spec(
516            &ws,
517            14321,
518            "n".into(),
519            "l".into(),
520            Duration::from_secs(30),
521        )
522        .unwrap_err();
523        assert!(format!("{err:#}").contains("FromSecret"));
524    }
525
526    #[test]
527    fn lower_uses_expose_mesh_port_for_container_port() {
528        let ws = minimal_workload_spec();
529        let spec = lower_workload_spec(
530            &ws,
531            14321,
532            "n".into(),
533            "l".into(),
534            Duration::from_secs(30),
535        )
536        .unwrap();
537        assert_eq!(spec.container_port, 3000);
538    }
539
540    #[test]
541    fn lower_defaults_container_port_when_no_mesh_port() {
542        let mut ws = minimal_workload_spec();
543        ws.expose.mesh.ports.clear();
544        let spec = lower_workload_spec(
545            &ws,
546            14321,
547            "n".into(),
548            "l".into(),
549            Duration::from_secs(30),
550        )
551        .unwrap();
552        assert_eq!(spec.container_port, DEFAULT_SSR_CONTAINER_PORT);
553    }
554
555    #[test]
556    fn lower_defaults_ready_path_to_readyz() {
557        // Was `/` — i.e. "render the catch-all route", which for an SSR
558        // workload proves only that the process is listening. `/readyz` is the
559        // path mesofact serves that means the isolate booted.
560        let ws = minimal_workload_spec();
561        let spec = lower_workload_spec(&ws, 14321, "n".into(), "l".into(), Duration::from_secs(30))
562            .unwrap();
563        assert_eq!(spec.ready_path, "/readyz");
564    }
565
566    #[test]
567    fn lower_takes_ready_path_from_a_declared_http_healthcheck() {
568        let mut ws = minimal_workload_spec();
569        ws.healthcheck = Some(workload_spec::Healthcheck {
570            probe: workload_spec::HealthProbe::HttpGet {
571                // Deliberately not the container_port: this bring-up probes the
572                // host-mapped port, so only the path is taken.
573                path: "/custom/health".into(),
574                port: 9999,
575                expect_status: None,
576            },
577            interval: workload_spec::Millis(1_000),
578            timeout: workload_spec::Millis(1_000),
579            initial_delay: workload_spec::Millis(0),
580            failure_threshold: 3,
581        });
582        let spec = lower_workload_spec(&ws, 14321, "n".into(), "l".into(), Duration::from_secs(30))
583            .unwrap();
584        assert_eq!(spec.ready_path, "/custom/health");
585        assert_eq!(spec.host_port, 14321, "probe port stays the host mapping");
586    }
587
588    #[test]
589    fn lower_falls_back_for_pathless_probe_kinds() {
590        let mut ws = minimal_workload_spec();
591        ws.healthcheck = Some(workload_spec::Healthcheck {
592            probe: workload_spec::HealthProbe::TcpConnect { port: 3000 },
593            interval: workload_spec::Millis(1_000),
594            timeout: workload_spec::Millis(1_000),
595            initial_delay: workload_spec::Millis(0),
596            failure_threshold: 3,
597        });
598        let spec = lower_workload_spec(&ws, 14321, "n".into(), "l".into(), Duration::from_secs(30))
599            .unwrap();
600        assert_eq!(spec.ready_path, "/readyz");
601    }
602
603    #[test]
604    fn lower_host_volume_pairs_through() {
605        let ws = minimal_workload_spec();
606        let spec = lower_workload_spec(
607            &ws,
608            14321,
609            "n".into(),
610            "l".into(),
611            Duration::from_secs(30),
612        )
613        .unwrap();
614        assert_eq!(spec.volumes.len(), 1);
615        assert_eq!(spec.volumes[0].0, PathBuf::from("/host/src"));
616        assert_eq!(spec.volumes[0].1, PathBuf::from("/app/src"));
617    }
618
619    #[test]
620    fn ssr_runtime_spec_serde_roundtrip() {
621        let mut env = BTreeMap::new();
622        env.insert("FOO".into(), "bar".into());
623        let spec = SsrRuntimeSpec {
624            network: None,
625            network_alias: None,
626            image: "oven/bun:1".into(),
627            cmd: Some(vec!["bun".into(), "run".into()]),
628            env,
629            host_port: 14321,
630            container_port: 3000,
631            volumes: vec![(PathBuf::from("/a"), PathBuf::from("/b"))],
632            container_name: "yah-pond-svc-pond-ssr".into(),
633            container_label: "svc:pond:ssr".into(),
634            ready_timeout: Duration::from_secs(30),
635            ready_path: "/".into(),
636        };
637        let s = serde_json::to_string(&spec).unwrap();
638        let round: SsrRuntimeSpec = serde_json::from_str(&s).unwrap();
639        assert_eq!(round.image, spec.image);
640        assert_eq!(round.host_port, spec.host_port);
641        assert_eq!(round.container_port, spec.container_port);
642        assert_eq!(round.ready_path, "/");
643    }
644}