增加rtr一些必要的api接口
This commit is contained in:
857
src/rtr/admin.rs
Normal file
857
src/rtr/admin.rs
Normal file
@@ -0,0 +1,857 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::{mpsc, oneshot, watch};
|
||||
use tokio::time::sleep;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::rtr::config::{RuntimeConfig, RuntimeConfigPatch};
|
||||
use crate::slurm::admin::{
|
||||
SlurmAdmin, SlurmFileActionRequest, SlurmFileOperationResult, SlurmFileWriteRequest,
|
||||
parse_reload_query,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RuntimeConfigHandle {
|
||||
current: Arc<RwLock<RuntimeConfig>>,
|
||||
tx: watch::Sender<RuntimeConfig>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SourceReloadHandle {
|
||||
tx: mpsc::Sender<SourceReloadCommand>,
|
||||
}
|
||||
|
||||
impl SourceReloadHandle {
|
||||
pub fn new(tx: mpsc::Sender<SourceReloadCommand>) -> Self {
|
||||
Self { tx }
|
||||
}
|
||||
|
||||
pub async fn reload(&self, phase: &'static str, force: bool) -> Result<SourceReloadResult> {
|
||||
let (respond_to, response) = oneshot::channel();
|
||||
self.tx
|
||||
.send(SourceReloadCommand {
|
||||
phase,
|
||||
force,
|
||||
respond_to,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| anyhow!("source reload task is not available"))?;
|
||||
response
|
||||
.await
|
||||
.map_err(|_| anyhow!("source reload task dropped the response"))?
|
||||
.map_err(anyhow::Error::msg)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SourceReloadCommand {
|
||||
pub phase: &'static str,
|
||||
pub force: bool,
|
||||
pub respond_to: oneshot::Sender<Result<SourceReloadResult, String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SourceReloadResult {
|
||||
pub phase: &'static str,
|
||||
pub changed: bool,
|
||||
pub skipped_unchanged: bool,
|
||||
pub payload_count: Option<usize>,
|
||||
pub serials: [u32; 3],
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AdminState {
|
||||
runtime_config: RuntimeConfigHandle,
|
||||
source_reload: Option<SourceReloadHandle>,
|
||||
slurm_admin: Option<SlurmAdmin>,
|
||||
log_tail: LogTailConfig,
|
||||
}
|
||||
|
||||
impl AdminState {
|
||||
pub fn new(
|
||||
runtime_config: RuntimeConfigHandle,
|
||||
source_reload: Option<SourceReloadHandle>,
|
||||
slurm_admin: Option<SlurmAdmin>,
|
||||
log_tail: LogTailConfig,
|
||||
) -> Self {
|
||||
Self {
|
||||
runtime_config,
|
||||
source_reload,
|
||||
slurm_admin,
|
||||
log_tail,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LogTailConfig {
|
||||
dir: PathBuf,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl LogTailConfig {
|
||||
pub fn from_env() -> Self {
|
||||
let dir = std::env::var_os("RPKI_RTR_LOG_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("/app/logs"));
|
||||
let name = std::env::var("RPKI_RTR_LOG_NAME")
|
||||
.or_else(|_| std::env::var("HOSTNAME"))
|
||||
.unwrap_or_else(|_| "rpki-rtr".to_string());
|
||||
Self { dir, name }
|
||||
}
|
||||
|
||||
fn path_for(&self, stream: LogStream) -> PathBuf {
|
||||
self.dir
|
||||
.join(format!("{}.{}.log", self.name, stream.as_str()))
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeConfigHandle {
|
||||
pub fn new(config: RuntimeConfig) -> Self {
|
||||
let (tx, _) = watch::channel(config.clone());
|
||||
Self {
|
||||
current: Arc::new(RwLock::new(config)),
|
||||
tx,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current(&self) -> RuntimeConfig {
|
||||
self.current
|
||||
.read()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> watch::Receiver<RuntimeConfig> {
|
||||
self.tx.subscribe()
|
||||
}
|
||||
|
||||
pub fn apply_patch(&self, patch: RuntimeConfigPatch) -> Result<RuntimeConfig> {
|
||||
let next = self.current().apply_patch(patch)?;
|
||||
{
|
||||
let mut current = self
|
||||
.current
|
||||
.write()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
*current = next.clone();
|
||||
}
|
||||
let _ = self.tx.send_replace(next.clone());
|
||||
Ok(next)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn_admin_config_server(
|
||||
addr: SocketAddr,
|
||||
token: Option<String>,
|
||||
state: AdminState,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = run_admin_config_server(addr, token, state).await {
|
||||
warn!("RTR admin config server exited: {:?}", err);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn run_admin_config_server(
|
||||
addr: SocketAddr,
|
||||
token: Option<String>,
|
||||
state: AdminState,
|
||||
) -> Result<()> {
|
||||
if token.is_none() && !addr.ip().is_loopback() {
|
||||
return Err(anyhow!(
|
||||
"RPKI_RTR_ADMIN_TOKEN is required when admin addr is not loopback: {}",
|
||||
addr
|
||||
));
|
||||
}
|
||||
|
||||
let listener = TcpListener::bind(addr)
|
||||
.await
|
||||
.with_context(|| format!("bind RTR admin config server on {addr}"))?;
|
||||
info!("RTR admin config server listening on {}", addr);
|
||||
|
||||
loop {
|
||||
let (stream, peer_addr) = listener.accept().await?;
|
||||
let token = token.clone();
|
||||
let state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = handle_admin_connection(stream, peer_addr, token, state).await {
|
||||
warn!(
|
||||
"RTR admin config request failed from {}: {:?}",
|
||||
peer_addr, err
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_admin_connection(
|
||||
mut stream: TcpStream,
|
||||
peer_addr: SocketAddr,
|
||||
token: Option<String>,
|
||||
state: AdminState,
|
||||
) -> Result<()> {
|
||||
let mut buffer = vec![0u8; 64 * 1024];
|
||||
let mut read = 0usize;
|
||||
let header_end = loop {
|
||||
if read == buffer.len() {
|
||||
write_response(&mut stream, 413, "payload too large", "text/plain").await?;
|
||||
return Ok(());
|
||||
}
|
||||
let n = stream.read(&mut buffer[read..]).await?;
|
||||
if n == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
read += n;
|
||||
if let Some(pos) = find_header_end(&buffer[..read]) {
|
||||
break pos;
|
||||
}
|
||||
};
|
||||
|
||||
let header = std::str::from_utf8(&buffer[..header_end])
|
||||
.map_err(|err| anyhow!("invalid HTTP header from {}: {}", peer_addr, err))?;
|
||||
let request = parse_request_header(header)?;
|
||||
|
||||
if let Some(token) = token.as_deref() {
|
||||
let expected = format!("Bearer {token}");
|
||||
if request.authorization.as_deref() != Some(expected.as_str()) {
|
||||
write_response(&mut stream, 401, "unauthorized", "text/plain").await?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let content_length = request.content_length.unwrap_or(0);
|
||||
let max_body_bytes = if request.path.starts_with("/admin/rtr/slurm/") {
|
||||
state
|
||||
.slurm_admin
|
||||
.as_ref()
|
||||
.map(SlurmAdmin::max_body_bytes)
|
||||
.unwrap_or(32 * 1024)
|
||||
} else {
|
||||
32 * 1024
|
||||
};
|
||||
if content_length > max_body_bytes {
|
||||
write_response(&mut stream, 413, "payload too large", "text/plain").await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let body_start = header_end + 4;
|
||||
let available_body = read.saturating_sub(body_start);
|
||||
let mut body = Vec::with_capacity(content_length);
|
||||
body.extend_from_slice(&buffer[body_start..read]);
|
||||
if available_body < content_length {
|
||||
let remaining = content_length - available_body;
|
||||
let mut tail = vec![0u8; remaining];
|
||||
stream.read_exact(&mut tail).await?;
|
||||
body.extend_from_slice(&tail);
|
||||
}
|
||||
body.truncate(content_length);
|
||||
|
||||
route_admin_request(&mut stream, request, body, state, peer_addr).await
|
||||
}
|
||||
|
||||
async fn route_admin_request(
|
||||
stream: &mut TcpStream,
|
||||
request: RequestHeader,
|
||||
body: Vec<u8>,
|
||||
state: AdminState,
|
||||
peer_addr: SocketAddr,
|
||||
) -> Result<()> {
|
||||
if request.method == "POST" && request.path == "/admin/rtr/config" {
|
||||
let patch = match serde_json::from_slice::<RuntimeConfigPatch>(&body) {
|
||||
Ok(patch) => patch,
|
||||
Err(err) => {
|
||||
let message = format!("invalid json: {err}");
|
||||
write_response(stream, 400, &message, "text/plain").await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
match state.runtime_config.apply_patch(patch) {
|
||||
Ok(config) => {
|
||||
info!("RTR admin config updated from {}: {:?}", peer_addr, config);
|
||||
let json = serde_json::to_string_pretty(&AdminConfigResponse {
|
||||
status: "ok",
|
||||
config,
|
||||
})?;
|
||||
write_response(stream, 200, &json, "application/json").await?;
|
||||
}
|
||||
Err(err) => {
|
||||
let message = format!("invalid config: {err}");
|
||||
write_response(stream, 400, &message, "text/plain").await?;
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if request.method == "GET" && request.path == "/admin/rtr/health" {
|
||||
let json = serde_json::to_string_pretty(&AdminHealthResponse {
|
||||
status: "ok",
|
||||
config_api: true,
|
||||
source_reload_api: state.source_reload.is_some(),
|
||||
slurm_api: state.slurm_admin.is_some(),
|
||||
logs_api: true,
|
||||
})?;
|
||||
write_response(stream, 200, &json, "application/json").await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if request.method == "GET" && request.path == "/admin/rtr/logs/tail" {
|
||||
return tail_log_stream(stream, request.query.as_deref(), &state.log_tail).await;
|
||||
}
|
||||
|
||||
if request.method == "GET" && request.path == "/admin/rtr/config" {
|
||||
let json = serde_json::to_string_pretty(&AdminConfigResponse {
|
||||
status: "ok",
|
||||
config: state.runtime_config.current(),
|
||||
})?;
|
||||
write_response(stream, 200, &json, "application/json").await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if request.method == "POST" && request.path == "/admin/rtr/slurm/reload" {
|
||||
let reload = reload_source(&state, "admin_slurm_reload", true).await;
|
||||
return write_json_or_error(stream, reload).await;
|
||||
}
|
||||
|
||||
if request.path == "/admin/rtr/slurm/files" && request.method == "GET" {
|
||||
let Some(slurm_admin) = state.slurm_admin.as_ref() else {
|
||||
write_response(stream, 400, "SLURM admin is disabled", "text/plain").await?;
|
||||
return Ok(());
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&SlurmFileListResponse {
|
||||
status: "ok",
|
||||
files: slurm_admin.list_files()?,
|
||||
})?;
|
||||
write_response(stream, 200, &json, "application/json").await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if request.path == "/admin/rtr/slurm/files" && request.method == "POST" {
|
||||
let Some(slurm_admin) = state.slurm_admin.as_ref() else {
|
||||
write_response(stream, 400, "SLURM admin is disabled", "text/plain").await?;
|
||||
return Ok(());
|
||||
};
|
||||
let request_body = parse_json::<SlurmFileWriteRequest>(stream, &body).await?;
|
||||
let Some(name) = request_body.name.as_deref() else {
|
||||
write_response(stream, 400, "missing SLURM file name", "text/plain").await?;
|
||||
return Ok(());
|
||||
};
|
||||
let reload = request_body.reload.unwrap_or(false);
|
||||
let operation = slurm_admin.put_file(name, &request_body.content, "create_or_update");
|
||||
return apply_slurm_operation(stream, &state, operation, reload).await;
|
||||
}
|
||||
|
||||
let slurm_file_prefix = "/admin/rtr/slurm/files/";
|
||||
if request.path.starts_with(slurm_file_prefix) {
|
||||
let rest = request.path[slurm_file_prefix.len()..].to_string();
|
||||
return route_slurm_file_request(stream, request, body, state, rest).await;
|
||||
}
|
||||
|
||||
write_response(stream, 404, "not found", "text/plain").await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn route_slurm_file_request(
|
||||
stream: &mut TcpStream,
|
||||
request: RequestHeader,
|
||||
body: Vec<u8>,
|
||||
state: AdminState,
|
||||
rest: String,
|
||||
) -> Result<()> {
|
||||
let Some(slurm_admin) = state.slurm_admin.as_ref() else {
|
||||
write_response(stream, 400, "SLURM admin is disabled", "text/plain").await?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if request.method == "GET" {
|
||||
let file = match slurm_admin.read_file(&rest) {
|
||||
Ok(file) => file,
|
||||
Err(err) => {
|
||||
let message = format!("invalid SLURM file request: {err}");
|
||||
write_response(stream, 400, &message, "text/plain").await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&SlurmFileContentResponse { status: "ok", file })?;
|
||||
write_response(stream, 200, &json, "application/json").await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if request.method == "PUT" {
|
||||
let request_body = parse_json::<SlurmFileWriteRequest>(stream, &body).await?;
|
||||
let reload = parse_reload_query(request.query.as_deref(), request_body.reload);
|
||||
let operation = slurm_admin.put_file(&rest, &request_body.content, "create_or_update");
|
||||
return apply_slurm_operation(stream, &state, operation, reload).await;
|
||||
}
|
||||
|
||||
if request.method == "DELETE" {
|
||||
let reload = if body.is_empty() {
|
||||
parse_reload_query(request.query.as_deref(), None)
|
||||
} else {
|
||||
let request_body = parse_json::<SlurmFileActionRequest>(stream, &body).await?;
|
||||
parse_reload_query(request.query.as_deref(), request_body.reload)
|
||||
};
|
||||
let operation = slurm_admin.delete_file(&rest);
|
||||
return apply_slurm_operation(stream, &state, operation, reload).await;
|
||||
}
|
||||
|
||||
if request.method == "POST" {
|
||||
if let Some(name) = rest.strip_suffix("/enable") {
|
||||
let request_body: SlurmFileActionRequest = parse_optional_json(stream, &body).await?;
|
||||
let reload = parse_reload_query(request.query.as_deref(), request_body.reload);
|
||||
let operation = slurm_admin.enable_file(name);
|
||||
return apply_slurm_operation(stream, &state, operation, reload).await;
|
||||
}
|
||||
if let Some(name) = rest.strip_suffix("/disable") {
|
||||
let request_body: SlurmFileActionRequest = parse_optional_json(stream, &body).await?;
|
||||
let reload = parse_reload_query(request.query.as_deref(), request_body.reload);
|
||||
let operation = slurm_admin.disable_file(name);
|
||||
return apply_slurm_operation(stream, &state, operation, reload).await;
|
||||
}
|
||||
}
|
||||
|
||||
write_response(stream, 404, "not found", "text/plain").await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_slurm_operation(
|
||||
stream: &mut TcpStream,
|
||||
state: &AdminState,
|
||||
operation: Result<crate::slurm::admin::AppliedSlurmFileOperation>,
|
||||
reload: bool,
|
||||
) -> Result<()> {
|
||||
let operation = match operation {
|
||||
Ok(operation) => operation,
|
||||
Err(err) => {
|
||||
let message = format!("invalid SLURM operation: {err}");
|
||||
write_response(stream, 400, &message, "text/plain").await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
if !reload {
|
||||
let json = serde_json::to_string_pretty(&SlurmOperationResponse {
|
||||
status: "ok",
|
||||
operation: operation.result,
|
||||
reload: None,
|
||||
rollback: None,
|
||||
})?;
|
||||
write_response(stream, 200, &json, "application/json").await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match reload_source(state, "admin_slurm_changed", true).await {
|
||||
Ok(result) => {
|
||||
let json = serde_json::to_string_pretty(&SlurmOperationResponse {
|
||||
status: "ok",
|
||||
operation: operation.result,
|
||||
reload: Some(result),
|
||||
rollback: None,
|
||||
})?;
|
||||
write_response(stream, 200, &json, "application/json").await?;
|
||||
}
|
||||
Err(err) => {
|
||||
let rollback = match operation.rollback() {
|
||||
Ok(()) => match reload_source(state, "admin_slurm_rollback", true).await {
|
||||
Ok(result) => Some(RollbackReport {
|
||||
status: "ok".to_string(),
|
||||
reload: Some(result),
|
||||
error: None,
|
||||
}),
|
||||
Err(reload_err) => Some(RollbackReport {
|
||||
status: "reload_failed".to_string(),
|
||||
reload: None,
|
||||
error: Some(reload_err.to_string()),
|
||||
}),
|
||||
},
|
||||
Err(rollback_err) => Some(RollbackReport {
|
||||
status: "failed".to_string(),
|
||||
reload: None,
|
||||
error: Some(rollback_err.to_string()),
|
||||
}),
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&SlurmOperationErrorResponse {
|
||||
status: "reload_failed",
|
||||
error: err.to_string(),
|
||||
rollback,
|
||||
})?;
|
||||
write_response(stream, 400, &json, "application/json").await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reload_source(
|
||||
state: &AdminState,
|
||||
phase: &'static str,
|
||||
force: bool,
|
||||
) -> Result<SourceReloadResult> {
|
||||
let Some(source_reload) = state.source_reload.as_ref() else {
|
||||
return Err(anyhow!("source reload is not available"));
|
||||
};
|
||||
source_reload.reload(phase, force).await
|
||||
}
|
||||
|
||||
async fn parse_json<T: for<'de> Deserialize<'de>>(
|
||||
stream: &mut TcpStream,
|
||||
body: &[u8],
|
||||
) -> Result<T> {
|
||||
match serde_json::from_slice::<T>(body) {
|
||||
Ok(value) => Ok(value),
|
||||
Err(err) => {
|
||||
let message = format!("invalid json: {err}");
|
||||
write_response(stream, 400, &message, "text/plain").await?;
|
||||
Err(anyhow!(message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn parse_optional_json<T: for<'de> Deserialize<'de> + Default>(
|
||||
stream: &mut TcpStream,
|
||||
body: &[u8],
|
||||
) -> Result<T> {
|
||||
if body.is_empty() {
|
||||
return Ok(T::default());
|
||||
}
|
||||
parse_json(stream, body).await
|
||||
}
|
||||
|
||||
async fn write_json_or_error<T: Serialize>(
|
||||
stream: &mut TcpStream,
|
||||
result: Result<T>,
|
||||
) -> Result<()> {
|
||||
match result {
|
||||
Ok(value) => {
|
||||
let json = serde_json::to_string_pretty(&value)?;
|
||||
write_response(stream, 200, &json, "application/json").await?;
|
||||
}
|
||||
Err(err) => {
|
||||
let message = format!("reload failed: {err}");
|
||||
write_response(stream, 400, &message, "text/plain").await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum LogStream {
|
||||
Stdout,
|
||||
Stderr,
|
||||
}
|
||||
|
||||
impl LogStream {
|
||||
fn parse(value: Option<&str>) -> Result<Self> {
|
||||
match value.unwrap_or("stdout") {
|
||||
"stdout" => Ok(Self::Stdout),
|
||||
"stderr" => Ok(Self::Stderr),
|
||||
other => Err(anyhow!(
|
||||
"invalid stream '{}': expected 'stdout' or 'stderr'",
|
||||
other
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Stdout => "stdout",
|
||||
Self::Stderr => "stderr",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LogTailRequest {
|
||||
stream: LogStream,
|
||||
lines: usize,
|
||||
follow: bool,
|
||||
}
|
||||
|
||||
impl LogTailRequest {
|
||||
fn parse(query: Option<&str>) -> Result<Self> {
|
||||
let stream = LogStream::parse(query_param(query, "stream"))?;
|
||||
let lines = query_param(query, "lines")
|
||||
.map(|value| {
|
||||
value
|
||||
.parse::<usize>()
|
||||
.map_err(|err| anyhow!("invalid lines '{}': {}", value, err))
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(200)
|
||||
.clamp(1, 5000);
|
||||
let follow = query_param(query, "follow")
|
||||
.map(|value| parse_bool_query(value, "follow"))
|
||||
.transpose()?
|
||||
.unwrap_or(true);
|
||||
Ok(Self {
|
||||
stream,
|
||||
lines,
|
||||
follow,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn tail_log_stream(
|
||||
stream: &mut TcpStream,
|
||||
query: Option<&str>,
|
||||
config: &LogTailConfig,
|
||||
) -> Result<()> {
|
||||
let request = match LogTailRequest::parse(query) {
|
||||
Ok(request) => request,
|
||||
Err(err) => {
|
||||
let message = format!("invalid log tail request: {err}");
|
||||
write_response(stream, 400, &message, "text/plain").await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let path = config.path_for(request.stream);
|
||||
if !Path::new(&path).is_file() {
|
||||
let message = format!("log file not found: {}", path.display());
|
||||
write_response(stream, 404, &message, "text/plain").await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
write_chunked_headers(stream, "text/plain; charset=utf-8").await?;
|
||||
let (tail, mut offset) = read_tail(&path, request.lines).await?;
|
||||
if !tail.is_empty() {
|
||||
write_chunk(stream, &tail).await?;
|
||||
}
|
||||
if !request.follow {
|
||||
write_final_chunk(stream).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
loop {
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
let metadata = match tokio::fs::metadata(&path).await {
|
||||
Ok(metadata) => metadata,
|
||||
Err(err) => {
|
||||
let message = format!("\nlog file unavailable: {err}\n");
|
||||
let _ = write_chunk(stream, message.as_bytes()).await;
|
||||
let _ = write_final_chunk(stream).await;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let len = metadata.len();
|
||||
if len < offset {
|
||||
offset = 0;
|
||||
write_chunk(stream, b"\nlog file truncated; restarting from beginning\n").await?;
|
||||
}
|
||||
if len == offset {
|
||||
continue;
|
||||
}
|
||||
let mut file = tokio::fs::File::open(&path).await?;
|
||||
file.seek(std::io::SeekFrom::Start(offset)).await?;
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf).await?;
|
||||
offset = len;
|
||||
if !buf.is_empty() {
|
||||
write_chunk(stream, &buf).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_tail(path: &Path, lines: usize) -> Result<(Vec<u8>, u64)> {
|
||||
const MAX_INITIAL_TAIL_BYTES: u64 = 1024 * 1024;
|
||||
|
||||
let metadata = tokio::fs::metadata(path).await?;
|
||||
let len = metadata.len();
|
||||
let start = len.saturating_sub(MAX_INITIAL_TAIL_BYTES);
|
||||
let mut file = tokio::fs::File::open(path).await?;
|
||||
file.seek(std::io::SeekFrom::Start(start)).await?;
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf).await?;
|
||||
Ok((tail_log_lines(buf, lines), len))
|
||||
}
|
||||
|
||||
pub fn tail_log_lines(buf: Vec<u8>, lines: usize) -> Vec<u8> {
|
||||
if lines == 0 || buf.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut seen = 0usize;
|
||||
for (idx, byte) in buf.iter().enumerate().rev() {
|
||||
if *byte == b'\n' {
|
||||
seen += 1;
|
||||
if seen > lines {
|
||||
return buf[idx + 1..].to_vec();
|
||||
}
|
||||
}
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
fn query_param<'a>(query: Option<&'a str>, key: &str) -> Option<&'a str> {
|
||||
query?.split('&').find_map(|part| {
|
||||
let (name, value) = part.split_once('=').unwrap_or((part, ""));
|
||||
(name == key).then_some(value)
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_bool_query(value: &str, name: &str) -> Result<bool> {
|
||||
match value {
|
||||
"true" | "1" | "yes" | "on" => Ok(true),
|
||||
"false" | "0" | "no" | "off" => Ok(false),
|
||||
_ => Err(anyhow!("invalid {} '{}': expected true/false", name, value)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_chunked_headers(stream: &mut TcpStream, content_type: &str) -> Result<()> {
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: {content_type}\r\ntransfer-encoding: chunked\r\ncache-control: no-store\r\nconnection: close\r\n\r\n"
|
||||
);
|
||||
stream.write_all(response.as_bytes()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_chunk(stream: &mut TcpStream, chunk: &[u8]) -> Result<()> {
|
||||
if chunk.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let header = format!("{:x}\r\n", chunk.len());
|
||||
stream.write_all(header.as_bytes()).await?;
|
||||
stream.write_all(chunk).await?;
|
||||
stream.write_all(b"\r\n").await?;
|
||||
stream.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_final_chunk(stream: &mut TcpStream) -> Result<()> {
|
||||
stream.write_all(b"0\r\n\r\n").await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AdminHealthResponse {
|
||||
status: &'static str,
|
||||
config_api: bool,
|
||||
source_reload_api: bool,
|
||||
slurm_api: bool,
|
||||
logs_api: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AdminConfigResponse {
|
||||
status: &'static str,
|
||||
config: RuntimeConfig,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SlurmFileListResponse {
|
||||
status: &'static str,
|
||||
files: Vec<crate::slurm::admin::SlurmFileListEntry>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SlurmFileContentResponse {
|
||||
status: &'static str,
|
||||
file: crate::slurm::admin::SlurmFileContent,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SlurmOperationResponse {
|
||||
status: &'static str,
|
||||
operation: SlurmFileOperationResult,
|
||||
reload: Option<SourceReloadResult>,
|
||||
rollback: Option<RollbackReport>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SlurmOperationErrorResponse {
|
||||
status: &'static str,
|
||||
error: String,
|
||||
rollback: Option<RollbackReport>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RollbackReport {
|
||||
status: String,
|
||||
reload: Option<SourceReloadResult>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
struct RequestHeader {
|
||||
method: String,
|
||||
path: String,
|
||||
query: Option<String>,
|
||||
content_length: Option<usize>,
|
||||
authorization: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_request_header(header: &str) -> Result<RequestHeader> {
|
||||
let mut lines = header.lines();
|
||||
let request_line = lines
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("missing HTTP request line"))?;
|
||||
let mut parts = request_line.split_whitespace();
|
||||
let method = parts
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("missing HTTP method"))?
|
||||
.to_string();
|
||||
let target = parts
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("missing HTTP path"))?
|
||||
.to_string();
|
||||
let (path, query) = match target.split_once('?') {
|
||||
Some((path, query)) => (path.to_string(), Some(query.to_string())),
|
||||
None => (target, None),
|
||||
};
|
||||
|
||||
let mut content_length = None;
|
||||
let mut authorization = None;
|
||||
for line in lines {
|
||||
let Some((name, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let name = name.trim().to_ascii_lowercase();
|
||||
let value = value.trim();
|
||||
match name.as_str() {
|
||||
"content-length" => {
|
||||
content_length = Some(
|
||||
value
|
||||
.parse::<usize>()
|
||||
.map_err(|err| anyhow!("invalid content-length '{}': {}", value, err))?,
|
||||
);
|
||||
}
|
||||
"authorization" => authorization = Some(value.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(RequestHeader {
|
||||
method,
|
||||
path,
|
||||
query,
|
||||
content_length,
|
||||
authorization,
|
||||
})
|
||||
}
|
||||
|
||||
fn find_header_end(buffer: &[u8]) -> Option<usize> {
|
||||
buffer.windows(4).position(|window| window == b"\r\n\r\n")
|
||||
}
|
||||
|
||||
async fn write_response(
|
||||
stream: &mut TcpStream,
|
||||
status: u16,
|
||||
body: &str,
|
||||
content_type: &str,
|
||||
) -> Result<()> {
|
||||
let reason = match status {
|
||||
200 => "OK",
|
||||
400 => "Bad Request",
|
||||
401 => "Unauthorized",
|
||||
404 => "Not Found",
|
||||
413 => "Payload Too Large",
|
||||
_ => "Internal Server Error",
|
||||
};
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status} {reason}\r\ncontent-type: {content_type}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
stream.write_all(response.as_bytes()).await?;
|
||||
Ok(())
|
||||
}
|
||||
85
src/rtr/cache/core.rs
vendored
85
src/rtr/cache/core.rs
vendored
@@ -277,12 +277,20 @@ impl RtrCache {
|
||||
max_delta: u8,
|
||||
prune_delta_by_snapshot_size: bool,
|
||||
delta: Arc<Delta>,
|
||||
) {
|
||||
state.deltas.push_back(delta);
|
||||
Self::prune_delta_window(state, max_delta, prune_delta_by_snapshot_size);
|
||||
}
|
||||
|
||||
fn prune_delta_window(
|
||||
state: &mut VersionState,
|
||||
max_delta: u8,
|
||||
prune_delta_by_snapshot_size: bool,
|
||||
) {
|
||||
let max_keep = usize::from(max_delta.max(1));
|
||||
while state.deltas.len() >= max_keep {
|
||||
while state.deltas.len() > max_keep {
|
||||
state.deltas.pop_front();
|
||||
}
|
||||
state.deltas.push_back(delta);
|
||||
let mut dropped_serials = Vec::new();
|
||||
if prune_delta_by_snapshot_size {
|
||||
let snapshot_wire_size = estimate_snapshot_payload_wire_size(state.snapshot.as_ref());
|
||||
@@ -426,6 +434,23 @@ impl RtrCache {
|
||||
}
|
||||
}
|
||||
|
||||
fn applied_update_with_existing_windows(&self) -> AppliedUpdate {
|
||||
let snapshots = std::array::from_fn(|idx| self.versions[idx].snapshot.clone());
|
||||
let serials = std::array::from_fn(|idx| self.versions[idx].serial);
|
||||
let session_ids = std::array::from_fn(|idx| self.versions[idx].session_id);
|
||||
let delta_windows = std::array::from_fn(|idx| Self::delta_window(&self.versions[idx]));
|
||||
let clear_delta_windows = std::array::from_fn(|idx| self.versions[idx].deltas.is_empty());
|
||||
AppliedUpdate {
|
||||
availability: self.availability,
|
||||
snapshots,
|
||||
serials,
|
||||
session_ids,
|
||||
deltas: [None, None, None],
|
||||
delta_windows,
|
||||
clear_delta_windows,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_data_available(&self) -> bool {
|
||||
self.availability == CacheAvailability::Ready
|
||||
}
|
||||
@@ -469,6 +494,62 @@ impl RtrCache {
|
||||
self.timing
|
||||
}
|
||||
|
||||
pub fn max_delta(&self) -> u8 {
|
||||
self.max_delta
|
||||
}
|
||||
|
||||
pub fn prune_delta_by_snapshot_size(&self) -> bool {
|
||||
self.prune_delta_by_snapshot_size
|
||||
}
|
||||
|
||||
pub(super) fn apply_runtime_config(
|
||||
&mut self,
|
||||
max_delta: u8,
|
||||
prune_delta_by_snapshot_size: bool,
|
||||
timing: Timing,
|
||||
) -> Option<AppliedUpdate> {
|
||||
let old_delta_lengths = self.delta_lengths();
|
||||
let old_max_delta = self.max_delta;
|
||||
let old_prune_delta_by_snapshot_size = self.prune_delta_by_snapshot_size;
|
||||
let old_timing = self.timing;
|
||||
|
||||
self.max_delta = max_delta.max(1);
|
||||
self.prune_delta_by_snapshot_size = prune_delta_by_snapshot_size;
|
||||
self.timing = timing;
|
||||
|
||||
for state in &mut self.versions {
|
||||
Self::prune_delta_window(state, self.max_delta, self.prune_delta_by_snapshot_size);
|
||||
}
|
||||
|
||||
let config_changed = old_max_delta != self.max_delta
|
||||
|| old_prune_delta_by_snapshot_size != self.prune_delta_by_snapshot_size
|
||||
|| old_timing.refresh != self.timing.refresh
|
||||
|| old_timing.retry != self.timing.retry
|
||||
|| old_timing.expire != self.timing.expire;
|
||||
let delta_changed = old_delta_lengths != self.delta_lengths();
|
||||
|
||||
if config_changed {
|
||||
info!(
|
||||
"RTR cache runtime config updated: old_max_delta={}, new_max_delta={}, old_prune_delta_by_snapshot_size={}, new_prune_delta_by_snapshot_size={}, old_delta_lengths={:?}, new_delta_lengths={:?}, timing=({}, {}, {})",
|
||||
old_max_delta,
|
||||
self.max_delta,
|
||||
old_prune_delta_by_snapshot_size,
|
||||
self.prune_delta_by_snapshot_size,
|
||||
old_delta_lengths,
|
||||
self.delta_lengths(),
|
||||
self.timing.refresh,
|
||||
self.timing.retry,
|
||||
self.timing.expire
|
||||
);
|
||||
}
|
||||
|
||||
if delta_changed {
|
||||
Some(self.applied_update_with_existing_windows())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn last_update_begin(&self) -> DualTime {
|
||||
self.last_update_begin.clone()
|
||||
}
|
||||
|
||||
10
src/rtr/cache/model.rs
vendored
10
src/rtr/cache/model.rs
vendored
@@ -82,11 +82,7 @@ pub struct Snapshot {
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
pub fn new(
|
||||
origins: Vec<RouteOrigin>,
|
||||
router_keys: Vec<RouterKey>,
|
||||
aspas: Vec<Aspa>,
|
||||
) -> Self {
|
||||
pub fn new(origins: Vec<RouteOrigin>, router_keys: Vec<RouterKey>, aspas: Vec<Aspa>) -> Self {
|
||||
Self::from_shared_parts(
|
||||
Arc::new(sorted_dedup(origins)),
|
||||
Arc::new(sorted_dedup(router_keys)),
|
||||
@@ -443,9 +439,7 @@ where
|
||||
|
||||
let mut normalized = by_customer
|
||||
.into_iter()
|
||||
.map(|(customer_asn, providers)| {
|
||||
Aspa::new(customer_asn.into(), providers)
|
||||
})
|
||||
.map(|(customer_asn, providers)| Aspa::new(customer_asn.into(), providers))
|
||||
.collect::<Vec<_>>();
|
||||
normalized.sort();
|
||||
normalized
|
||||
|
||||
14
src/rtr/cache/store.rs
vendored
14
src/rtr/cache/store.rs
vendored
@@ -106,6 +106,20 @@ impl RtrCache {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_runtime_config(
|
||||
&mut self,
|
||||
max_delta: u8,
|
||||
prune_delta_by_snapshot_size: bool,
|
||||
timing: Timing,
|
||||
store: &RtrStore,
|
||||
) {
|
||||
if let Some(update) =
|
||||
self.apply_runtime_config(max_delta, prune_delta_by_snapshot_size, timing)
|
||||
{
|
||||
spawn_store_sync(store, update);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_restore_from_store(
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::time::Duration;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use chrono_tz::Tz;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::rtr::payload::Timing;
|
||||
@@ -40,10 +41,50 @@ pub struct AppConfig {
|
||||
pub report_history_limit: usize,
|
||||
pub timezone: Tz,
|
||||
pub timing: Timing,
|
||||
pub admin_addr: Option<SocketAddr>,
|
||||
pub admin_token: Option<String>,
|
||||
|
||||
pub service_config: RtrServiceConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RuntimeConfig {
|
||||
pub max_delta: u8,
|
||||
pub prune_delta_by_snapshot_size: bool,
|
||||
pub source_refresh_interval_seconds: u64,
|
||||
pub runtime_report_interval_seconds: u64,
|
||||
pub report_history_limit: usize,
|
||||
pub strict_ccr_validation: bool,
|
||||
pub timezone: String,
|
||||
pub timing: RuntimeTimingConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RuntimeTimingConfig {
|
||||
pub refresh: u32,
|
||||
pub retry: u32,
|
||||
pub expire: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct RuntimeConfigPatch {
|
||||
pub max_delta: Option<u8>,
|
||||
pub prune_delta_by_snapshot_size: Option<bool>,
|
||||
pub source_refresh_interval_seconds: Option<u64>,
|
||||
pub runtime_report_interval_seconds: Option<u64>,
|
||||
pub report_history_limit: Option<usize>,
|
||||
pub strict_ccr_validation: Option<bool>,
|
||||
pub timezone: Option<String>,
|
||||
pub timing: Option<RuntimeTimingConfigPatch>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Deserialize)]
|
||||
pub struct RuntimeTimingConfigPatch {
|
||||
pub refresh: Option<u32>,
|
||||
pub retry: Option<u32>,
|
||||
pub expire: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -75,6 +116,8 @@ impl Default for AppConfig {
|
||||
report_history_limit: 10,
|
||||
timezone: default_timezone(),
|
||||
timing: Timing::default(),
|
||||
admin_addr: None,
|
||||
admin_token: None,
|
||||
|
||||
service_config: RtrServiceConfig {
|
||||
max_connections: 512,
|
||||
@@ -201,6 +244,19 @@ impl AppConfig {
|
||||
if let Some(value) = env_var("RPKI_RTR_TIMEZONE")? {
|
||||
config.timezone = parse_timezone(&value, "RPKI_RTR_TIMEZONE")?;
|
||||
}
|
||||
if let Some(value) = env_var("RPKI_RTR_ADMIN_ADDR")? {
|
||||
let value = value.trim();
|
||||
if !value.is_empty() {
|
||||
config.admin_addr =
|
||||
Some(value.parse().map_err(|err| {
|
||||
anyhow!("invalid RPKI_RTR_ADMIN_ADDR '{}': {}", value, err)
|
||||
})?);
|
||||
}
|
||||
}
|
||||
if let Some(value) = env_var("RPKI_RTR_ADMIN_TOKEN")? {
|
||||
let value = value.trim().to_string();
|
||||
config.admin_token = if value.is_empty() { None } else { Some(value) };
|
||||
}
|
||||
|
||||
let source_refresh_interval_new = env_var("RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS")?;
|
||||
let source_refresh_interval_legacy = env_var("RPKI_RTR_REFRESH_INTERVAL_SECS")?;
|
||||
@@ -307,6 +363,108 @@ impl AppConfig {
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn runtime_config(&self) -> RuntimeConfig {
|
||||
RuntimeConfig {
|
||||
max_delta: self.max_delta,
|
||||
prune_delta_by_snapshot_size: self.prune_delta_by_snapshot_size,
|
||||
source_refresh_interval_seconds: self.source_refresh_interval.as_secs(),
|
||||
runtime_report_interval_seconds: self.runtime_report_interval.as_secs(),
|
||||
report_history_limit: self.report_history_limit,
|
||||
strict_ccr_validation: self.strict_ccr_validation,
|
||||
timezone: format_timezone(self.timezone),
|
||||
timing: RuntimeTimingConfig::from(self.timing),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeConfig {
|
||||
pub fn apply_patch(&self, patch: RuntimeConfigPatch) -> Result<Self> {
|
||||
let mut next = self.clone();
|
||||
if let Some(max_delta) = patch.max_delta {
|
||||
if max_delta == 0 {
|
||||
return Err(anyhow!("invalid max_delta '{}': must be >= 1", max_delta));
|
||||
}
|
||||
next.max_delta = max_delta;
|
||||
}
|
||||
if let Some(value) = patch.prune_delta_by_snapshot_size {
|
||||
next.prune_delta_by_snapshot_size = value;
|
||||
}
|
||||
if let Some(value) = patch.source_refresh_interval_seconds {
|
||||
if value == 0 {
|
||||
return Err(anyhow!(
|
||||
"invalid source_refresh_interval_seconds '{}': must be >= 1",
|
||||
value
|
||||
));
|
||||
}
|
||||
next.source_refresh_interval_seconds = value;
|
||||
}
|
||||
if let Some(value) = patch.runtime_report_interval_seconds {
|
||||
if value == 0 {
|
||||
return Err(anyhow!(
|
||||
"invalid runtime_report_interval_seconds '{}': must be >= 1",
|
||||
value
|
||||
));
|
||||
}
|
||||
next.runtime_report_interval_seconds = value;
|
||||
}
|
||||
if let Some(value) = patch.report_history_limit {
|
||||
if value == 0 {
|
||||
return Err(anyhow!(
|
||||
"invalid report_history_limit '{}': must be >= 1",
|
||||
value
|
||||
));
|
||||
}
|
||||
next.report_history_limit = value;
|
||||
}
|
||||
if let Some(value) = patch.strict_ccr_validation {
|
||||
next.strict_ccr_validation = value;
|
||||
}
|
||||
if let Some(value) = patch.timezone {
|
||||
next.timezone = format_timezone(parse_timezone(&value, "timezone")?);
|
||||
}
|
||||
if let Some(timing) = patch.timing {
|
||||
let mut next_timing = Timing::from(next.timing);
|
||||
if let Some(value) = timing.refresh {
|
||||
next_timing.refresh = value;
|
||||
}
|
||||
if let Some(value) = timing.retry {
|
||||
next_timing.retry = value;
|
||||
}
|
||||
if let Some(value) = timing.expire {
|
||||
next_timing.expire = value;
|
||||
}
|
||||
next_timing
|
||||
.validate()
|
||||
.map_err(|err| anyhow!("invalid timing: {}", err))?;
|
||||
next.timing = RuntimeTimingConfig::from(next_timing);
|
||||
}
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
pub fn timezone(&self) -> Result<Tz> {
|
||||
parse_timezone(&self.timezone, "timezone")
|
||||
}
|
||||
|
||||
pub fn timing(&self) -> Timing {
|
||||
Timing::from(self.timing)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Timing> for RuntimeTimingConfig {
|
||||
fn from(timing: Timing) -> Self {
|
||||
Self {
|
||||
refresh: timing.refresh,
|
||||
retry: timing.retry,
|
||||
expire: timing.expire,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RuntimeTimingConfig> for Timing {
|
||||
fn from(timing: RuntimeTimingConfig) -> Self {
|
||||
Self::new(timing.refresh, timing.retry, timing.expire)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn log_startup_config(config: &AppConfig) {
|
||||
@@ -356,6 +514,14 @@ pub fn log_startup_config(config: &AppConfig) {
|
||||
info!("rtr_timing_refresh_secs={}", config.timing.refresh);
|
||||
info!("rtr_timing_retry_secs={}", config.timing.retry);
|
||||
info!("rtr_timing_expire_secs={}", config.timing.expire);
|
||||
info!(
|
||||
"admin_addr={}",
|
||||
config
|
||||
.admin_addr
|
||||
.map(|addr| addr.to_string())
|
||||
.unwrap_or_else(|| "disabled".to_string())
|
||||
);
|
||||
info!("admin_token_enabled={}", config.admin_token.is_some());
|
||||
info!("max_connections={}", config.service_config.max_connections);
|
||||
info!(
|
||||
"max_concurrent_handshakes={}",
|
||||
@@ -441,7 +607,7 @@ fn parse_positive_u32(value: &str, name: &str) -> Result<u32> {
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
fn parse_timezone(value: &str, name: &str) -> Result<Tz> {
|
||||
pub fn parse_timezone(value: &str, name: &str) -> Result<Tz> {
|
||||
let value = value.trim();
|
||||
let normalized = match value.to_ascii_lowercase().as_str() {
|
||||
"shanghai" | "beijing" | "peking" => "Asia/Shanghai",
|
||||
@@ -458,37 +624,3 @@ fn parse_timezone(value: &str, name: &str) -> Result<Tz> {
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_timezone_accepts_iana_names_and_common_aliases() {
|
||||
assert_eq!(
|
||||
parse_timezone("Asia/Shanghai", "TEST").unwrap(),
|
||||
chrono_tz::Asia::Shanghai
|
||||
);
|
||||
assert_eq!(
|
||||
parse_timezone("shanghai", "TEST").unwrap(),
|
||||
chrono_tz::Asia::Shanghai
|
||||
);
|
||||
assert_eq!(
|
||||
parse_timezone("Europe/London", "TEST").unwrap(),
|
||||
chrono_tz::Europe::London
|
||||
);
|
||||
assert_eq!(
|
||||
parse_timezone("America/New_York", "TEST").unwrap(),
|
||||
chrono_tz::America::New_York
|
||||
);
|
||||
assert_eq!(parse_timezone("UTC", "TEST").unwrap(), chrono_tz::UTC);
|
||||
assert_eq!(parse_timezone("Z", "TEST").unwrap(), chrono_tz::UTC);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_timezone_rejects_offset_and_invalid_values() {
|
||||
assert!(parse_timezone("+08:00", "TEST").is_err());
|
||||
assert!(parse_timezone("+0800", "TEST").is_err());
|
||||
assert!(parse_timezone("Mars/Base", "TEST").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod admin;
|
||||
pub mod cache;
|
||||
pub mod config;
|
||||
pub mod error_type;
|
||||
|
||||
@@ -10,7 +10,7 @@ use serde::Serialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::rtr::cache::{CacheAvailability, CacheMemoryStats, SharedRtrCache, VersionReportStats};
|
||||
use crate::rtr::config::format_timezone;
|
||||
use crate::rtr::config::{RuntimeConfig, format_timezone};
|
||||
use crate::rtr::server::{RtrNotifier, RtrServiceStats, RtrTransportConnectionCounts};
|
||||
use crate::source::pipeline::{
|
||||
DataQualityReport, FileFingerprint, SourceFingerprint, SourceLoadReport,
|
||||
@@ -73,8 +73,7 @@ impl ReportConfiguration {
|
||||
pub struct ReportContext {
|
||||
started_at: DateTime<Utc>,
|
||||
started_instant: Instant,
|
||||
timezone: Tz,
|
||||
configuration: ReportConfiguration,
|
||||
configuration: Arc<RwLock<ReportConfiguration>>,
|
||||
runtime: Arc<RwLock<RuntimeReportState>>,
|
||||
}
|
||||
|
||||
@@ -148,16 +147,38 @@ impl Default for RefreshReport {
|
||||
|
||||
impl ReportContext {
|
||||
pub fn new(configuration: ReportConfiguration) -> Self {
|
||||
let timezone = configuration.timezone();
|
||||
Self {
|
||||
started_at: Utc::now(),
|
||||
started_instant: Instant::now(),
|
||||
timezone,
|
||||
configuration,
|
||||
configuration: Arc::new(RwLock::new(configuration)),
|
||||
runtime: Arc::new(RwLock::new(RuntimeReportState::default())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_runtime_config(&self, config: &RuntimeConfig) {
|
||||
let timezone = config
|
||||
.timezone()
|
||||
.expect("runtime config timezone should be validated before report update");
|
||||
let mut configuration = self
|
||||
.configuration
|
||||
.write()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
*configuration = ReportConfiguration::new(
|
||||
config.source_refresh_interval_seconds,
|
||||
config.runtime_report_interval_seconds,
|
||||
config.report_history_limit,
|
||||
config.max_delta,
|
||||
config.prune_delta_by_snapshot_size,
|
||||
config.strict_ccr_validation,
|
||||
timezone,
|
||||
(
|
||||
config.timing.refresh,
|
||||
config.timing.retry,
|
||||
config.timing.expire,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn record_refresh_success(
|
||||
&self,
|
||||
attempted_at: DateTime<Utc>,
|
||||
@@ -365,6 +386,12 @@ impl ReportContext {
|
||||
service_stats: &RtrServiceStats,
|
||||
) -> ReportParts {
|
||||
let cache = shared_cache.load_full();
|
||||
let configuration = self
|
||||
.configuration
|
||||
.read()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.clone();
|
||||
let timezone = configuration.timezone();
|
||||
let runtime = self
|
||||
.runtime
|
||||
.read()
|
||||
@@ -377,14 +404,14 @@ impl ReportContext {
|
||||
let active_connections = service_stats.active_connections();
|
||||
let connections_by_transport = service_stats.transport_connections();
|
||||
let max_connections = service_stats.max_connections();
|
||||
let generated_at = self.report_now();
|
||||
let generated_at = self.report_now(timezone);
|
||||
let metadata = ReportMetadata {
|
||||
schema_version: 2,
|
||||
generated_at,
|
||||
phase: phase.to_string(),
|
||||
};
|
||||
let service = ServiceReport {
|
||||
started_at: self.to_report_time(self.started_at),
|
||||
started_at: self.to_report_time(self.started_at, timezone),
|
||||
uptime_seconds: self.started_instant.elapsed().as_secs(),
|
||||
active_connections,
|
||||
connections_by_transport: TransportConnectionReport::from(connections_by_transport),
|
||||
@@ -397,13 +424,13 @@ impl ReportContext {
|
||||
};
|
||||
let source = runtime
|
||||
.source
|
||||
.map(|source| SourceLoadReportView::from_report(source, self.timezone));
|
||||
let refresh = RefreshReportView::from_report(runtime.refresh, self.timezone);
|
||||
.map(|source| SourceLoadReportView::from_report(source, timezone));
|
||||
let refresh = RefreshReportView::from_report(runtime.refresh, timezone);
|
||||
let cache = CacheReport {
|
||||
availability,
|
||||
created_at: self.to_report_time(cache.created_at().utc()),
|
||||
last_update_begin: self.to_report_time(cache.last_update_begin().utc()),
|
||||
last_update_end: self.to_report_time(cache.last_update_end().utc()),
|
||||
created_at: self.to_report_time(cache.created_at().utc(), timezone),
|
||||
last_update_begin: self.to_report_time(cache.last_update_begin().utc(), timezone),
|
||||
last_update_end: self.to_report_time(cache.last_update_end().utc(), timezone),
|
||||
memory: cache.memory_stats(),
|
||||
versions: cache.version_report_stats(),
|
||||
};
|
||||
@@ -414,7 +441,7 @@ impl ReportContext {
|
||||
phase: metadata.phase.clone(),
|
||||
source,
|
||||
source_fingerprint: runtime.source_fingerprint.map(|fingerprint| {
|
||||
SourceFingerprintReport::from_fingerprint(fingerprint, self.timezone)
|
||||
SourceFingerprintReport::from_fingerprint(fingerprint, timezone)
|
||||
}),
|
||||
refresh,
|
||||
data_quality: runtime.data_quality,
|
||||
@@ -440,7 +467,7 @@ impl ReportContext {
|
||||
phase: metadata.phase,
|
||||
service,
|
||||
process,
|
||||
configuration: self.configuration.clone(),
|
||||
configuration,
|
||||
};
|
||||
let suffix = report_file_suffix(generated_at);
|
||||
|
||||
@@ -462,7 +489,7 @@ impl ReportContext {
|
||||
report_dir,
|
||||
"rtr-source",
|
||||
suffix,
|
||||
self.configuration.report_history_limit,
|
||||
self.report_history_limit(),
|
||||
report,
|
||||
)
|
||||
}
|
||||
@@ -477,7 +504,7 @@ impl ReportContext {
|
||||
report_dir,
|
||||
"rtr-clients",
|
||||
suffix,
|
||||
self.configuration.report_history_limit,
|
||||
self.report_history_limit(),
|
||||
report,
|
||||
)
|
||||
}
|
||||
@@ -492,17 +519,24 @@ impl ReportContext {
|
||||
report_dir,
|
||||
"rtr-runtime",
|
||||
suffix,
|
||||
self.configuration.report_history_limit,
|
||||
self.report_history_limit(),
|
||||
report,
|
||||
)
|
||||
}
|
||||
|
||||
fn report_now(&self) -> DateTime<Tz> {
|
||||
self.to_report_time(Utc::now())
|
||||
fn report_history_limit(&self) -> usize {
|
||||
self.configuration
|
||||
.read()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.report_history_limit
|
||||
}
|
||||
|
||||
fn to_report_time(&self, time: DateTime<Utc>) -> DateTime<Tz> {
|
||||
time.with_timezone(&self.timezone)
|
||||
fn report_now(&self, timezone: Tz) -> DateTime<Tz> {
|
||||
self.to_report_time(Utc::now(), timezone)
|
||||
}
|
||||
|
||||
fn to_report_time(&self, time: DateTime<Utc>, timezone: Tz) -> DateTime<Tz> {
|
||||
time.with_timezone(&timezone)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -745,308 +779,3 @@ fn prune_rolling_reports(report_dir: &Path, prefix: &str, keep: usize) -> Result
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::rtr::cache::RtrCache;
|
||||
use crate::rtr::server::RtrService;
|
||||
use crate::source::pipeline::{
|
||||
DataQualityReport, PayloadTypeCounts, SlurmRuleCounts, SourceLoadReport,
|
||||
};
|
||||
use arc_swap::ArcSwap;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn test_configuration() -> ReportConfiguration {
|
||||
ReportConfiguration::new(
|
||||
300,
|
||||
300,
|
||||
10,
|
||||
100,
|
||||
false,
|
||||
false,
|
||||
chrono_tz::Asia::Shanghai,
|
||||
(3600, 600, 7200),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_report_creates_parseable_json() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let report_dir = temp.path().join("report");
|
||||
let shared_cache = Arc::new(ArcSwap::from_pointee(RtrCache::default()));
|
||||
let service = RtrService::new(shared_cache.clone());
|
||||
let notifier = service.notifier();
|
||||
let context = ReportContext::new(test_configuration());
|
||||
|
||||
context
|
||||
.write(
|
||||
&report_dir,
|
||||
"test",
|
||||
&shared_cache,
|
||||
¬ifier,
|
||||
&service.stats(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let source_report = read_single_report(&report_dir, "rtr-source");
|
||||
assert_eq!(source_report["schema_version"], 2);
|
||||
assert_eq!(source_report["phase"], "test");
|
||||
assert_report_time_offset(&source_report["generated_at"]);
|
||||
assert_report_time_offset(&source_report["cache"]["created_at"]);
|
||||
assert_eq!(source_report["cache"]["availability"], "ready");
|
||||
assert_eq!(source_report["refresh"]["status"], "not_attempted");
|
||||
assert!(source_report["source"].is_null());
|
||||
assert!(source_report["data_quality"].is_null());
|
||||
|
||||
let runtime_report = read_single_report(&report_dir, "rtr-runtime");
|
||||
assert_report_time_offset(&runtime_report["service"]["started_at"]);
|
||||
assert_eq!(
|
||||
runtime_report["configuration"]["source_refresh_interval_seconds"],
|
||||
300
|
||||
);
|
||||
assert_eq!(runtime_report["configuration"]["report_history_limit"], 10);
|
||||
|
||||
let clients_report = read_single_report(&report_dir, "rtr-clients");
|
||||
assert_eq!(clients_report["service"]["max_connections"], 1024);
|
||||
assert_eq!(clients_report["service"]["active_connections"], 0);
|
||||
assert_eq!(
|
||||
clients_report["service"]["connections_by_transport"]["tcp"],
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
clients_report["service"]["connections_by_transport"]["tls"],
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
clients_report["service"]["connections_by_transport"]["ssh"],
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
source_report["cache"]["versions"].as_array().unwrap().len(),
|
||||
3
|
||||
);
|
||||
assert_eq!(
|
||||
source_report["cache"]["versions"][2]["snapshot"]["total"],
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
source_report["cache"]["memory"]["delta_payload_counts"][2],
|
||||
0
|
||||
);
|
||||
assert!(source_report["source_fingerprint"].is_null());
|
||||
assert_no_temporary_reports(&report_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_failure_preserves_last_successful_source_data() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let report_dir = temp.path().join("report");
|
||||
let shared_cache = Arc::new(ArcSwap::from_pointee(RtrCache::default()));
|
||||
let service = RtrService::new(shared_cache.clone());
|
||||
let notifier = service.notifier();
|
||||
let context = ReportContext::new(test_configuration());
|
||||
let source = SourceLoadReport {
|
||||
ccr_file: "data/example.ccr".to_string(),
|
||||
ccr_file_size_bytes: 123,
|
||||
ccr_modified_at: Some(Utc::now()),
|
||||
ccr_produced_at: Some("20260615000000Z".to_string()),
|
||||
slurm_enabled: true,
|
||||
slurm_file_count: 1,
|
||||
slurm_files: vec!["policy.slurm".to_string()],
|
||||
slurm_version: Some(2),
|
||||
};
|
||||
let quality = DataQualityReport {
|
||||
ccr_input: PayloadTypeCounts {
|
||||
total: 11,
|
||||
vrp: 10,
|
||||
router_key: 0,
|
||||
aspa: 1,
|
||||
},
|
||||
invalid: PayloadTypeCounts::default(),
|
||||
before_slurm: PayloadTypeCounts {
|
||||
total: 11,
|
||||
vrp: 10,
|
||||
router_key: 0,
|
||||
aspa: 1,
|
||||
},
|
||||
after_slurm: PayloadTypeCounts {
|
||||
total: 10,
|
||||
vrp: 9,
|
||||
router_key: 0,
|
||||
aspa: 1,
|
||||
},
|
||||
slurm_filters: SlurmRuleCounts {
|
||||
prefix: 1,
|
||||
router_key: 0,
|
||||
aspa: 0,
|
||||
},
|
||||
slurm_assertions: SlurmRuleCounts::default(),
|
||||
};
|
||||
context.record_source_fingerprint(SourceFingerprint {
|
||||
ccr: FileFingerprint {
|
||||
path: "data/example.ccr".to_string(),
|
||||
len: 123,
|
||||
modified_unix_secs: 1_781_404_800,
|
||||
},
|
||||
slurm_files: vec![FileFingerprint {
|
||||
path: "policy.slurm".to_string(),
|
||||
len: 42,
|
||||
modified_unix_secs: 1_781_408_400,
|
||||
}],
|
||||
});
|
||||
|
||||
context.record_refresh_success(Utc::now(), 12, true, source, quality);
|
||||
context.record_refresh_failure(Utc::now(), 5, &anyhow::anyhow!("source unavailable"));
|
||||
context
|
||||
.write(
|
||||
&report_dir,
|
||||
"refresh_failed",
|
||||
&shared_cache,
|
||||
¬ifier,
|
||||
&service.stats(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let report = read_single_report(&report_dir, "rtr-source");
|
||||
assert_eq!(report["source"]["ccr_file"], "data/example.ccr");
|
||||
assert_eq!(
|
||||
report["source_fingerprint"]["ccr"]["path"],
|
||||
"data/example.ccr"
|
||||
);
|
||||
assert_eq!(report["source_fingerprint"]["ccr"]["len"], 123);
|
||||
assert_report_time_offset(&report["source_fingerprint"]["ccr"]["modified_at"]);
|
||||
assert_eq!(
|
||||
report["source_fingerprint"]["slurm_files"][0]["path"],
|
||||
"policy.slurm"
|
||||
);
|
||||
assert_report_time_offset(&report["source"]["ccr_modified_at"]);
|
||||
assert_eq!(report["data_quality"]["after_slurm"]["total"], 10);
|
||||
assert_eq!(report["refresh"]["status"], "failed");
|
||||
assert_eq!(report["refresh"]["consecutive_failures"], 1);
|
||||
assert_eq!(report["refresh"]["last_error"], "source unavailable");
|
||||
assert!(!report["refresh"]["last_success_at"].is_null());
|
||||
assert_report_time_offset(&report["refresh"]["last_success_at"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolling_reports_keep_latest_files_per_category() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let report_dir = temp.path().join("report");
|
||||
let shared_cache = Arc::new(ArcSwap::from_pointee(RtrCache::default()));
|
||||
let service = RtrService::new(shared_cache.clone());
|
||||
let notifier = service.notifier();
|
||||
let mut configuration = test_configuration();
|
||||
configuration.report_history_limit = 2;
|
||||
let context = ReportContext::new(configuration);
|
||||
|
||||
for phase in ["one", "two", "three"] {
|
||||
context
|
||||
.write(
|
||||
&report_dir,
|
||||
phase,
|
||||
&shared_cache,
|
||||
¬ifier,
|
||||
&service.stats(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(report_files(&report_dir, "rtr-source").len(), 2);
|
||||
assert_eq!(report_files(&report_dir, "rtr-clients").len(), 2);
|
||||
assert_eq!(report_files(&report_dir, "rtr-runtime").len(), 2);
|
||||
assert_no_temporary_reports(&report_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_writes_only_create_requested_report_type() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let report_dir = temp.path().join("report");
|
||||
let shared_cache = Arc::new(ArcSwap::from_pointee(RtrCache::default()));
|
||||
let service = RtrService::new(shared_cache.clone());
|
||||
let notifier = service.notifier();
|
||||
let context = ReportContext::new(test_configuration());
|
||||
|
||||
context
|
||||
.write_source(
|
||||
&report_dir,
|
||||
"source_only",
|
||||
&shared_cache,
|
||||
¬ifier,
|
||||
&service.stats(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(report_files(&report_dir, "rtr-source").len(), 1);
|
||||
assert_eq!(report_files(&report_dir, "rtr-clients").len(), 0);
|
||||
assert_eq!(report_files(&report_dir, "rtr-runtime").len(), 0);
|
||||
|
||||
context
|
||||
.write_clients(
|
||||
&report_dir,
|
||||
"clients_only",
|
||||
&shared_cache,
|
||||
¬ifier,
|
||||
&service.stats(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(report_files(&report_dir, "rtr-source").len(), 1);
|
||||
assert_eq!(report_files(&report_dir, "rtr-clients").len(), 1);
|
||||
assert_eq!(report_files(&report_dir, "rtr-runtime").len(), 0);
|
||||
|
||||
context
|
||||
.write_runtime(
|
||||
&report_dir,
|
||||
"runtime_only",
|
||||
&shared_cache,
|
||||
¬ifier,
|
||||
&service.stats(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(report_files(&report_dir, "rtr-source").len(), 1);
|
||||
assert_eq!(report_files(&report_dir, "rtr-clients").len(), 1);
|
||||
assert_eq!(report_files(&report_dir, "rtr-runtime").len(), 1);
|
||||
}
|
||||
|
||||
fn assert_report_time_offset(value: &Value) {
|
||||
let value = value.as_str().expect("report time should be a string");
|
||||
assert!(
|
||||
value.ends_with("+08:00"),
|
||||
"report time should use +08:00 offset, got {value}"
|
||||
);
|
||||
}
|
||||
|
||||
fn read_single_report(report_dir: &Path, prefix: &str) -> Value {
|
||||
let files = report_files(report_dir, prefix);
|
||||
assert_eq!(files.len(), 1, "expected one {prefix} report");
|
||||
serde_json::from_slice(&fs::read(&files[0]).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn report_files(report_dir: &Path, prefix: &str) -> Vec<PathBuf> {
|
||||
let start = format!("{prefix}-");
|
||||
let mut files = fs::read_dir(report_dir)
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().path())
|
||||
.filter(|path| {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.starts_with(&start) && name.ends_with(".json"))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
files.sort();
|
||||
files
|
||||
}
|
||||
|
||||
fn assert_no_temporary_reports(report_dir: &Path) {
|
||||
let has_temporary = fs::read_dir(report_dir).unwrap().any(|entry| {
|
||||
entry
|
||||
.unwrap()
|
||||
.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|name| name.ends_with(".tmp"))
|
||||
});
|
||||
assert!(!has_temporary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -947,7 +947,12 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_end_of_data(&mut self, session_id: u16, serial: u32, timing: Timing) -> Result<()> {
|
||||
async fn write_end_of_data(
|
||||
&mut self,
|
||||
session_id: u16,
|
||||
serial: u32,
|
||||
timing: Timing,
|
||||
) -> Result<()> {
|
||||
let version = self.version()?;
|
||||
|
||||
let end = EndOfData::new(version, session_id, serial, timing)?;
|
||||
@@ -1117,12 +1122,7 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_aspa_to<W>(
|
||||
writer: &mut W,
|
||||
aspa: &Aspa,
|
||||
announce: bool,
|
||||
version: u8,
|
||||
) -> Result<()>
|
||||
async fn send_aspa_to<W>(writer: &mut W, aspa: &Aspa, announce: bool, version: u8) -> Result<()>
|
||||
where
|
||||
W: AsyncWrite + Unpin,
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use rocksdb::{ColumnFamilyDescriptor, IteratorMode, Options, WriteBatch, DB};
|
||||
use anyhow::{Result, anyhow};
|
||||
use rocksdb::{ColumnFamilyDescriptor, DB, IteratorMode, Options, WriteBatch};
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::borrow::Borrow;
|
||||
use std::path::Path;
|
||||
@@ -397,82 +397,3 @@ fn validate_delta_window(deltas: &[Delta], min_serial: u32, max_serial: u32) ->
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use crate::data_model::resources::as_resources::Asn;
|
||||
use crate::data_model::resources::ip_resources::{IPAddress, IPAddressPrefix};
|
||||
use crate::rtr::cache::Snapshot;
|
||||
use crate::rtr::payload::{Aspa, Payload, RouteOrigin, RouterKey, Ski};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn mixed_snapshot() -> Snapshot {
|
||||
Snapshot::from_payloads(vec![
|
||||
Payload::RouteOrigin(RouteOrigin::new(
|
||||
IPAddressPrefix::new(IPAddress::from_ipv4(Ipv4Addr::new(192, 0, 2, 0)), 24),
|
||||
24,
|
||||
Asn::from(64496),
|
||||
)),
|
||||
Payload::RouterKey(RouterKey::new(
|
||||
Ski::default(),
|
||||
Asn::from(64497),
|
||||
vec![1, 2, 3],
|
||||
)),
|
||||
Payload::Aspa(Aspa::new(Asn::from(64498), vec![Asn::from(64499)])),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_cache_state_persists_single_canonical_snapshot() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let store = RtrStore::open(temp.path()).unwrap();
|
||||
let source = mixed_snapshot();
|
||||
let snapshots = std::array::from_fn(|version| source.project_for_version(version as u8));
|
||||
let deltas: [Option<&Delta>; 3] = [None, None, None];
|
||||
let windows: [Option<(u32, u32)>; 3] = [None, None, None];
|
||||
let clear = [true, true, true];
|
||||
|
||||
store
|
||||
.save_cache_state_versioned(
|
||||
CacheAvailability::Ready,
|
||||
&snapshots,
|
||||
&[1, 2, 3],
|
||||
&[10, 11, 12],
|
||||
&deltas,
|
||||
&windows,
|
||||
&clear,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(store
|
||||
.get_cf::<Snapshot>(CF_SNAPSHOT, &snapshot_key(0))
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert!(store
|
||||
.get_cf::<Snapshot>(CF_SNAPSHOT, &snapshot_key(1))
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert!(store
|
||||
.get_cf::<Snapshot>(CF_SNAPSHOT, &snapshot_key(2))
|
||||
.unwrap()
|
||||
.is_some());
|
||||
|
||||
let restored_v0 = store.get_snapshot_for_version(0).unwrap().unwrap();
|
||||
assert_eq!(restored_v0.origins().len(), 1);
|
||||
assert_eq!(restored_v0.router_keys().len(), 0);
|
||||
assert_eq!(restored_v0.aspas().len(), 0);
|
||||
|
||||
let restored_v1 = store.get_snapshot_for_version(1).unwrap().unwrap();
|
||||
assert_eq!(restored_v1.origins().len(), 1);
|
||||
assert_eq!(restored_v1.router_keys().len(), 1);
|
||||
assert_eq!(restored_v1.aspas().len(), 0);
|
||||
|
||||
let restored_v2 = store.get_snapshot_for_version(2).unwrap().unwrap();
|
||||
assert_eq!(restored_v2.origins().len(), 1);
|
||||
assert_eq!(restored_v2.router_keys().len(), 1);
|
||||
assert_eq!(restored_v2.aspas().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user