mirror of
https://github.com/telemt/telemt.git
synced 2026-09-07 11:06:14 +03:00
WEB Knobs in API
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,444 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use http_body_util::Full;
|
||||
use hyper::body::{Bytes, Incoming};
|
||||
use hyper::header::CONTENT_TYPE;
|
||||
use hyper::{Method, Request, Response, StatusCode};
|
||||
use serde::Serialize;
|
||||
|
||||
use super::config_store::current_revision;
|
||||
use super::http_utils::{read_json, success_response};
|
||||
use super::model::ApiFailure;
|
||||
use super::{ALLOW_GET, ALLOW_POST, ApiShared};
|
||||
use crate::config::ProxyConfig;
|
||||
use crate::web::control::{WebRuntimeLifecycle, WebRuntimePublication};
|
||||
use crate::web::manager::{ControlError, SessionDetail, WebProcessRuntime};
|
||||
|
||||
// Exact JSON DTOs and strict query parsing stay independent from route dispatch.
|
||||
mod request;
|
||||
use request::{
|
||||
CloseRequest, RuntimeInstanceRequest, parse_session_query, parse_session_ref,
|
||||
valid_runtime_instance,
|
||||
};
|
||||
|
||||
const STATUS_PATH: &str = "/v1/runtime/web/status";
|
||||
const SESSIONS_PATH: &str = "/v1/runtime/web/sessions";
|
||||
const CLOSE_PATH: &str = "/v1/runtime/web/sessions/close";
|
||||
const DEBUG_CLEAR_PATH: &str = "/v1/runtime/web/debug/clear";
|
||||
const LEARNING_RESET_PATH: &str = "/v1/runtime/web/carrier-learning/reset";
|
||||
const SESSION_DETAIL_PREFIX: &str = "/v1/runtime/web/sessions/";
|
||||
const OPERATION_PREFIX: &str = "/v1/runtime/web/operations/";
|
||||
const MAX_CONTROL_BODY_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// Returns the exact allowed method set for a WEB runtime route.
|
||||
pub(super) fn allowed_methods(path: &str) -> Option<&'static str> {
|
||||
match path {
|
||||
STATUS_PATH | SESSIONS_PATH => Some(ALLOW_GET),
|
||||
CLOSE_PATH | DEBUG_CLEAR_PATH | LEARNING_RESET_PATH => Some(ALLOW_POST),
|
||||
_ if detail_ref(path).is_some() || operation_ref(path).is_some() => Some(ALLOW_GET),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the normalized API path belongs to WEB runtime control.
|
||||
pub(super) fn is_route(path: &str) -> bool {
|
||||
allowed_methods(path).is_some()
|
||||
}
|
||||
|
||||
/// Dispatches one authenticated WEB runtime status or control request.
|
||||
pub(super) async fn handle(
|
||||
method: Method,
|
||||
path: &str,
|
||||
query: Option<&str>,
|
||||
request: Request<Incoming>,
|
||||
shared: &ApiShared,
|
||||
config: &ProxyConfig,
|
||||
_request_id: u64,
|
||||
body_limit: usize,
|
||||
) -> Result<Response<Full<Bytes>>, ApiFailure> {
|
||||
let revision = current_revision(&shared.config_path).await?;
|
||||
match (method.as_str(), path) {
|
||||
("GET", STATUS_PATH) => {
|
||||
reject_query(query)?;
|
||||
let publication = shared.web_runtime_rx.borrow().clone();
|
||||
let runtime = publication.runtime.upgrade();
|
||||
let data = WebStatusData::new(publication, runtime.as_deref(), config.web.enabled);
|
||||
Ok(success_response(StatusCode::OK, data, revision))
|
||||
}
|
||||
("GET", SESSIONS_PATH) => {
|
||||
let runtime = readable_runtime(shared)?;
|
||||
let request = parse_session_query(&runtime, query)?;
|
||||
let page = runtime.list_sessions(request);
|
||||
Ok(success_response(StatusCode::OK, page, revision))
|
||||
}
|
||||
("GET", _) if detail_ref(path).is_some() => {
|
||||
reject_query(query)?;
|
||||
let runtime = readable_runtime(shared)?;
|
||||
let session_ref = detail_ref(path).expect("route guard checked detail reference");
|
||||
let trace_session_id = parse_session_ref(&runtime, session_ref)?;
|
||||
match runtime.session_detail(trace_session_id) {
|
||||
SessionDetail::Active(row) => Ok(success_response(StatusCode::OK, row, revision)),
|
||||
SessionDetail::Gone { attempt } => Ok(success_response(
|
||||
StatusCode::GONE,
|
||||
GoneSessionData {
|
||||
session_ref: session_ref.to_string(),
|
||||
state: "closed",
|
||||
attempt,
|
||||
},
|
||||
revision,
|
||||
)),
|
||||
SessionDetail::Busy => Err(snapshot_busy()),
|
||||
SessionDetail::NotFound => Err(ApiFailure::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
"web_session_not_found",
|
||||
"WEB session was not found",
|
||||
)),
|
||||
}
|
||||
}
|
||||
("GET", _) if operation_ref(path).is_some() => {
|
||||
reject_query(query)?;
|
||||
let runtime = readable_runtime(shared)?;
|
||||
let operation_id = operation_ref(path).expect("route guard checked operation id");
|
||||
let status = runtime
|
||||
.control_operation(operation_id)
|
||||
.map_err(control_failure)?;
|
||||
Ok(success_response(StatusCode::OK, status, revision))
|
||||
}
|
||||
("POST", CLOSE_PATH) => {
|
||||
require_mutable(config)?;
|
||||
reject_query(query)?;
|
||||
require_json_content_type(&request)?;
|
||||
let request = read_json::<CloseRequest>(
|
||||
request.into_body(),
|
||||
body_limit.min(MAX_CONTROL_BODY_BYTES),
|
||||
)
|
||||
.await?;
|
||||
let runtime = control_runtime(shared)?;
|
||||
require_runtime_instance(&runtime, &request.runtime_instance)?;
|
||||
let selector = request.selector.resolve(&runtime)?;
|
||||
let status = runtime
|
||||
.start_close_operation(&request.runtime_instance, selector)
|
||||
.map_err(control_failure)?;
|
||||
shared.runtime_events.record(
|
||||
"api.web.sessions.close.accepted",
|
||||
format!(
|
||||
"operation_id={} requested={}",
|
||||
status.operation_id, status.requested
|
||||
),
|
||||
);
|
||||
Ok(success_response(StatusCode::ACCEPTED, status, revision))
|
||||
}
|
||||
("POST", DEBUG_CLEAR_PATH) => {
|
||||
require_mutable(config)?;
|
||||
reject_query(query)?;
|
||||
require_json_content_type(&request)?;
|
||||
let request = read_json::<RuntimeInstanceRequest>(
|
||||
request.into_body(),
|
||||
body_limit.min(MAX_CONTROL_BODY_BYTES),
|
||||
)
|
||||
.await?;
|
||||
let runtime = control_runtime(shared)?;
|
||||
require_runtime_instance(&runtime, &request.runtime_instance)?;
|
||||
let outcome = runtime.clear_debug().map_err(control_failure)?;
|
||||
let data = DebugClearData {
|
||||
runtime_instance: runtime.runtime_instance().to_string(),
|
||||
records_cleared: outcome.records_cleared,
|
||||
leased_bytes: outcome.leased_bytes,
|
||||
epoch: outcome.epoch,
|
||||
};
|
||||
shared.runtime_events.record(
|
||||
"api.web.debug.clear.ok",
|
||||
format!("records={} epoch={}", data.records_cleared, data.epoch),
|
||||
);
|
||||
Ok(success_response(StatusCode::OK, data, revision))
|
||||
}
|
||||
("POST", LEARNING_RESET_PATH) => {
|
||||
require_mutable(config)?;
|
||||
reject_query(query)?;
|
||||
require_json_content_type(&request)?;
|
||||
let request = read_json::<RuntimeInstanceRequest>(
|
||||
request.into_body(),
|
||||
body_limit.min(MAX_CONTROL_BODY_BYTES),
|
||||
)
|
||||
.await?;
|
||||
let runtime = control_runtime(shared)?;
|
||||
require_runtime_instance(&runtime, &request.runtime_instance)?;
|
||||
let outcome = runtime
|
||||
.reset_carrier_learning()
|
||||
.map_err(|_| runtime_unavailable(WebRuntimeLifecycle::Draining))?;
|
||||
let data = LearningResetData {
|
||||
runtime_instance: runtime.runtime_instance().to_string(),
|
||||
entries_cleared: outcome.entries_cleared,
|
||||
epoch: outcome.epoch,
|
||||
};
|
||||
shared.runtime_events.record(
|
||||
"api.web.carrier_learning.reset.ok",
|
||||
format!("entries={} epoch={}", data.entries_cleared, data.epoch),
|
||||
);
|
||||
Ok(success_response(StatusCode::OK, data, revision))
|
||||
}
|
||||
_ => Err(ApiFailure::method_not_allowed(
|
||||
allowed_methods(path).unwrap_or(ALLOW_GET),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct WebStatusData {
|
||||
lifecycle: &'static str,
|
||||
lifecycle_epoch: u64,
|
||||
lifecycle_age_ms: u64,
|
||||
available: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
reason: Option<&'static str>,
|
||||
listeners: Vec<String>,
|
||||
effective_config_enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
runtime: Option<crate::web::manager::WebRuntimeStatus>,
|
||||
}
|
||||
|
||||
impl WebStatusData {
|
||||
fn new(
|
||||
publication: WebRuntimePublication,
|
||||
runtime: Option<&WebProcessRuntime>,
|
||||
effective_config_enabled: bool,
|
||||
) -> Self {
|
||||
let available = runtime.is_some()
|
||||
&& matches!(
|
||||
publication.lifecycle,
|
||||
WebRuntimeLifecycle::Running | WebRuntimeLifecycle::Draining
|
||||
);
|
||||
let reason = if available {
|
||||
None
|
||||
} else {
|
||||
Some(match publication.lifecycle {
|
||||
WebRuntimeLifecycle::Starting => "starting",
|
||||
WebRuntimeLifecycle::NoWebListener => "no_web_listener",
|
||||
WebRuntimeLifecycle::Running => "runtime_released",
|
||||
WebRuntimeLifecycle::Draining => "runtime_released",
|
||||
WebRuntimeLifecycle::Drained => "drained",
|
||||
WebRuntimeLifecycle::DeadlineExceeded => "deadline_exceeded",
|
||||
})
|
||||
};
|
||||
Self {
|
||||
lifecycle: publication.lifecycle.as_str(),
|
||||
lifecycle_epoch: publication.epoch,
|
||||
lifecycle_age_ms: millis(Instant::now().saturating_duration_since(publication.since)),
|
||||
available,
|
||||
reason,
|
||||
listeners: publication
|
||||
.listeners
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect(),
|
||||
effective_config_enabled,
|
||||
runtime: runtime.map(WebProcessRuntime::try_status),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GoneSessionData {
|
||||
session_ref: String,
|
||||
state: &'static str,
|
||||
attempt: u8,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DebugClearData {
|
||||
runtime_instance: String,
|
||||
records_cleared: usize,
|
||||
leased_bytes: usize,
|
||||
epoch: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LearningResetData {
|
||||
runtime_instance: String,
|
||||
entries_cleared: usize,
|
||||
epoch: u64,
|
||||
}
|
||||
|
||||
fn readable_runtime(shared: &ApiShared) -> Result<Arc<WebProcessRuntime>, ApiFailure> {
|
||||
let publication = shared.web_runtime_rx.borrow().clone();
|
||||
if !matches!(
|
||||
publication.lifecycle,
|
||||
WebRuntimeLifecycle::Running | WebRuntimeLifecycle::Draining
|
||||
) {
|
||||
return Err(runtime_unavailable(publication.lifecycle));
|
||||
}
|
||||
publication
|
||||
.runtime
|
||||
.upgrade()
|
||||
.ok_or_else(|| runtime_unavailable(publication.lifecycle))
|
||||
}
|
||||
|
||||
fn control_runtime(shared: &ApiShared) -> Result<Arc<WebProcessRuntime>, ApiFailure> {
|
||||
let publication = shared.web_runtime_rx.borrow().clone();
|
||||
if publication.lifecycle != WebRuntimeLifecycle::Running {
|
||||
return Err(runtime_unavailable(publication.lifecycle));
|
||||
}
|
||||
publication
|
||||
.runtime
|
||||
.upgrade()
|
||||
.ok_or_else(|| runtime_unavailable(publication.lifecycle))
|
||||
}
|
||||
|
||||
fn runtime_unavailable(lifecycle: WebRuntimeLifecycle) -> ApiFailure {
|
||||
ApiFailure::new(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"web_runtime_unavailable",
|
||||
format!("WEB runtime is unavailable: {}", lifecycle.as_str()),
|
||||
)
|
||||
}
|
||||
|
||||
fn require_mutable(config: &ProxyConfig) -> Result<(), ApiFailure> {
|
||||
if config.server.api.read_only {
|
||||
return Err(ApiFailure::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"read_only",
|
||||
"API runs in read-only mode",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_json_content_type<B>(request: &Request<B>) -> Result<(), ApiFailure> {
|
||||
let mut values = request.headers().get_all(CONTENT_TYPE).iter();
|
||||
let exact = values
|
||||
.next()
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value == "application/json")
|
||||
&& values.next().is_none();
|
||||
if !exact {
|
||||
return Err(ApiFailure::new(
|
||||
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
||||
"unsupported_media_type",
|
||||
"Content-Type must be exactly application/json",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_runtime_instance(
|
||||
runtime: &WebProcessRuntime,
|
||||
runtime_instance: &str,
|
||||
) -> Result<(), ApiFailure> {
|
||||
if !valid_runtime_instance(runtime_instance) {
|
||||
return Err(ApiFailure::bad_request(
|
||||
"runtime_instance must be 32 lowercase hexadecimal characters",
|
||||
));
|
||||
}
|
||||
if runtime.runtime_instance() != runtime_instance {
|
||||
return Err(ApiFailure::new(
|
||||
StatusCode::CONFLICT,
|
||||
"web_runtime_mismatch",
|
||||
"WEB runtime instance no longer matches",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn control_failure(error: ControlError) -> ApiFailure {
|
||||
match error {
|
||||
ControlError::StaleInstance => ApiFailure::new(
|
||||
StatusCode::CONFLICT,
|
||||
"web_runtime_mismatch",
|
||||
"WEB runtime instance no longer matches",
|
||||
),
|
||||
ControlError::InvalidSelector | ControlError::InvalidOperation => {
|
||||
ApiFailure::bad_request("Invalid WEB control request")
|
||||
}
|
||||
ControlError::IssuanceEnabled => ApiFailure::new(
|
||||
StatusCode::CONFLICT,
|
||||
"web_issuance_enabled",
|
||||
"Close-all requires effective WEB issuance to be disabled",
|
||||
),
|
||||
ControlError::OperationInProgress => ApiFailure::new(
|
||||
StatusCode::CONFLICT,
|
||||
"web_operation_in_progress",
|
||||
"Another WEB close operation is active",
|
||||
),
|
||||
ControlError::OperationNotFound => ApiFailure::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
"web_operation_not_found",
|
||||
"WEB control operation was not found",
|
||||
),
|
||||
ControlError::Closed => runtime_unavailable(WebRuntimeLifecycle::Draining),
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_busy() -> ApiFailure {
|
||||
ApiFailure::new(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"web_snapshot_busy",
|
||||
"WEB runtime snapshot is temporarily busy",
|
||||
)
|
||||
}
|
||||
|
||||
fn reject_query(query: Option<&str>) -> Result<(), ApiFailure> {
|
||||
if query.is_some_and(|query| !query.is_empty()) {
|
||||
return Err(ApiFailure::bad_request(
|
||||
"This endpoint does not accept query parameters",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn detail_ref(path: &str) -> Option<&str> {
|
||||
path.strip_prefix(SESSION_DETAIL_PREFIX)
|
||||
.filter(|value| !value.is_empty() && !value.contains('/') && *value != "close")
|
||||
}
|
||||
|
||||
fn operation_ref(path: &str) -> Option<&str> {
|
||||
path.strip_prefix(OPERATION_PREFIX)
|
||||
.filter(|value| !value.is_empty() && !value.contains('/'))
|
||||
}
|
||||
|
||||
fn millis(duration: std::time::Duration) -> u64 {
|
||||
duration.as_millis().min(u128::from(u64::MAX)) as u64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use hyper::header::HeaderValue;
|
||||
|
||||
#[test]
|
||||
fn route_table_keeps_status_read_only_and_controls_post_only() {
|
||||
assert_eq!(allowed_methods(STATUS_PATH), Some(ALLOW_GET));
|
||||
assert_eq!(allowed_methods(SESSIONS_PATH), Some(ALLOW_GET));
|
||||
assert_eq!(allowed_methods(CLOSE_PATH), Some(ALLOW_POST));
|
||||
assert_eq!(allowed_methods(DEBUG_CLEAR_PATH), Some(ALLOW_POST));
|
||||
assert_eq!(allowed_methods(LEARNING_RESET_PATH), Some(ALLOW_POST));
|
||||
assert_eq!(
|
||||
allowed_methods("/v1/runtime/web/sessions/ws1.instance.0000000000000001"),
|
||||
Some(ALLOW_GET)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_content_type_is_exact_and_single() {
|
||||
let exact = Request::builder()
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(require_json_content_type(&exact).is_ok());
|
||||
|
||||
let parameterized = Request::builder()
|
||||
.header(CONTENT_TYPE, "application/json; charset=utf-8")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(require_json_content_type(¶meterized).is_err());
|
||||
|
||||
let mut duplicated = Request::builder()
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(())
|
||||
.unwrap();
|
||||
duplicated
|
||||
.headers_mut()
|
||||
.append(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
assert!(require_json_content_type(&duplicated).is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user