1use std::{collections::HashMap, env, future::Future, net::SocketAddr, sync::Arc, time::Duration};
2
3use anyhow::{Context, Result, ensure};
4use tracing::info;
5
6use crate::{
7 host_leases::PostgresHostLeaseStore,
8 placement::PostgresObjectPlacementStore,
9 postgres::PostgresDatabase,
10 sandbox::{CommandSandboxProvider, HostSandboxRuntimeConfig},
11 storage_urls::{GcsStorageUrlSigner, validate_buckets},
12};
13
14use super::{ActorJwtVerifier, ControlPlaneService};
15
16const DEFAULT_JWT_ISSUER: &str = "durable-object-control-plane";
17const DEFAULT_AUTHORITY_AUDIENCE: &str = "durable-object-authority";
18const DEFAULT_INVOCATION_AUDIENCE: &str = "durable-object-invoke";
19const DEFAULT_JWT_TTL_SECONDS: u64 = 1_800;
20const DEFAULT_ACTOR_IDLE_TIMEOUT_MS: u64 = 60_000;
21const DEFAULT_HOST_IDLE_TIMEOUT_MS: u64 = 300_000;
22const MAX_IDLE_TIMEOUT_MS: u64 = 86_400_000;
23
24pub struct ControlPlaneProcessConfig {
25 pub bind: SocketAddr,
26 pub jwt_signing_key: String,
27 pub jwt_key_id: String,
28 pub jwt_issuer: String,
29 pub authority_audience: String,
30 pub invocation_audience: String,
31 pub jwt_max_lifetime: Duration,
32 pub admin_token: String,
33 pub storage: ControlPlaneStorageConfig,
34 pub sandbox_provider: Option<SandboxProviderConfig>,
35 pub socket_event_sink: Option<SocketEventSinkConfig>,
36 pub socket_authenticator: Option<SocketAuthenticatorConfig>,
37}
38
39pub struct ControlPlaneStorageConfig {
40 pub postgres_url: String,
41 pub standard_buckets: HashMap<String, String>,
42}
43
44pub struct SandboxProviderConfig {
45 pub provider_name: String,
46 pub command: String,
47 pub environment: HashMap<String, String>,
48 pub runtime: HostSandboxRuntimeConfig,
49}
50
51pub struct SocketEventSinkConfig {
52 pub url: String,
53 pub token: String,
54}
55
56pub struct SocketAuthenticatorConfig {
57 pub url: String,
58 pub token: String,
59}
60
61impl ControlPlaneProcessConfig {
62 pub fn from_env() -> Result<Self> {
63 Self::from_lookup(|name| env::var(name).ok())
64 }
65}
66
67pub async fn serve_control_plane(
68 config: ControlPlaneProcessConfig,
69 shutdown: impl Future<Output = ()> + Send + 'static,
70) -> Result<()> {
71 let bind = config.bind;
72 let routes = control_plane_routes(config).await?;
73 info!(bind = %bind, "durable-object control plane is ready");
74 let listener = tokio::net::TcpListener::bind(bind)
75 .await
76 .context("bind durable-object control plane")?;
77 serve_routes(listener, routes, shutdown).await
78}
79
80async fn serve_routes(
81 listener: tokio::net::TcpListener,
82 routes: tonic::service::Routes,
83 shutdown: impl Future<Output = ()> + Send + 'static,
84) -> Result<()> {
85 axum::serve(listener, routes.into_axum_router())
86 .with_graceful_shutdown(shutdown)
87 .await
88 .context("serve durable-object control plane")
89}
90
91async fn control_plane_routes(config: ControlPlaneProcessConfig) -> Result<tonic::service::Routes> {
92 let issuer = super::ActorJwtIssuer::from_base64_pkcs8(
93 &config.jwt_signing_key,
94 config.jwt_key_id,
95 config.jwt_issuer.clone(),
96 config.authority_audience.clone(),
97 config.invocation_audience,
98 config.jwt_max_lifetime,
99 )?;
100 let auth = ActorJwtVerifier::for_scope(
101 issuer.verifier_keys_json()?,
102 config.jwt_issuer,
103 config.authority_audience,
104 super::ActorTokenPurpose::ControlPlane,
105 config.jwt_max_lifetime,
106 )?;
107 let database = PostgresDatabase::connect(&config.storage.postgres_url).await?;
108 let leases = Arc::new(PostgresHostLeaseStore::from_database(database.clone()));
109 let placements = Arc::new(PostgresObjectPlacementStore::from_database(
110 database.clone(),
111 ));
112 let registry = Arc::new(super::PostgresAdminRegistry::from_database(database));
113 let storage_urls = Arc::new(GcsStorageUrlSigner::from_adc(
114 config.storage.standard_buckets,
115 )?);
116 let provisioner = sandbox_provisioner(config.sandbox_provider, &issuer, &leases)?;
117 let socket_events = config
118 .socket_event_sink
119 .map(|sink| super::event_sink::HttpSocketMessageEventSink::new(sink.url, sink.token))
120 .transpose()?
121 .map(|sink| Arc::new(sink) as Arc<dyn super::event_sink::SocketMessageEventSink>);
122 let socket_authenticator = config
123 .socket_authenticator
124 .map(|auth| super::socket_auth::HttpSocketAuthenticator::new(auth.url, auth.token))
125 .transpose()?
126 .map(|auth| Arc::new(auth) as Arc<dyn super::socket_auth::SocketAuthenticator>);
127 let service = ControlPlaneService::new(leases, placements, storage_urls, auth)
128 .with_routing(registry.clone(), issuer.clone(), provisioner)
129 .with_socket_event_sink(socket_events)
130 .with_socket_authenticator(socket_authenticator);
131 let admin = super::admin::AdminService::new(config.admin_token, registry, issuer)?;
132 let public_api = super::public_api::router(service.clone(), admin);
133 let internal_api = service.into_internal_service();
134 Ok(tonic::service::Routes::from(public_api).add_service(internal_api))
135}
136
137fn sandbox_provisioner(
138 config: Option<SandboxProviderConfig>,
139 issuer: &super::ActorJwtIssuer,
140 leases: &Arc<PostgresHostLeaseStore>,
141) -> Result<Option<Arc<dyn super::service::HostProvisioner>>> {
142 config
143 .map(
144 |config| -> Result<Arc<dyn super::service::HostProvisioner>> {
145 let provider = Arc::new(CommandSandboxProvider::new(
146 config.provider_name,
147 config.command,
148 config.environment,
149 )?);
150 Ok(Arc::new(super::service::SandboxHostProvisioner::new(
151 provider,
152 config.runtime,
153 issuer.clone(),
154 leases.clone(),
155 )))
156 },
157 )
158 .transpose()
159}
160
161impl ControlPlaneProcessConfig {
162 fn from_lookup(mut get: impl FnMut(&str) -> Option<String>) -> Result<Self> {
163 let bind = get("DURABLE_OBJECT_CONTROL_PLANE_BIND")
164 .unwrap_or_else(|| "127.0.0.1:7100".into())
165 .parse()
166 .context("DURABLE_OBJECT_CONTROL_PLANE_BIND must be a socket address")?;
167 let jwt_signing_key = required(&mut get, "DURABLE_OBJECT_JWT_SIGNING_KEY")?;
168 let jwt_key_id = get("DURABLE_OBJECT_JWT_KEY_ID").unwrap_or_else(|| "primary".into());
169 let jwt_issuer =
170 get("DURABLE_OBJECT_JWT_ISSUER").unwrap_or_else(|| DEFAULT_JWT_ISSUER.into());
171 let authority_audience = get("DURABLE_OBJECT_AUTHORITY_JWT_AUDIENCE")
172 .unwrap_or_else(|| DEFAULT_AUTHORITY_AUDIENCE.into());
173 let invocation_audience = get("DURABLE_OBJECT_INVOKE_JWT_AUDIENCE")
174 .unwrap_or_else(|| DEFAULT_INVOCATION_AUDIENCE.into());
175 let jwt_max_lifetime = Duration::from_secs(
176 get("DURABLE_OBJECT_JWT_MAX_TTL_SECONDS")
177 .map(|value| value.parse())
178 .transpose()
179 .context("DURABLE_OBJECT_JWT_MAX_TTL_SECONDS must be an integer")?
180 .unwrap_or(DEFAULT_JWT_TTL_SECONDS),
181 );
182 ensure!(
183 !jwt_max_lifetime.is_zero(),
184 "DURABLE_OBJECT_JWT_MAX_TTL_SECONDS must be positive"
185 );
186 let admin_token = required(&mut get, "DURABLE_OBJECT_ADMIN_TOKEN")?;
187 ensure!(
188 admin_token.trim() == admin_token,
189 "DURABLE_OBJECT_ADMIN_TOKEN has surrounding whitespace"
190 );
191 let standard_buckets: HashMap<String, String> =
192 serde_json::from_str(&required(&mut get, "DURABLE_OBJECT_STANDARD_BUCKETS")?)
193 .context("DURABLE_OBJECT_STANDARD_BUCKETS must be a JSON region-to-bucket map")?;
194 validate_buckets(&standard_buckets)?;
195 let storage = ControlPlaneStorageConfig {
196 postgres_url: required(&mut get, "DURABLE_OBJECT_POSTGRES_URL")?,
197 standard_buckets,
198 };
199 let sandbox_provider =
200 sandbox_provider_config(&mut get, &jwt_issuer, &invocation_audience)?;
201 let socket_event_sink = socket_event_sink_config(&mut get)?;
202 let socket_authenticator = socket_authenticator_config(&mut get)?;
203 Ok(Self {
204 bind,
205 jwt_signing_key,
206 jwt_key_id,
207 jwt_issuer,
208 authority_audience,
209 invocation_audience,
210 jwt_max_lifetime,
211 admin_token,
212 storage,
213 sandbox_provider,
214 socket_event_sink,
215 socket_authenticator,
216 })
217 }
218}
219
220fn socket_authenticator_config(
221 get: &mut impl FnMut(&str) -> Option<String>,
222) -> Result<Option<SocketAuthenticatorConfig>> {
223 let url = get("DURABLE_OBJECT_SOCKET_AUTH_URL");
224 let token = get("DURABLE_OBJECT_SOCKET_AUTH_TOKEN");
225 match (url, token) {
226 (None, None) => Ok(None),
227 (Some(url), Some(token)) => Ok(Some(SocketAuthenticatorConfig {
228 url: validated_http_url(&url, "DURABLE_OBJECT_SOCKET_AUTH_URL")?,
229 token,
230 })),
231 _ => anyhow::bail!(
232 "DURABLE_OBJECT_SOCKET_AUTH_URL and DURABLE_OBJECT_SOCKET_AUTH_TOKEN must be configured together"
233 ),
234 }
235}
236
237fn socket_event_sink_config(
238 get: &mut impl FnMut(&str) -> Option<String>,
239) -> Result<Option<SocketEventSinkConfig>> {
240 let url = get("DURABLE_OBJECT_SOCKET_EVENT_URL");
241 let token = get("DURABLE_OBJECT_SOCKET_EVENT_TOKEN");
242 match (url, token) {
243 (None, None) => Ok(None),
244 (Some(url), Some(token)) => Ok(Some(SocketEventSinkConfig {
245 url: validated_http_url(&url, "DURABLE_OBJECT_SOCKET_EVENT_URL")?,
246 token,
247 })),
248 _ => anyhow::bail!(
249 "DURABLE_OBJECT_SOCKET_EVENT_URL and DURABLE_OBJECT_SOCKET_EVENT_TOKEN must be configured together"
250 ),
251 }
252}
253
254fn sandbox_provider_config(
255 get: &mut impl FnMut(&str) -> Option<String>,
256 jwt_issuer: &str,
257 invocation_audience: &str,
258) -> Result<Option<SandboxProviderConfig>> {
259 let Some(provider_name) = get("DURABLE_OBJECT_SANDBOX_PROVIDER") else {
260 ensure!(
261 get("DURABLE_OBJECT_SANDBOX_COMMAND").is_none(),
262 "sandbox command requires a provider"
263 );
264 return Ok(None);
265 };
266 ensure!(
267 provider_name == "modal",
268 "unsupported sandbox provider {provider_name:?}"
269 );
270 let environment = HashMap::from([
271 (
272 "MODAL_TOKEN_ID".into(),
273 provider_credential(get, "MODAL_TOKEN_ID")?,
274 ),
275 (
276 "MODAL_TOKEN_SECRET".into(),
277 provider_credential(get, "MODAL_TOKEN_SECRET")?,
278 ),
279 ]);
280 let control_plane_url = validated_http_url(
281 &required(get, "DURABLE_OBJECT_CONTROL_PLANE_URL")?,
282 "DURABLE_OBJECT_CONTROL_PLANE_URL",
283 )?;
284 Ok(Some(SandboxProviderConfig {
285 provider_name,
286 command: get("DURABLE_OBJECT_SANDBOX_COMMAND")
287 .unwrap_or_else(|| "little-durable-objects-modal".into()),
288 environment,
289 runtime: HostSandboxRuntimeConfig {
290 control_plane_url,
291 jwt_issuer: jwt_issuer.into(),
292 invocation_jwt_audience: invocation_audience.into(),
293 actor_idle_timeout_ms: idle_timeout(
294 get,
295 "DURABLE_OBJECT_ACTOR_IDLE_TIMEOUT_MS",
296 DEFAULT_ACTOR_IDLE_TIMEOUT_MS,
297 )?,
298 host_idle_timeout_ms: idle_timeout(
299 get,
300 "DURABLE_OBJECT_HOST_IDLE_TIMEOUT_MS",
301 DEFAULT_HOST_IDLE_TIMEOUT_MS,
302 )?,
303 },
304 }))
305}
306
307fn provider_credential(get: &mut impl FnMut(&str) -> Option<String>, name: &str) -> Result<String> {
308 let value = required(get, name)?;
309 ensure!(value.trim() == value, "{name} has surrounding whitespace");
310 Ok(value)
311}
312
313fn required(get: &mut impl FnMut(&str) -> Option<String>, name: &str) -> Result<String> {
314 let value = get(name).with_context(|| format!("{name} is required"))?;
315 ensure!(!value.is_empty(), "{name} must not be empty");
316 Ok(value)
317}
318
319fn validated_http_url(value: &str, name: &str) -> Result<String> {
320 let url = reqwest::Url::parse(value).with_context(|| format!("{name} must be a URL"))?;
321 ensure!(
322 matches!(url.scheme(), "http" | "https") && url.host_str().is_some(),
323 "{name} must be HTTP or HTTPS"
324 );
325 Ok(url.to_string())
326}
327
328fn idle_timeout(
329 get: &mut impl FnMut(&str) -> Option<String>,
330 name: &str,
331 default: u64,
332) -> Result<u64> {
333 let value = get(name)
334 .map(|value| value.parse())
335 .transpose()
336 .with_context(|| format!("{name} must be an integer"))?
337 .unwrap_or(default);
338 ensure!(
339 (1..=MAX_IDLE_TIMEOUT_MS).contains(&value),
340 "{name} is outside the supported range"
341 );
342 Ok(value)
343}
344
345#[cfg(test)]
346mod tests {
347 use axum::{Router, extract::WebSocketUpgrade, response::Response, routing::get};
348 use futures_util::{SinkExt, StreamExt};
349 use tokio::sync::oneshot;
350 use tokio_tungstenite::{connect_async, tungstenite::Message};
351
352 use super::*;
353
354 #[tokio::test]
355 async fn server_carries_websocket_upgrades() -> Result<()> {
356 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
357 let address = listener.local_addr()?;
358 let routes =
359 tonic::service::Routes::from(Router::new().route("/socket", get(echo_websocket)));
360 let (shutdown_tx, shutdown_rx) = oneshot::channel();
361 let server = tokio::spawn(serve_routes(listener, routes, async {
362 let _ = shutdown_rx.await;
363 }));
364
365 let (mut socket, _) = connect_async(format!("ws://{address}/socket")).await?;
366 socket.send(Message::Text("hello".into())).await?;
367 assert_eq!(
368 socket.next().await.transpose()?,
369 Some(Message::Text("hello".into()))
370 );
371 socket.close(None).await?;
372 let _ = shutdown_tx.send(());
373 server.await??;
374 Ok(())
375 }
376
377 #[test]
378 fn parses_the_minimal_storage_configuration() -> Result<()> {
379 let values = HashMap::from([
380 ("DURABLE_OBJECT_JWT_SIGNING_KEY", "c2lnbmluZw=="),
381 ("DURABLE_OBJECT_ADMIN_TOKEN", "admin-token"),
382 (
383 "DURABLE_OBJECT_POSTGRES_URL",
384 "postgresql://localhost/actors",
385 ),
386 (
387 "DURABLE_OBJECT_STANDARD_BUCKETS",
388 "{\"us-east\":\"actor-state-test\"}",
389 ),
390 ]);
391 let config = ControlPlaneProcessConfig::from_lookup(|name| {
392 values.get(name).map(|value| (*value).into())
393 })?;
394 assert_eq!(
395 config.storage.standard_buckets["us-east"],
396 "actor-state-test"
397 );
398 Ok(())
399 }
400
401 #[test]
402 fn parses_socket_event_sink_only_when_url_and_token_are_present() -> Result<()> {
403 let mut complete = HashMap::from([
404 (
405 "DURABLE_OBJECT_SOCKET_EVENT_URL",
406 "https://api.example.com/events",
407 ),
408 ("DURABLE_OBJECT_SOCKET_EVENT_TOKEN", "event-token"),
409 ]);
410 let sink =
411 socket_event_sink_config(&mut |name| complete.get(name).map(|value| (*value).into()))?
412 .context("socket event sink was not configured")?;
413 assert_eq!(sink.url, "https://api.example.com/events");
414 assert_eq!(sink.token, "event-token");
415
416 complete.remove("DURABLE_OBJECT_SOCKET_EVENT_TOKEN");
417 assert!(
418 socket_event_sink_config(&mut |name| complete.get(name).map(|value| (*value).into()))
419 .is_err()
420 );
421 Ok(())
422 }
423
424 #[test]
425 fn parses_socket_authenticator_only_when_url_and_token_are_present() -> Result<()> {
426 let mut complete = HashMap::from([
427 (
428 "DURABLE_OBJECT_SOCKET_AUTH_URL",
429 "https://api.example.com/authorize",
430 ),
431 ("DURABLE_OBJECT_SOCKET_AUTH_TOKEN", "auth-token"),
432 ]);
433 let auth = socket_authenticator_config(&mut |name| {
434 complete.get(name).map(|value| (*value).into())
435 })?
436 .context("socket authenticator was not configured")?;
437 assert_eq!(auth.url, "https://api.example.com/authorize");
438 assert_eq!(auth.token, "auth-token");
439
440 complete.remove("DURABLE_OBJECT_SOCKET_AUTH_TOKEN");
441 assert!(
442 socket_authenticator_config(&mut |name| complete
443 .get(name)
444 .map(|value| (*value).into()))
445 .is_err()
446 );
447 Ok(())
448 }
449
450 async fn echo_websocket(upgrade: WebSocketUpgrade) -> Response {
451 upgrade.on_upgrade(async |mut socket| {
452 if let Some(Ok(message)) = socket.recv().await {
453 let _ = socket.send(message).await;
454 }
455 })
456 }
457}