mod error;
mod extractors;
mod handlers_downloads;
mod handlers_filesystem;
mod handlers_namespace;
mod handlers_query;
mod handlers_store;
mod handlers_uploads;
mod metrics;
#[cfg(feature = "openapi")]
mod openapi;
mod serve;
#[cfg(test)]
mod tests;
mod tls;
#[cfg(feature = "openapi")]
pub use self::openapi::{openapi_document, openapi_json_pretty};
pub use self::serve::{app, serve, serve_with_shutdown, ServeError};
pub use self::tls::TlsConfigError;
use self::error::ApiResponseError;
use self::extractors::{
authorize, server_busy_error, AppJson, AppPath, AppQuery, NamespaceIdPath, OptionalAppJson,
UploadBodyStream,
};
use self::handlers_downloads::begin_download;
use self::handlers_filesystem::{
apply_commit, get_file_bytes, list_changes, list_file_revisions, list_path_entries, list_trash,
stat_path,
};
use self::handlers_namespace::{
create_checkpoint, create_namespace, delete_namespace, fork_namespace, list_checkpoints,
maintenance_step, namespace_status, release_checkpoint,
};
use self::handlers_query::{
disable_grep_index, enable_grep_index, gc_grep_index, grep, grep_index_not_maintained,
grep_index_status, grep_queries_not_served,
};
use self::handlers_store::probe_store;
use self::handlers_uploads::{
abort_upload, begin_upload, complete_upload, read_upload_status, sign_upload_parts,
upload_content,
};
use self::serve::AppState;
#[cfg(test)]
use self::serve::{
app_with_store, app_with_store_and_state, app_with_store_and_transfer_issuer,
build_handles_with_metrics_jsonl_path, serve_on,
};
use axum::extract::{MatchedPath, Request, State};
use axum::http::header::CONTENT_TYPE;
use axum::http::{HeaderMap, HeaderValue, StatusCode};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post, put};
use axum::Router;
use loonfs::ErrorCode;
#[cfg(test)]
use loonfs::SharedObjectStore;
const REQUEST_ID_HEADER: &str = "x-request-id";
tokio::task_local! {
pub(super) static REQUEST_ID: String;
}
async fn with_request_id(request: Request, next: Next) -> Response {
let request_id = loonfs_api::generated_id("req");
let mut response = REQUEST_ID
.scope(request_id.clone(), next.run(request))
.await;
if let Ok(value) = HeaderValue::from_str(&request_id) {
response.headers_mut().insert(REQUEST_ID_HEADER, value);
}
response
}
async fn with_request_metrics(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Response {
let route = request
.extensions()
.get::<MatchedPath>()
.map(|matched| matched.as_str().to_owned());
let method = request.method().clone();
let started = request_clock();
let response = next.run(request).await;
state.metrics.request_served(
route.as_deref(),
&method,
response.status(),
started.elapsed().as_secs_f64(),
);
response
}
#[allow(clippy::disallowed_methods)]
fn request_clock() -> std::time::Instant {
std::time::Instant::now()
}
fn router(state: AppState) -> Router {
let serves_grep = state.config.grep.mode.serves_grep();
let maintains_index = state.config.grep.mode.maintains_index();
let grep_route = if serves_grep {
post(grep)
} else {
post(grep_queries_not_served)
};
let enable_grep_route = if maintains_index {
post(enable_grep_index)
} else {
post(grep_index_not_maintained)
};
let disable_grep_route = if maintains_index {
post(disable_grep_index)
} else {
post(grep_index_not_maintained)
};
let grep_gc_route = if maintains_index {
post(gc_grep_index)
} else {
post(grep_index_not_maintained)
};
let grep_status_route = if maintains_index {
get(grep_index_status)
} else {
get(grep_index_not_maintained)
};
Router::new()
.route("/health", get(health))
.route("/readiness", get(readiness))
.route("/metrics", get(serve_metrics))
.route("/v0/capabilities", get(handlers_namespace::capabilities))
.route("/v0/namespaces", post(create_namespace))
.route(
"/v0/namespaces/{namespace}",
get(namespace_status).delete(delete_namespace),
)
.route("/v0/namespaces/{namespace}/forks", post(fork_namespace))
.route(
"/v0/namespaces/{namespace}/filesystem/list",
get(list_path_entries),
)
.route("/v0/namespaces/{namespace}/filesystem/stat", get(stat_path))
.route(
"/v0/namespaces/{namespace}/filesystem/content",
get(get_file_bytes),
)
.route(
"/v0/namespaces/{namespace}/filesystem/downloads",
post(begin_download),
)
.route("/v0/namespaces/{namespace}/query/grep", grep_route)
.route(
"/v0/admin/namespaces/{namespace}/grep/index",
grep_status_route,
)
.route(
"/v0/admin/namespaces/{namespace}/grep/index/enable",
enable_grep_route,
)
.route(
"/v0/admin/namespaces/{namespace}/grep/index/disable",
disable_grep_route,
)
.route(
"/v0/admin/namespaces/{namespace}/grep/index/gc",
grep_gc_route,
)
.route(
"/v0/namespaces/{namespace}/filesystem/revisions",
get(list_file_revisions),
)
.route(
"/v0/namespaces/{namespace}/filesystem/trash",
get(list_trash),
)
.route("/v0/namespaces/{namespace}/commits", post(apply_commit))
.route("/v0/namespaces/{namespace}/uploads", post(begin_upload))
.route(
"/v0/namespaces/{namespace}/uploads/{upload_id}/content",
put(upload_content),
)
.route(
"/v0/namespaces/{namespace}/uploads/{upload_id}/parts",
post(sign_upload_parts),
)
.route(
"/v0/namespaces/{namespace}/uploads/{upload_id}/complete",
post(complete_upload),
)
.route(
"/v0/namespaces/{namespace}/uploads/{upload_id}/abort",
post(abort_upload),
)
.route(
"/v0/namespaces/{namespace}/uploads/{upload_id}",
get(read_upload_status),
)
.route("/v0/namespaces/{namespace}/changes", get(list_changes))
.route(
"/v0/admin/namespaces/{namespace}/checkpoints",
post(create_checkpoint).get(list_checkpoints),
)
.route(
"/v0/admin/namespaces/{namespace}/checkpoints/{checkpoint_id}/release",
post(release_checkpoint),
)
.route(
"/v0/admin/namespaces/{namespace}/maintenance/step",
post(maintenance_step),
)
.route("/v0/admin/store/probe", post(probe_store))
.fallback(route_not_found)
.method_not_allowed_fallback(method_not_allowed)
.layer(middleware::from_fn_with_state(
state.clone(),
with_request_metrics,
))
.layer(middleware::from_fn(with_request_id))
.with_state(state)
}
async fn route_not_found() -> ApiResponseError {
ApiResponseError::new(
StatusCode::NOT_FOUND,
ErrorCode::RouteNotFound,
"no v0 route matches this path; see the API spec for the served surface",
)
}
async fn method_not_allowed() -> ApiResponseError {
ApiResponseError::new(
StatusCode::METHOD_NOT_ALLOWED,
ErrorCode::MethodNotAllowed,
"this path exists but does not serve this HTTP method",
)
}
#[cfg_attr(
feature = "openapi",
utoipa::path(
get,
path = "/health",
tag = "health",
summary = "Check health",
description = "Returns `ok` when the server is running and can accept requests.",
security(()),
responses((status = 200, description = "Server health check", body = String))
)
)]
async fn health() -> &'static str {
"ok"
}
#[cfg_attr(
feature = "openapi",
utoipa::path(
get,
path = "/readiness",
tag = "health",
summary = "Check readiness",
description = "Returns `ready` while the server admits new work. Once shutdown \
begins and publisher admission closes, answers 503 `shutting_down` \
so load balancers can drain the instance. `/health` stays the \
liveness probe: it only reports that the process is up.",
security(()),
responses(
(status = 200, description = "The server admits new work", body = String),
(status = 503, description = "Shutdown has begun; admission is closed", body = loonfs_api::ApiError)
)
)
)]
async fn readiness(State(state): State<AppState>) -> Result<&'static str, ApiResponseError> {
if state.writer.is_shutting_down() {
return Err(ApiResponseError::new(
StatusCode::SERVICE_UNAVAILABLE,
ErrorCode::ShuttingDown,
"the server is shutting down and no longer admits new work",
));
}
Ok("ready")
}
const PROMETHEUS_CONTENT_TYPE: &str = "text/plain; version=0.0.4";
#[cfg_attr(
feature = "openapi",
utoipa::path(
get,
path = "/metrics",
tag = "health",
summary = "Scrape metrics",
description = "Returns this process's metrics in Prometheus text exposition format \
0.0.4. Unlike `/health` and `/readiness`, the route requires the \
deployment's bearer token.",
responses(
(status = 200, description = "Prometheus text exposition", body = String),
(status = 401, description = "Missing or invalid bearer token", body = loonfs_api::ApiError)
)
)
)]
async fn serve_metrics(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Response, ApiResponseError> {
authorize(&state.config, &headers)?;
let rendered = state.metrics.render(
&state.writer.runtime_cache_stats(),
state.upload_permits.available_permits(),
state.download_permits.available_permits(),
);
Ok(([(CONTENT_TYPE, PROMETHEUS_CONTENT_TYPE)], rendered).into_response())
}