增加rtr一些必要的api接口

This commit is contained in:
xiuting.xu
2026-06-23 17:04:00 +08:00
parent 303bdffd97
commit b105b3b7d0
41 changed files with 3078 additions and 1735 deletions

857
src/rtr/admin.rs Normal file
View 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
View File

@@ -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()
}

View File

@@ -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

View File

@@ -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(

View File

@@ -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());
}
}

View File

@@ -1,3 +1,4 @@
pub mod admin;
pub mod cache;
pub mod config;
pub mod error_type;

View File

@@ -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,
&notifier,
&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,
&notifier,
&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,
&notifier,
&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,
&notifier,
&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,
&notifier,
&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,
&notifier,
&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);
}
}

View File

@@ -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,
{

View File

@@ -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);
}
}