Skip to main content

shardline_server/
app.rs

1mod operational;
2mod protocol_routes;
3mod provider;
4mod provider_routes;
5mod reconstruction_helpers;
6mod reconstruction_routes;
7
8pub use provider::{
9    extract_provider_subject, latest_lifecycle_signal_at, reconciled_provider_repository_state,
10    validate_provider_name_path,
11};
12pub use reconstruction_helpers::{full_byte_stream_response, parse_batch_reconstruction_query};
13
14use std::{
15    num::NonZeroUsize,
16    sync::{
17        Arc,
18        atomic::{AtomicU64, Ordering},
19    },
20};
21
22use axum::{
23    Router,
24    extract::DefaultBodyLimit,
25    http::HeaderMap,
26    routing::{get, head, post},
27    serve as serve_http,
28};
29use shardline_protocol::{RepositoryScope, TokenScope};
30use tokio::net::TcpListener;
31use tokio::sync::OwnedSemaphorePermit;
32use tokio::sync::Semaphore;
33
34use crate::{
35    ServerConfig, ServerError,
36    auth::{AuthContext, ServerAuth},
37    backend::ServerBackend,
38    config::AuthProviderKind,
39    metrics::{MetricsLayer, metrics_routes},
40    provider::ProviderTokenService,
41    reconstruction_cache::ReconstructionCacheService,
42    server_frontend::ServerFrontend,
43    server_role::ServerRole,
44    transfer_limiter::TransferLimiter,
45    xet_adapter::{XET_READ_TOKEN_ROUTE, XET_WRITE_TOKEN_ROUTE, XORB_TRANSFER_ROUTE},
46};
47use operational::{
48    head_xorb, health, metrics, read_chunk, read_xorb_transfer, ready, stats, upload_shard,
49    upload_xorb,
50};
51use protocol_routes::{
52    bazel_get_ac, bazel_get_cas, bazel_put_ac, bazel_put_cas, lfs_batch, lfs_get_object,
53    lfs_head_object, lfs_put_object, oci_api_dispatch, oci_dispatch, oci_registry_token,
54    oci_transfer_dispatch, oci_v2_root,
55};
56pub(crate) use protocol_routes::{parse_oci_path, parse_upload_content_range};
57use provider_routes::{
58    git_lfs_authenticate, handle_provider_webhook, issue_provider_token, issue_xet_read_token,
59    issue_xet_write_token,
60};
61use reconstruction_routes::{batch_reconstruction, reconstruction, reconstruction_v2};
62
63pub const MAX_BATCH_RECONSTRUCTION_FILE_IDS: usize = 1024;
64pub const MAX_BATCH_RECONSTRUCTION_QUERY_BYTES: usize = 131_072;
65pub const MAX_LFS_BATCH_OBJECTS: usize = 1024;
66pub const MAX_OCI_MANIFEST_TAGS: usize = 128;
67pub const MAX_OCI_TAG_LIST_PAGE_SIZE: usize = 256;
68pub const MAX_PROTOCOL_QUERY_BYTES: usize = 16_384;
69pub const MAX_PROVIDER_TOKEN_REQUEST_BODY_BYTES: usize = 16_384;
70pub const MAX_PROVIDER_WEBHOOK_BODY_BYTES: usize = 1_048_576;
71pub const MAX_PROVIDER_NAME_BYTES: usize = 64;
72pub const MAX_PROVIDER_SUBJECT_BYTES: usize = 512;
73pub const MAX_PROVIDER_BASIC_AUTH_HEADER_BYTES: usize = 4096;
74
75#[derive(Debug)]
76pub struct AppState {
77    pub config: ServerConfig,
78    pub role: ServerRole,
79    pub backend: ServerBackend,
80    pub auth: Option<ServerAuth>,
81    pub provider_tokens: Option<ProviderTokenService>,
82    pub reconstruction_cache: ReconstructionCacheService,
83    pub transfer_limiter: TransferLimiter,
84    pub oci_registry_token_limiter: Arc<Semaphore>,
85    pub protocol_metrics: ProtocolMetrics,
86}
87
88#[derive(Debug, Default)]
89pub struct ProtocolMetrics {
90    oci_registry_token_requests_total: AtomicU64,
91    oci_registry_token_rate_limited_total: AtomicU64,
92    oci_registry_token_active_requests: AtomicU64,
93}
94
95impl ProtocolMetrics {
96    fn increment_oci_registry_token_requests(&self) {
97        let _previous = self
98            .oci_registry_token_requests_total
99            .fetch_add(1, Ordering::Relaxed);
100    }
101
102    fn increment_oci_registry_token_rate_limited(&self) {
103        let _previous = self
104            .oci_registry_token_rate_limited_total
105            .fetch_add(1, Ordering::Relaxed);
106    }
107
108    fn begin_oci_registry_token_request(&self) -> ActiveProtocolRequestGuard<'_> {
109        let _previous = self
110            .oci_registry_token_active_requests
111            .fetch_add(1, Ordering::Relaxed);
112        ActiveProtocolRequestGuard {
113            gauge: &self.oci_registry_token_active_requests,
114        }
115    }
116}
117
118#[derive(Debug)]
119struct ActiveProtocolRequestGuard<'metric> {
120    gauge: &'metric AtomicU64,
121}
122
123impl Drop for ActiveProtocolRequestGuard<'_> {
124    fn drop(&mut self) {
125        let _previous = self.gauge.fetch_sub(1, Ordering::Relaxed);
126    }
127}
128
129/// Builds the Shardline HTTP router.
130///
131/// # Errors
132///
133/// Returns [`ServerError`] when the configured backend cannot initialize.
134pub async fn router(config: ServerConfig) -> Result<Router, ServerError> {
135    config.validate_runtime_requirements()?;
136    let role = config.server_role();
137    let max_request_body_bytes = config.max_request_body_bytes();
138    let provider_token_body_limit = bounded_api_body_limit(
139        max_request_body_bytes,
140        MAX_PROVIDER_TOKEN_REQUEST_BODY_BYTES,
141    );
142    let provider_webhook_body_limit =
143        bounded_api_body_limit(max_request_body_bytes, MAX_PROVIDER_WEBHOOK_BODY_BYTES);
144    let backend = ServerBackend::from_config(&config).await?;
145    let auth = build_auth_provider(&config).await?;
146    let provider_tokens = if role.serves_api() {
147        match (
148            config.provider_config_path(),
149            config.provider_api_key(),
150            config.provider_token_issuer(),
151            config.provider_token_ttl_seconds(),
152            config.token_signing_key(),
153        ) {
154            (
155                Some(config_path),
156                Some(api_key),
157                Some(issuer),
158                Some(ttl_seconds),
159                Some(signing_key),
160            ) => Some(ProviderTokenService::from_file(
161                config_path,
162                api_key.to_vec(),
163                issuer,
164                ttl_seconds,
165                signing_key,
166            )?),
167            _ => None,
168        }
169    } else {
170        None
171    };
172    let reconstruction_cache = if role.uses_reconstruction_cache() {
173        ReconstructionCacheService::from_config(&config)?
174    } else {
175        ReconstructionCacheService::disabled()
176    };
177    let transfer_limiter =
178        TransferLimiter::new(config.chunk_size(), config.transfer_max_in_flight_chunks());
179    let oci_registry_token_limiter = Arc::new(Semaphore::new(
180        config.oci_registry_token_max_in_flight_requests().get(),
181    ));
182    let state = Arc::new(AppState {
183        config,
184        role,
185        backend,
186        auth,
187        provider_tokens,
188        reconstruction_cache,
189        transfer_limiter,
190        oci_registry_token_limiter,
191        protocol_metrics: ProtocolMetrics::default(),
192    });
193
194    let mut app = Router::new()
195        .route("/healthz", get(health))
196        .route("/readyz", get(ready))
197        .route("/metrics", get(metrics))
198        .layer(MetricsLayer);
199    if role.serves_api() {
200        app = app
201            .route(
202                "/v1/providers/{provider}/tokens",
203                post(issue_provider_token).layer(DefaultBodyLimit::max(provider_token_body_limit)),
204            )
205            .route(
206                "/v1/providers/{provider}/git-lfs-authenticate",
207                post(git_lfs_authenticate).layer(DefaultBodyLimit::max(provider_token_body_limit)),
208            )
209            .route(
210                "/v1/providers/{provider}/webhooks",
211                post(handle_provider_webhook)
212                    .layer(DefaultBodyLimit::max(provider_webhook_body_limit)),
213            )
214            .route("/v1/stats", get(stats));
215    }
216    for frontend in state.config.server_frontends() {
217        app = register_frontend_routes(app, *frontend, role, &state)?;
218    }
219
220    Ok(app
221        .layer(DefaultBodyLimit::max(max_request_body_bytes.get()))
222        .with_state(state))
223}
224
225/// Runs the Shardline HTTP server.
226///
227/// # Errors
228///
229/// Returns [`ServerError`] when the listener cannot bind or the server exits with an
230/// IO error.
231#[tracing::instrument(skip(config), fields(bind_addr = %config.bind_addr()))]
232pub async fn serve(config: ServerConfig) -> Result<(), ServerError> {
233    let listener = TcpListener::bind(config.bind_addr()).await?;
234    tracing::info!("listening on {}", config.bind_addr());
235    serve_with_listener(config, listener).await
236}
237
238/// Runs the Shardline HTTP server on an existing listener.
239///
240/// # Errors
241///
242/// Returns [`ServerError`] when router initialization fails or the server exits with
243/// an IO error.
244pub async fn serve_with_listener(
245    config: ServerConfig,
246    listener: TcpListener,
247) -> Result<(), ServerError> {
248    let app = router(config).await?;
249    tracing::info!("router initialized, starting HTTP serve");
250    serve_http(listener, app).await?;
251    Ok(())
252}
253
254fn register_frontend_routes(
255    app: Router<Arc<AppState>>,
256    frontend: ServerFrontend,
257    role: ServerRole,
258    app_state: &AppState,
259) -> Result<Router<Arc<AppState>>, ServerError> {
260    match frontend {
261        ServerFrontend::Xet => Ok(register_xet_routes(app, role)),
262        ServerFrontend::Lfs => Ok(register_lfs_routes(app, role)),
263        ServerFrontend::BazelHttp => Ok(register_bazel_routes(app, role)),
264        ServerFrontend::Oci => Ok(register_oci_routes(app, role)),
265        ServerFrontend::Hub => register_hub_routes(app, app_state),
266        ServerFrontend::Metrics => Ok(app.merge(metrics_routes::<Arc<AppState>>())),
267    }
268}
269
270fn register_xet_routes(mut app: Router<Arc<AppState>>, role: ServerRole) -> Router<Arc<AppState>> {
271    if role.serves_api() {
272        app = app
273            .route(XET_READ_TOKEN_ROUTE, get(issue_xet_read_token))
274            .route(XET_WRITE_TOKEN_ROUTE, get(issue_xet_write_token))
275            .route("/reconstructions", get(batch_reconstruction))
276            .route("/v1/reconstructions", get(batch_reconstruction))
277            .route("/v1/reconstructions/{file_id}", get(reconstruction))
278            .route("/v2/reconstructions/{file_id}", get(reconstruction_v2))
279            .route("/shards", post(upload_shard))
280            .route("/v1/shards", post(upload_shard));
281    }
282    if role.serves_transfer() {
283        app = app
284            .route("/v1/chunks/default/{hash}", get(read_chunk))
285            .route("/v1/chunks/default-merkledb/{hash}", get(read_chunk))
286            .route(
287                "/v1/xorbs/default/{hash}",
288                head(head_xorb).post(upload_xorb),
289            )
290            .route(XORB_TRANSFER_ROUTE, get(read_xorb_transfer));
291    }
292    app
293}
294
295fn register_lfs_routes(mut app: Router<Arc<AppState>>, role: ServerRole) -> Router<Arc<AppState>> {
296    if role.serves_api() {
297        app = app.route("/v1/lfs/objects/batch", post(lfs_batch));
298    }
299    if role.serves_transfer() {
300        app = app.route(
301            "/v1/lfs/objects/{oid}",
302            get(lfs_get_object)
303                .head(lfs_head_object)
304                .put(lfs_put_object),
305        );
306    }
307    app
308}
309
310fn register_bazel_routes(
311    mut app: Router<Arc<AppState>>,
312    role: ServerRole,
313) -> Router<Arc<AppState>> {
314    if role.serves_transfer() {
315        app = app
316            .route(
317                "/v1/bazel/cache/ac/{hash}",
318                get(bazel_get_ac).put(bazel_put_ac),
319            )
320            .route(
321                "/v1/bazel/cache/cas/{hash}",
322                get(bazel_get_cas).put(bazel_put_cas),
323            );
324    }
325    app
326}
327
328fn register_oci_routes(mut app: Router<Arc<AppState>>, role: ServerRole) -> Router<Arc<AppState>> {
329    match role {
330        ServerRole::All => {
331            app = app
332                .route("/v2/token", get(oci_registry_token))
333                .route("/v2/", get(oci_v2_root))
334                .route("/v2/{*path}", axum::routing::any(oci_dispatch));
335        }
336        ServerRole::Api => {
337            app = app
338                .route("/v2/token", get(oci_registry_token))
339                .route("/v2/", get(oci_v2_root))
340                .route("/v2/{*path}", axum::routing::any(oci_api_dispatch));
341        }
342        ServerRole::Transfer => {
343            app = app.route("/v2/{*path}", axum::routing::any(oci_transfer_dispatch));
344        }
345    }
346    app
347}
348
349fn authorize(
350    state: &AppState,
351    headers: &HeaderMap,
352    required_scope: TokenScope,
353) -> Result<Option<AuthContext>, ServerError> {
354    if let Some(auth) = &state.auth {
355        return Ok(Some(auth.authorize(headers, required_scope)?));
356    }
357
358    Ok(None)
359}
360
361const fn scope_from_auth(auth: &AuthContext) -> &RepositoryScope {
362    auth.claims().repository()
363}
364
365#[must_use]
366pub fn bounded_api_body_limit(configured_limit: NonZeroUsize, endpoint_limit: usize) -> usize {
367    configured_limit.get().min(endpoint_limit)
368}
369
370fn register_hub_routes(
371    app: Router<Arc<AppState>>,
372    app_state: &AppState,
373) -> Result<Router<Arc<AppState>>, ServerError> {
374    let hub_auth = app_state
375        .auth
376        .as_ref()
377        .map(|sa| shardline_hub_api::auth::HubAuth::from_arc(sa.provider_arc()));
378
379    // Create a HubStore from the configured backend.
380    // When using Postgres, share the main server's pool so Hub API and the core
381    // server operate on the same database connection pool (multi-process safe).
382    let root_dir = app_state.config.root_dir();
383    let store: shardline_index::hub::BoxedHubStore =
384        app_state.config.index_postgres_url().map_or_else(
385            || -> Result<shardline_index::hub::BoxedHubStore, ServerError> {
386                let hub_root = root_dir.join("hub");
387                if let Err(e) = std::fs::create_dir_all(&hub_root) {
388                    tracing::warn!("failed to create hub directory: {e}");
389                }
390                let sqlite_store = shardline_index::LocalIndexStore::new(hub_root)
391                    .map_err(|e| ServerError::Io(std::io::Error::other(e)))?;
392                Ok(shardline_index::hub::BoxedHubStore::from_store(
393                    sqlite_store,
394                ))
395            },
396            |pg_url| -> Result<shardline_index::hub::BoxedHubStore, ServerError> {
397                let pool = sqlx::postgres::PgPoolOptions::new()
398                    .max_connections(16)
399                    .connect_lazy(pg_url)
400                    .map_err(|e| ServerError::Io(std::io::Error::other(e)))?;
401                let pg_store = shardline_index::PostgresIndexStore::new(pool);
402                Ok(shardline_index::hub::BoxedHubStore::from_store(pg_store))
403            },
404        )?;
405
406    // Build an HTTP client for outbound webhook delivery.
407    let http_client = reqwest::Client::builder()
408        .timeout(std::time::Duration::from_secs(10))
409        .build()
410        .ok();
411
412    let hub_state = shardline_hub_api::routes::HubState {
413        store,
414        auth: hub_auth,
415        http_client,
416    };
417    shardline_hub_api::init(hub_state);
418    Ok(app.merge(shardline_hub_api::hub_routes()))
419}
420
421fn endpoint_body_limit(
422    configured_limit: NonZeroUsize,
423    endpoint_limit: usize,
424) -> Result<NonZeroUsize, ServerError> {
425    NonZeroUsize::new(bounded_api_body_limit(configured_limit, endpoint_limit))
426        .ok_or(ServerError::Overflow)
427}
428
429/// Acquires a semaphore permit for a chunk transfer.
430///
431/// # Errors
432///
433/// Returns [`ServerError`] if the chunk length cannot be determined or the transfer limiter
434/// is closed.
435pub async fn acquire_chunk_transfer_permit(
436    state: &AppState,
437    hash_hex: &str,
438) -> Result<OwnedSemaphorePermit, ServerError> {
439    let total_bytes = state.backend.chunk_length(hash_hex).await?;
440    state.transfer_limiter.acquire_bytes(total_bytes).await
441}
442
443async fn build_auth_provider(config: &ServerConfig) -> Result<Option<ServerAuth>, ServerError> {
444    match config.auth_provider() {
445        AuthProviderKind::Local => {
446            let Some(key) = config.token_signing_key() else {
447                return Ok(None);
448            };
449            Ok(Some(ServerAuth::new(key)?))
450        }
451        AuthProviderKind::Passthrough => {
452            let provider = Box::new(shardline_server_core::auth::PassthroughProvider);
453            Ok(Some(ServerAuth::from_provider(provider)))
454        }
455        AuthProviderKind::Oidc => {
456            let issuer = config.auth_oidc_issuer().ok_or_else(|| {
457                ServerError::Config(crate::config::ServerConfigError::InvalidAuthProvider)
458            })?;
459            let provider = crate::oidc_provider::OidcProvider::new(issuer, None)
460                .await
461                .map_err(|_e| {
462                    ServerError::Config(crate::config::ServerConfigError::InvalidAuthProvider)
463                })?;
464            Ok(Some(ServerAuth::from_provider(Box::new(provider))))
465        }
466        AuthProviderKind::Jwks => {
467            let jwks_url = config.auth_jwks_url().ok_or_else(|| {
468                ServerError::Config(crate::config::ServerConfigError::InvalidAuthProvider)
469            })?;
470            let issuer = config.auth_jwks_issuer().unwrap_or("jwks");
471            let provider = crate::jwks_provider::JwksProvider::new(jwks_url, issuer)
472                .await
473                .map_err(|_e| {
474                    ServerError::Config(crate::config::ServerConfigError::InvalidAuthProvider)
475                })?;
476            Ok(Some(ServerAuth::from_provider(Box::new(provider))))
477        }
478    }
479}
480
481#[cfg(test)]
482mod tests;