Skip to main content

cratefield_core/
sidecar.rs

1//! Sidecar mounts (ADR 0009): a module served by its own Worker, mounted at
2//! the same `/v1/<name>` as an in-process module and indistinguishable to a
3//! caller.
4//!
5//! The mount table is **runtime configuration**, never a builder call. A
6//! `.sidecar()` in `src/harness.rs` would bake a customer-specific mount into
7//! the artifact, so the artifact would stop being a function of the module set
8//! and could no longer be shared between ventures (#59).
9
10use std::collections::BTreeMap;
11use std::sync::Arc;
12
13use axum::Router;
14use axum::extract::{OriginalUri, State};
15use axum::response::{IntoResponse, Response};
16use bytes::Bytes;
17
18use crate::config::Config;
19use crate::http::{MAX_BODY_BYTES, X_REQUEST_ID};
20use crate::module::HARNESS_API;
21use crate::ports::Dispatcher;
22use crate::problem::Problem;
23use crate::problems::SLUGS;
24use crate::scope::Scope;
25
26/// Config key holding the mount table, a JSON object of
27/// `{"<module name>": "<service binding>"}`.
28pub const HARNESS_SIDECARS: &str = "HARNESS_SIDECARS";
29
30/// Contract version stamped on every harness response, checked by the host on
31/// every forwarded response. Stamping beats a cold-start handshake because an
32/// isolate outlives a sidecar redeploy (ADR 0009).
33pub const X_HARNESS_API: &str = "x-harness-api";
34/// Module name stamped alongside [`X_HARNESS_API`].
35pub const X_HARNESS_MODULE: &str = "x-harness-module";
36
37/// Headers that must not be forwarded: hop-by-hop, plus `host`, which belongs
38/// to the host Worker's own connection.
39const NOT_FORWARDED: &[&str] = &[
40    "host",
41    "connection",
42    "keep-alive",
43    "proxy-authenticate",
44    "proxy-authorization",
45    "te",
46    "trailer",
47    "transfer-encoding",
48    "upgrade",
49];
50
51/// One mounted sidecar.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct SidecarMount {
54    /// Module name; mounted at `/v1/<name>`.
55    pub name: String,
56    /// Service binding the runtime resolves to reach it.
57    pub binding: String,
58}
59
60/// The mount table, parsed from configuration.
61#[derive(Debug, Clone, Default, PartialEq, Eq)]
62pub struct SidecarMounts(Vec<SidecarMount>);
63
64impl SidecarMounts {
65    /// Reads and validates [`HARNESS_SIDECARS`]. Absent or empty is not an
66    /// error: most ventures mount no sidecars.
67    ///
68    /// # Errors
69    ///
70    /// Every malformed entry, reported together so one deploy surfaces them
71    /// all rather than one per attempt.
72    pub fn from_config(config: &dyn Config) -> Result<Self, Vec<String>> {
73        let Some(raw) = config
74            .get(HARNESS_SIDECARS)
75            .filter(|v| !v.trim().is_empty())
76        else {
77            return Ok(Self::default());
78        };
79        let parsed: BTreeMap<String, String> = serde_json::from_str(&raw).map_err(|err| {
80            vec![format!(
81                "{HARNESS_SIDECARS} must be a JSON object of {{\"module-name\": \"BINDING\"}}: {err}"
82            )]
83        })?;
84
85        let mut errors = Vec::new();
86        let mut mounts = Vec::new();
87        for (name, binding) in parsed {
88            if !is_kebab(&name) {
89                errors.push(format!(
90                    "sidecar name `{name}` must be kebab-case ([a-z0-9]+ separated by '-')"
91                ));
92            }
93            if binding.trim().is_empty() {
94                errors.push(format!("sidecar `{name}` has an empty service binding"));
95            }
96            mounts.push(SidecarMount { name, binding });
97        }
98        if errors.is_empty() {
99            Ok(Self(mounts))
100        } else {
101            Err(errors)
102        }
103    }
104
105    #[must_use]
106    pub fn is_empty(&self) -> bool {
107        self.0.is_empty()
108    }
109
110    pub fn iter(&self) -> impl Iterator<Item = &SidecarMount> {
111        self.0.iter()
112    }
113
114    /// Names that collide with an in-process module. Both sides are known
115    /// without an `Env`, so this is the one sidecar check that can run early.
116    #[must_use]
117    pub fn collisions(&self, module_names: &[&str]) -> Vec<String> {
118        self.0
119            .iter()
120            .filter(|m| module_names.contains(&m.name.as_str()))
121            .map(|m| {
122                format!(
123                    "sidecar `{}` claims `/v1/{}`, already served in-process",
124                    m.name, m.name
125                )
126            })
127            .collect()
128    }
129}
130
131fn is_kebab(s: &str) -> bool {
132    !s.is_empty()
133        && !s.starts_with('-')
134        && !s.ends_with('-')
135        && !s.contains("--")
136        && s.chars()
137            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
138}
139
140struct SidecarState {
141    mount: SidecarMount,
142    dispatcher: Option<Arc<dyn Dispatcher>>,
143}
144
145/// The router for one sidecar prefix: a fallback that forwards everything.
146pub(crate) fn router(mount: SidecarMount, dispatcher: Option<Arc<dyn Dispatcher>>) -> Router {
147    Router::new()
148        .fallback(forward)
149        .with_state(Arc::new(SidecarState { mount, dispatcher }))
150}
151
152async fn forward(
153    State(state): State<Arc<SidecarState>>,
154    scope: Scope,
155    // `nest` strips the mount prefix from `parts.uri`, but a sidecar is a
156    // whole harness serving its module at `/v1/<name>`: it must be given the
157    // path the caller used, or every forwarded request 404s at the far end.
158    OriginalUri(uri): OriginalUri,
159    request: axum::extract::Request,
160) -> Response {
161    let unavailable = |detail: String| -> Response {
162        Problem::new(&SLUGS.sidecar_unavailable)
163            .with_detail(detail)
164            .instance(&scope.request_id)
165            .into_response()
166    };
167
168    let Some(dispatcher) = state.dispatcher.clone() else {
169        tracing::warn!(
170            module = state.mount.name,
171            "sidecar mounted but the runtime provides no dispatcher"
172        );
173        return unavailable(format!(
174            "`{}` is mounted as a sidecar but this runtime cannot dispatch",
175            state.mount.name
176        ));
177    };
178    if !dispatcher.has(&state.mount.binding) {
179        tracing::warn!(
180            module = state.mount.name,
181            binding = state.mount.binding,
182            "sidecar binding is not present in this deployment"
183        );
184        return unavailable(format!(
185            "`{}` is mounted on binding `{}`, which this deployment does not have",
186            state.mount.name, state.mount.binding
187        ));
188    }
189
190    let (parts, body) = request.into_parts();
191    let Ok(body) = axum::body::to_bytes(body, MAX_BODY_BYTES).await else {
192        return Problem::new(&SLUGS.request_too_large)
193            .instance(&scope.request_id)
194            .into_response();
195    };
196
197    let mut outbound = http::Request::builder()
198        .method(parts.method.clone())
199        .uri(uri);
200    if let Some(headers) = outbound.headers_mut() {
201        for (name, value) in &parts.headers {
202            if NOT_FORWARDED.contains(&name.as_str()) {
203                continue;
204            }
205            headers.append(name.clone(), value.clone());
206        }
207        // One trail across both Workers. `insert`, so a client-supplied id
208        // cannot arrive twice.
209        if let Ok(value) = scope.request_id.parse() {
210            headers.insert(X_REQUEST_ID, value);
211        }
212    }
213    let outbound = match outbound.body(body) {
214        Ok(req) => req,
215        Err(err) => return unavailable(format!("could not build the forwarded request: {err}")),
216    };
217
218    match dispatcher.dispatch(&state.mount.binding, outbound).await {
219        Ok(response) => match contract_of(&response) {
220            Some(api) if api != HARNESS_API => {
221                tracing::warn!(
222                    module = state.mount.name,
223                    sidecar_api = api,
224                    host_api = HARNESS_API,
225                    "sidecar contract mismatch"
226                );
227                Problem::new(&SLUGS.sidecar_contract_mismatch)
228                    .with_detail(format!(
229                        "`{}` answers contract {api}; this harness speaks {HARNESS_API}",
230                        state.mount.name
231                    ))
232                    .instance(&scope.request_id)
233                    .into_response()
234            }
235            _ => into_axum(response),
236        },
237        Err(err) => {
238            tracing::warn!(module = state.mount.name, error = %err, "sidecar dispatch failed");
239            unavailable(err.to_string())
240        }
241    }
242}
243
244/// The contract a response claims, if it claims one. A sidecar that stamps
245/// nothing is not rejected here: it may predate the header, and the mismatch
246/// that matters is a *wrong* number, not a missing one.
247fn contract_of(response: &http::Response<Bytes>) -> Option<u32> {
248    response
249        .headers()
250        .get(X_HARNESS_API)
251        .and_then(|v| v.to_str().ok())
252        .and_then(|v| v.parse().ok())
253}
254
255fn into_axum(response: http::Response<Bytes>) -> Response {
256    let (parts, body) = response.into_parts();
257    let mut out = Response::new(axum::body::Body::from(body));
258    *out.status_mut() = parts.status;
259    *out.headers_mut() = parts.headers;
260    *out.version_mut() = parts.version;
261    out
262}