cratefield_core/
sidecar.rs1use 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
26pub const HARNESS_SIDECARS: &str = "HARNESS_SIDECARS";
29
30pub const X_HARNESS_API: &str = "x-harness-api";
34pub const X_HARNESS_MODULE: &str = "x-harness-module";
36
37const 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#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct SidecarMount {
54 pub name: String,
56 pub binding: String,
58}
59
60#[derive(Debug, Clone, Default, PartialEq, Eq)]
62pub struct SidecarMounts(Vec<SidecarMount>);
63
64impl SidecarMounts {
65 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 #[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
145pub(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 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 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
244fn 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}