use std::collections::{BTreeMap, BTreeSet};
use axum::{
Router,
body::Bytes,
extract::{Path, State, WebSocketUpgrade},
http::{HeaderMap, HeaderValue, StatusCode, header},
response::{IntoResponse, Redirect, Response},
routing::{delete, get, post, put},
};
use futures_util::future::join_all;
use serde_json::json;
use crate::{
api::{
error::ApiError,
models::{
ActionResponse, AdminBoardUpsertRequest, AdminOverviewResponse,
AdminServerConfigEditable, AdminServerConfigReadonly, AdminServerConfigResponse,
AdminSessionsResponse, AdminTftpConfigResponse, AdminTftpStatusResponse,
BoardPowerAction, BoardPowerStatusResponse, BoardRuntimeStatusResponse,
BoardTypeSummary, BootProfileResponse, CreateSessionRequest, DtbFileResponse,
FileResponse, NetworkInterfaceSummary, SerialPortSummary, SerialStatusResponse,
SessionCreatedResponse, SessionDetailResponse, SessionDtbResponse, TftpSessionResponse,
UpdateServerConfigRequest,
},
},
board_pool::BoardAllocationStatus,
config::{BoardConfig, BootConfig, PowerManagementConfig, ServerConfig, TftpConfig},
dtb_store::normalize_dtb_name,
power::{PowerAction, PowerActionError},
serial::{
discovery::list_serial_ports as discover_serial_ports,
discovery::resolve_serial_config,
network::{
default_non_loopback_interface_name,
list_network_interfaces as discover_network_interfaces,
},
ws::run_serial_ws,
},
session::SessionStopReason,
state::{AppState, BoardLeaseState, TouchSessionError},
tftp::{
files::{TftpFileRef, normalize_relative_path},
service::build_tftp_manager,
status::resolve_interface_ipv4,
},
web::{serve_admin_asset, serve_admin_history, serve_admin_index},
};
pub fn build_router(state: AppState) -> Router {
Router::new()
.route(
"/",
get(|| async { Redirect::temporary("/admin/overview") }),
)
.route("/admin", get(serve_admin_index))
.route("/admin/", get(serve_admin_index))
.route("/admin/assets/{*path}", get(serve_admin_asset))
.route("/admin/{*path}", get(serve_admin_history))
.route("/api/v1/admin/overview", get(get_admin_overview))
.route("/api/v1/admin/boards", get(list_boards).post(create_board))
.route("/api/v1/admin/dtbs", get(list_dtbs).post(create_dtb))
.route("/api/v1/admin/serial-ports", get(list_serial_ports))
.route(
"/api/v1/admin/network-interfaces",
get(list_network_interfaces),
)
.route(
"/api/v1/admin/boards/{board_id}/power-status",
get(get_board_power_status),
)
.route(
"/api/v1/admin/boards/{board_id}/runtime-status",
get(get_board_runtime_status),
)
.route(
"/api/v1/admin/boards/{board_id}",
get(get_board).put(update_board).delete(delete_board),
)
.route(
"/api/v1/admin/dtbs/{dtb_name}",
get(get_dtb).put(update_dtb).delete(delete_dtb),
)
.route("/api/v1/admin/sessions", get(list_admin_sessions))
.route(
"/api/v1/admin/sessions/{session_id}",
delete(delete_admin_session),
)
.route(
"/api/v1/admin/tftp",
get(get_tftp_config).put(update_tftp_config),
)
.route("/api/v1/admin/tftp/status", get(get_tftp_status))
.route("/api/v1/admin/tftp/reconcile", post(reconcile_tftp))
.route(
"/api/v1/admin/server-config",
get(get_server_config).put(update_server_config),
)
.route("/api/v1/board-types", get(list_board_types))
.route("/api/v1/sessions", post(create_session))
.route(
"/api/v1/sessions/{session_id}",
get(get_session).delete(delete_session),
)
.route(
"/api/v1/sessions/{session_id}/heartbeat",
post(heartbeat_session),
)
.route(
"/api/v1/sessions/{session_id}/boot-profile",
get(get_boot_profile),
)
.route("/api/v1/sessions/{session_id}/dtb", get(get_session_dtb))
.route(
"/api/v1/sessions/{session_id}/dtb/download",
get(download_session_dtb),
)
.route(
"/api/v1/sessions/{session_id}/serial",
get(get_serial_status),
)
.route("/api/v1/sessions/{session_id}/serial/ws", get(serial_ws))
.route(
"/api/v1/sessions/{session_id}/board/power-on",
post(power_on_board),
)
.route(
"/api/v1/sessions/{session_id}/board/power-off",
post(power_off_board),
)
.route(
"/api/v1/sessions/{session_id}/files",
get(list_session_files).put(put_session_file),
)
.route(
"/api/v1/sessions/{session_id}/files/{*path}",
put(reject_legacy_put_session_file)
.get(get_session_file)
.delete(delete_session_file),
)
.route(
"/api/v1/sessions/{session_id}/tftp",
get(get_session_tftp_status),
)
.with_state(state)
}
async fn get_admin_overview(
State(state): State<AppState>,
) -> Result<axum::Json<AdminOverviewResponse>, ApiError> {
let boards = state.boards.read().await;
let runtimes = state.board_runtimes.read().await;
let board_types = summarize_board_types(&boards, &runtimes);
let board_count_total = boards.len();
let disabled_board_count = boards.values().filter(|board| board.disabled).count();
let board_count_available = boards
.values()
.filter(|board| !board.disabled)
.filter(|board| {
runtimes
.get(&board.id)
.map(|runtime| runtime.lease_state == BoardLeaseState::Idle)
.unwrap_or(false)
})
.count();
drop(runtimes);
drop(boards);
let mut tftp_status = state
.tftp_manager
.read()
.await
.status()
.await
.map_err(|err| {
ApiError::service_unavailable(format!("failed to get TFTP status: {err}"))
})?;
let config = state.config.read().await.clone();
tftp_status.resolved_server_ip =
resolve_server_network(&config)?.and_then(|network| network.server_ip);
tftp_status.resolved_netmask =
resolve_server_network(&config)?.and_then(|network| network.netmask);
Ok(axum::Json(AdminOverviewResponse {
board_count_total,
board_count_available,
disabled_board_count,
active_session_count: session_snapshots(&state)
.await
.into_iter()
.filter(|session| session.state == crate::session::SessionLifecycleState::Active)
.count(),
board_types,
tftp_status,
server: readonly_server_config(&config),
}))
}
async fn list_boards(
State(state): State<AppState>,
) -> Result<axum::Json<Vec<BoardConfig>>, ApiError> {
let boards = state
.boards
.read()
.await
.values()
.cloned()
.map(with_resolved_serial_config)
.collect::<Vec<_>>();
Ok(axum::Json(boards))
}
async fn list_dtbs(
State(state): State<AppState>,
) -> Result<axum::Json<Vec<DtbFileResponse>>, ApiError> {
let files = state.dtb_store.list_all().await?;
Ok(axum::Json(
files.into_iter().map(DtbFileResponse::from_dtb).collect(),
))
}
async fn list_network_interfaces() -> Result<axum::Json<Vec<NetworkInterfaceSummary>>, ApiError> {
Ok(axum::Json(discover_network_interfaces().map_err(
|err| {
ApiError::service_unavailable(format!(
"failed to enumerate network interfaces: {err:#}"
))
},
)?))
}
async fn list_serial_ports() -> Result<axum::Json<Vec<SerialPortSummary>>, ApiError> {
Ok(axum::Json(discover_serial_ports().map_err(|err| {
ApiError::service_unavailable(format!("failed to enumerate serial ports: {err:#}"))
})?))
}
async fn get_board(
Path(board_id): Path<String>,
State(state): State<AppState>,
) -> Result<axum::Json<BoardConfig>, ApiError> {
let board = state
.boards
.read()
.await
.get(&board_id)
.cloned()
.ok_or_else(|| ApiError::not_found(format!("board `{board_id}` not found")))?;
Ok(axum::Json(with_resolved_serial_config(board)))
}
async fn get_board_power_status(
Path(board_id): Path<String>,
State(state): State<AppState>,
) -> Result<axum::Json<BoardPowerStatusResponse>, ApiError> {
let status = state
.board_power_status(&board_id)
.await
.ok_or_else(|| ApiError::not_found(format!("board `{board_id}` not found")))?;
Ok(axum::Json(BoardPowerStatusResponse {
available: status.available,
powered: status.powered,
last_action: status.last_action.map(board_power_action),
updated_at: status.updated_at,
}))
}
async fn get_board_runtime_status(
Path(board_id): Path<String>,
State(state): State<AppState>,
) -> Result<axum::Json<BoardRuntimeStatusResponse>, ApiError> {
let status = state
.board_runtime_status(&board_id)
.await
.ok_or_else(|| ApiError::not_found(format!("board `{board_id}` not found")))?;
Ok(axum::Json(BoardRuntimeStatusResponse {
lease_state: status.lease_state,
active_session_id: status.active_session_id,
last_release_error: status.last_release_error,
updated_at: status.updated_at,
}))
}
async fn get_dtb(
Path(dtb_name): Path<String>,
State(state): State<AppState>,
) -> Result<axum::Json<DtbFileResponse>, ApiError> {
let dtb_name =
normalize_dtb_name(&dtb_name).map_err(|err| ApiError::bad_request(err.to_string()))?;
let file = state
.dtb_store
.get(&dtb_name)
.await?
.ok_or_else(|| ApiError::not_found(format!("DTB `{dtb_name}` not found")))?;
Ok(axum::Json(DtbFileResponse::from_dtb(file)))
}
async fn create_dtb(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> Result<(StatusCode, axum::Json<DtbFileResponse>), ApiError> {
let dtb_name = dtb_name_header(&headers, "X-Dtb-Name")?;
if body.is_empty() {
return Err(ApiError::bad_request("DTB upload body must not be empty"));
}
if state.dtb_store.get(&dtb_name).await?.is_some() {
return Err(ApiError::conflict(format!(
"DTB `{dtb_name}` already exists"
)));
}
let file = state.dtb_store.write(&dtb_name, &body).await?;
Ok((
StatusCode::CREATED,
axum::Json(DtbFileResponse::from_dtb(file)),
))
}
async fn create_board(
State(state): State<AppState>,
axum::Json(request): axum::Json<AdminBoardUpsertRequest>,
) -> Result<(StatusCode, axum::Json<BoardConfig>), ApiError> {
let board = build_board_config_for_create(&state, request).await?;
{
let boards = state.boards.read().await;
if boards.contains_key(&board.id) {
return Err(ApiError::conflict(format!(
"board `{}` already exists",
board.id
)));
}
}
state.board_store.write_board(&board).await?;
state
.boards
.write()
.await
.insert(board.id.clone(), board.clone());
state.sync_board_runtime_states().await;
state.sync_virtual_power_statuses().await;
Ok((StatusCode::CREATED, axum::Json(board)))
}
async fn update_board(
Path(board_id): Path<String>,
State(state): State<AppState>,
axum::Json(request): axum::Json<AdminBoardUpsertRequest>,
) -> Result<axum::Json<BoardConfig>, ApiError> {
let board = build_board_config_for_update(&state, &board_id, request).await?;
{
let boards = state.boards.read().await;
if !boards.contains_key(&board_id) {
return Err(ApiError::not_found(format!("board `{board_id}` not found")));
}
if board.id != board_id && boards.contains_key(&board.id) {
return Err(ApiError::conflict(format!(
"board `{}` already exists",
board.id
)));
}
}
let runtime = state
.board_runtime_status(&board_id)
.await
.ok_or_else(|| ApiError::not_found(format!("board `{board_id}` not found")))?;
if runtime.lease_state != BoardLeaseState::Idle {
return Err(ApiError::conflict(format!(
"board `{board_id}` is not idle"
)));
}
state.board_store.write_board(&board).await?;
if board.id != board_id {
state.board_store.delete_board(&board_id).await?;
}
{
let mut boards = state.boards.write().await;
boards.remove(&board_id);
boards.insert(board.id.clone(), board.clone());
}
state.sync_board_runtime_states().await;
state.sync_virtual_power_statuses().await;
Ok(axum::Json(board))
}
async fn build_board_config_for_create(
state: &AppState,
request: AdminBoardUpsertRequest,
) -> Result<BoardConfig, ApiError> {
let mut request = normalize_board_upsert_request(request)?;
let boards = state.boards.read().await;
let board_id = request
.id
.take()
.unwrap_or_else(|| allocate_board_id(&boards, &request.board_type));
Ok(request.into_board_config(board_id))
}
async fn build_board_config_for_update(
_state: &AppState,
current_board_id: &str,
request: AdminBoardUpsertRequest,
) -> Result<BoardConfig, ApiError> {
let mut request = normalize_board_upsert_request(request)?;
let board_id = request
.id
.take()
.unwrap_or_else(|| current_board_id.to_string());
Ok(request.into_board_config(board_id))
}
fn normalize_board_upsert_request(
mut request: AdminBoardUpsertRequest,
) -> Result<AdminBoardUpsertRequest, ApiError> {
normalize_optional_string(&mut request.id);
normalize_required_string(&mut request.board_type, "board_type")?;
normalize_optional_string(&mut request.notes);
normalize_tags(&mut request.tags);
normalize_serial_config(request.serial.as_mut())?;
normalize_power_management_config(&mut request.power_management)?;
normalize_boot_config(&mut request.boot);
if let Some(id) = request.id.as_ref()
&& (id.contains('/') || id.contains('\\'))
{
return Err(ApiError::bad_request(
"board id must not contain path separators",
));
}
if let BootConfig::Uboot(profile) = &request.boot
&& let Some(dtb_name) = profile.dtb_name.as_deref()
{
normalize_dtb_name(dtb_name).map_err(|err| ApiError::bad_request(err.to_string()))?;
}
Ok(request)
}
fn normalize_required_string(value: &mut String, field_name: &str) -> Result<(), ApiError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(ApiError::bad_request(format!(
"{field_name} must not be empty"
)));
}
if trimmed.len() != value.len() {
*value = trimmed.to_string();
}
Ok(())
}
fn normalize_optional_string(value: &mut Option<String>) {
if let Some(raw) = value {
let trimmed = raw.trim();
if trimmed.is_empty() {
*value = None;
} else if trimmed.len() != raw.len() {
*raw = trimmed.to_string();
}
}
}
fn normalize_tags(tags: &mut Vec<String>) {
*tags = tags
.iter()
.map(|tag| tag.trim())
.filter(|tag| !tag.is_empty())
.map(ToOwned::to_owned)
.collect();
}
fn normalize_serial_config(
serial: Option<&mut crate::config::SerialConfig>,
) -> Result<(), ApiError> {
let Some(serial) = serial else {
return Ok(());
};
let trimmed = serial.key.value.trim();
if trimmed.is_empty() {
return Err(ApiError::bad_request(
"serial.key.value must not be empty when serial is configured",
));
}
if trimmed.len() != serial.key.value.len() {
serial.key.value = trimmed.to_string();
}
if serial.baud_rate == 0 {
return Err(ApiError::bad_request(
"serial.baud_rate must be > 0 when serial is configured",
));
}
serial.resolved_device_path = None;
serial.resolved_usb_path = None;
Ok(())
}
fn normalize_power_management_config(
power_management: &mut PowerManagementConfig,
) -> Result<(), ApiError> {
match power_management {
PowerManagementConfig::Custom(custom) => {
normalize_required_string(&mut custom.power_on_cmd, "power_management.power_on_cmd")?;
normalize_required_string(&mut custom.power_off_cmd, "power_management.power_off_cmd")?;
}
PowerManagementConfig::ZhongshengRelay(relay) => {
normalize_required_string(&mut relay.serial_port, "power_management.serial_port")?;
}
PowerManagementConfig::Virtual(_) => {}
}
Ok(())
}
fn normalize_boot_config(boot: &mut BootConfig) {
match boot {
BootConfig::Uboot(profile) => {
normalize_optional_string(&mut profile.dtb_name);
}
BootConfig::Pxe(profile) => {
normalize_optional_string(&mut profile.notes);
}
}
}
fn allocate_board_id(boards: &BTreeMap<String, BoardConfig>, board_type: &str) -> String {
let mut num = 1usize;
loop {
let candidate = format!("{board_type}-{num}");
if !boards.contains_key(&candidate) {
return candidate;
}
num += 1;
}
}
impl AdminBoardUpsertRequest {
fn into_board_config(self, board_id: String) -> BoardConfig {
BoardConfig {
id: board_id,
board_type: self.board_type,
tags: self.tags,
serial: self.serial,
power_management: self.power_management,
boot: self.boot,
notes: self.notes,
disabled: self.disabled,
}
}
}
async fn update_dtb(
Path(dtb_name): Path<String>,
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> Result<axum::Json<DtbFileResponse>, ApiError> {
let current_name =
normalize_dtb_name(&dtb_name).map_err(|err| ApiError::bad_request(err.to_string()))?;
let requested_name = headers
.get("X-Dtb-Name")
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| {
normalize_dtb_name(value).map_err(|err| ApiError::bad_request(err.to_string()))
})
.transpose()?;
let mut effective_name = current_name.clone();
if requested_name.as_deref() == Some(current_name.as_str()) && body.is_empty() {
let file = state
.dtb_store
.get(¤t_name)
.await?
.ok_or_else(|| ApiError::not_found(format!("DTB `{current_name}` not found")))?;
return Ok(axum::Json(DtbFileResponse::from_dtb(file)));
}
if let Some(new_name) = requested_name.as_deref()
&& new_name != current_name
{
state
.dtb_store
.rename(¤t_name, new_name)
.await
.map_err(|err| {
let message = err.to_string();
if message.contains("already exists") {
ApiError::conflict(message)
} else if message.contains("not found") {
ApiError::not_found(message)
} else {
ApiError::from(err)
}
})?;
rewrite_board_dtb_references(&state, ¤t_name, new_name).await?;
effective_name = new_name.to_string();
}
if !body.is_empty() {
state.dtb_store.write(&effective_name, &body).await?;
} else if requested_name.is_none() {
return Err(ApiError::bad_request(
"DTB update requires a new name or replacement file body",
));
}
let file = state
.dtb_store
.get(&effective_name)
.await?
.ok_or_else(|| ApiError::not_found(format!("DTB `{effective_name}` not found")))?;
Ok(axum::Json(DtbFileResponse::from_dtb(file)))
}
async fn delete_dtb(
Path(dtb_name): Path<String>,
State(state): State<AppState>,
) -> Result<StatusCode, ApiError> {
let dtb_name =
normalize_dtb_name(&dtb_name).map_err(|err| ApiError::bad_request(err.to_string()))?;
let boards = state.boards.read().await;
let referenced_by = boards_referencing_dtb(&boards, &dtb_name);
drop(boards);
if !referenced_by.is_empty() {
return Err(ApiError::conflict(format!(
"DTB `{dtb_name}` is referenced by boards: {}",
referenced_by.join(", ")
)));
}
if state.dtb_store.get(&dtb_name).await?.is_none() {
return Err(ApiError::not_found(format!("DTB `{dtb_name}` not found")));
}
state.dtb_store.delete(&dtb_name).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn delete_board(
Path(board_id): Path<String>,
State(state): State<AppState>,
) -> Result<StatusCode, ApiError> {
let runtime = state
.board_runtime_status(&board_id)
.await
.ok_or_else(|| ApiError::not_found(format!("board `{board_id}` not found")))?;
if runtime.lease_state != BoardLeaseState::Idle {
return Err(ApiError::conflict(format!(
"board `{board_id}` is not idle"
)));
}
{
let mut boards = state.boards.write().await;
if boards.remove(&board_id).is_none() {
return Err(ApiError::not_found(format!("board `{board_id}` not found")));
}
}
state.sync_board_runtime_states().await;
state.sync_virtual_power_statuses().await;
state.board_store.delete_board(&board_id).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn list_admin_sessions(
State(state): State<AppState>,
) -> Result<axum::Json<AdminSessionsResponse>, ApiError> {
Ok(axum::Json(AdminSessionsResponse {
sessions: session_snapshots(&state).await,
}))
}
async fn delete_admin_session(
Path(session_id): Path<String>,
State(state): State<AppState>,
) -> Result<StatusCode, ApiError> {
let removed = state
.request_session_stop(&session_id, SessionStopReason::ApiDelete)
.await;
if removed.is_none() {
return Err(ApiError::not_found(format!(
"session `{session_id}` not found"
)));
}
Ok(StatusCode::ACCEPTED)
}
async fn get_tftp_config(
State(state): State<AppState>,
) -> Result<axum::Json<AdminTftpConfigResponse>, ApiError> {
let config = state.config.read().await.clone();
Ok(axum::Json(AdminTftpConfigResponse { tftp: config.tftp }))
}
async fn update_tftp_config(
State(state): State<AppState>,
axum::Json(tftp): axum::Json<TftpConfig>,
) -> Result<axum::Json<AdminTftpConfigResponse>, ApiError> {
tokio::fs::create_dir_all(tftp.root_dir())
.await
.map_err(|err| ApiError::internal(err.to_string()))?;
let new_manager = build_tftp_manager(&tftp);
new_manager.start_if_needed().await.map_err(|err| {
ApiError::service_unavailable(format!("failed to start TFTP provider: {err}"))
})?;
if matches!(tftp, TftpConfig::SystemTftpdHpa(_))
&& let Err(err) = new_manager.reconcile().await
{
return Err(ApiError::service_unavailable(format!(
"failed to reconcile TFTP provider: {err}"
)));
}
{
let mut config = state.config.write().await;
config.tftp = tftp.clone();
}
state.save_config().await?;
*state.tftp_manager.write().await = new_manager;
Ok(axum::Json(AdminTftpConfigResponse { tftp }))
}
async fn get_tftp_status(
State(state): State<AppState>,
) -> Result<axum::Json<AdminTftpStatusResponse>, ApiError> {
let mut status = state
.tftp_manager
.read()
.await
.status()
.await
.map_err(|err| {
ApiError::service_unavailable(format!("failed to get TFTP status: {err}"))
})?;
let config = state.config.read().await.clone();
status.resolved_server_ip =
resolve_server_network(&config)?.and_then(|network| network.server_ip);
status.resolved_netmask = resolve_server_network(&config)?.and_then(|network| network.netmask);
Ok(axum::Json(AdminTftpStatusResponse { status }))
}
async fn get_server_config(
State(state): State<AppState>,
) -> Result<axum::Json<AdminServerConfigResponse>, ApiError> {
let config = state.config.read().await.clone();
Ok(axum::Json(server_config_response(&config)))
}
async fn update_server_config(
State(state): State<AppState>,
axum::Json(request): axum::Json<UpdateServerConfigRequest>,
) -> Result<axum::Json<AdminServerConfigResponse>, ApiError> {
if request.network.interface.trim().is_empty() {
return Err(ApiError::bad_request("network.interface must not be empty"));
}
{
let mut config = state.config.write().await;
config.network = request.network;
}
state.save_config().await?;
let config = state.config.read().await.clone();
Ok(axum::Json(server_config_response(&config)))
}
async fn reconcile_tftp(
State(state): State<AppState>,
) -> Result<axum::Json<AdminTftpStatusResponse>, ApiError> {
{
let manager = state.tftp_manager.read().await;
manager.reconcile().await.map_err(|err| {
ApiError::service_unavailable(format!("failed to reconcile TFTP: {err}"))
})?;
}
get_tftp_status(State(state)).await
}
async fn list_board_types(
State(state): State<AppState>,
) -> Result<axum::Json<Vec<BoardTypeSummary>>, ApiError> {
let boards = state.boards.read().await;
let runtimes = state.board_runtimes.read().await;
let result = summarize_board_types(&boards, &runtimes);
Ok(axum::Json(result))
}
async fn create_session(
State(state): State<AppState>,
axum::Json(request): axum::Json<CreateSessionRequest>,
) -> Result<(StatusCode, axum::Json<SessionCreatedResponse>), ApiError> {
if request.board_type.trim().is_empty() {
return Err(ApiError::bad_request("board_type must not be empty"));
}
let session = state
.create_session(
&request.board_type,
&request.required_tags,
request.client_name.clone(),
)
.await
.map_err(|err| match err {
BoardAllocationStatus::BoardTypeNotFound => {
ApiError::not_found(format!("board type `{}` not found", request.board_type))
}
BoardAllocationStatus::NoAvailableBoard => ApiError::conflict(format!(
"no available board for type `{}`",
request.board_type
)),
})?;
let board = state
.session_board(&session.id)
.await
.ok_or_else(|| ApiError::not_found("allocated board disappeared"))?;
let ws_url = board
.serial
.as_ref()
.map(|_| format!("/api/v1/sessions/{}/serial/ws", session.id));
Ok((
StatusCode::CREATED,
axum::Json(SessionCreatedResponse {
session_id: session.id,
board_id: board.id,
lease_expires_at: session.expires_at,
serial_available: board.serial.is_some(),
boot_mode: board.boot.kind_name().to_string(),
ws_url,
}),
))
}
async fn get_session(
Path(session_id): Path<String>,
State(state): State<AppState>,
) -> Result<axum::Json<SessionDetailResponse>, ApiError> {
let session = get_session_or_404(&state, &session_id).await?;
let board = state
.session_board(&session_id)
.await
.ok_or_else(|| ApiError::not_found("session board not found"))?;
let files = session_file_responses(&state, &session_id, &board).await?;
let connected = session.serial_connected;
Ok(axum::Json(SessionDetailResponse {
session,
board: board.clone(),
serial_available: board.serial.is_some(),
serial_connected: connected,
files,
}))
}
async fn heartbeat_session(
Path(session_id): Path<String>,
State(state): State<AppState>,
) -> Result<axum::Json<serde_json::Value>, ApiError> {
let session = state
.heartbeat_session(&session_id)
.await
.map_err(|err| match err {
TouchSessionError::NotFound => {
ApiError::not_found(format!("session `{session_id}` not found"))
}
TouchSessionError::Releasing => {
ApiError::conflict(format!("session `{session_id}` is releasing"))
}
})?;
Ok(axum::Json(json!({
"session_id": session.id,
"lease_expires_at": session.expires_at
})))
}
async fn delete_session(
Path(session_id): Path<String>,
State(state): State<AppState>,
) -> Result<StatusCode, ApiError> {
let removed = state
.request_session_stop(&session_id, SessionStopReason::ApiDelete)
.await;
if removed.is_none() {
return Err(ApiError::not_found(format!(
"session `{session_id}` not found"
)));
}
Ok(StatusCode::ACCEPTED)
}
async fn get_boot_profile(
Path(session_id): Path<String>,
State(state): State<AppState>,
) -> Result<axum::Json<BootProfileResponse>, ApiError> {
let board = state
.session_board(&session_id)
.await
.ok_or_else(|| ApiError::not_found("session board not found"))?;
let network = resolved_board_network(&state, &board).await?;
Ok(axum::Json(BootProfileResponse {
boot: board.boot,
server_ip: network.as_ref().and_then(|item| item.server_ip.clone()),
netmask: network.as_ref().and_then(|item| item.netmask.clone()),
interface: network.as_ref().and_then(|item| item.interface.clone()),
}))
}
async fn get_session_dtb(
Path(session_id): Path<String>,
State(state): State<AppState>,
) -> Result<axum::Json<SessionDtbResponse>, ApiError> {
get_session_or_404(&state, &session_id).await?;
let board = state
.session_board(&session_id)
.await
.ok_or_else(|| ApiError::not_found("session board not found"))?;
let Some(dtb_name) = board_preset_dtb_name(&board).map(str::to_string) else {
return Ok(axum::Json(SessionDtbResponse {
dtb_name: None,
relative_path: None,
session_file_path: None,
tftp_url: None,
}));
};
let file = ensure_session_preset_dtb_file(&state, &session_id, &board).await?;
let tftp_url = if let Some(file) = file {
file_response_for_board(&state, &board, file)
.await?
.tftp_url
} else {
None
};
Ok(axum::Json(SessionDtbResponse {
dtb_name: Some(dtb_name.clone()),
relative_path: Some(session_dtb_relative_path(&session_id, &dtb_name)),
session_file_path: Some(session_dtb_file_path(&dtb_name)),
tftp_url,
}))
}
async fn download_session_dtb(
Path(session_id): Path<String>,
State(state): State<AppState>,
) -> Result<Response, ApiError> {
get_session_or_404(&state, &session_id).await?;
let board = state
.session_board(&session_id)
.await
.ok_or_else(|| ApiError::not_found("session board not found"))?;
let dtb_name = board_preset_dtb_name(&board)
.ok_or_else(|| ApiError::not_found("board has no preset DTB configured"))?;
let bytes = state.dtb_store.read(dtb_name).await.map_err(|err| {
let message = err.to_string();
if message.contains("No such file") || message.contains("not found") {
ApiError::not_found(format!("preset DTB `{dtb_name}` not found"))
} else {
ApiError::from(err)
}
})?;
Ok((
StatusCode::OK,
[
(
header::CONTENT_TYPE,
HeaderValue::from_static("application/octet-stream"),
),
(
header::CONTENT_DISPOSITION,
HeaderValue::from_str(&format!("attachment; filename=\"{dtb_name}\""))
.unwrap_or_else(|_| HeaderValue::from_static("attachment")),
),
],
bytes,
)
.into_response())
}
async fn get_serial_status(
Path(session_id): Path<String>,
State(state): State<AppState>,
) -> Result<axum::Json<SerialStatusResponse>, ApiError> {
let board = state
.session_board(&session_id)
.await
.ok_or_else(|| ApiError::not_found("session board not found"))?;
let connected = state
.get_session(&session_id)
.await
.map(|session| session.serial_connected)
.unwrap_or(false);
let response = if let Some(serial) = board.serial {
let resolved = resolve_serial_config(&serial)
.map_err(|err| ApiError::service_unavailable(format!("{err:#}")))?;
SerialStatusResponse {
available: true,
connected,
port: Some(resolved.current_device_path),
baud_rate: Some(serial.baud_rate),
ws_url: Some(format!("/api/v1/sessions/{session_id}/serial/ws")),
}
} else {
SerialStatusResponse {
available: false,
connected: false,
port: None,
baud_rate: None,
ws_url: None,
}
};
Ok(axum::Json(response))
}
fn with_resolved_serial_config(mut board: BoardConfig) -> BoardConfig {
if let Some(serial) = board.serial.as_mut()
&& let Ok(resolved) = resolve_serial_config(serial)
{
serial.resolved_device_path = Some(resolved.current_device_path);
serial.resolved_usb_path = resolved.usb_path;
}
board
}
async fn serial_ws(
Path(session_id): Path<String>,
State(state): State<AppState>,
ws: WebSocketUpgrade,
) -> Result<Response, ApiError> {
let session = state
.session_state(&session_id)
.await
.ok_or_else(|| ApiError::not_found("session not found"))?;
let board = session.board().clone();
let Some(_serial) = board.serial.clone() else {
return Err(ApiError::conflict("board has no serial configuration"));
};
if session.is_releasing() {
return Err(ApiError::conflict("session is releasing"));
}
if !session.try_set_serial_connected() {
return Err(ApiError::conflict("serial websocket already connected"));
}
Ok(ws.on_upgrade(move |socket| run_serial_ws(socket, state, session)))
}
async fn power_on_board(
Path(session_id): Path<String>,
State(state): State<AppState>,
) -> Result<axum::Json<ActionResponse>, ApiError> {
run_board_power_action(&state, &session_id, true).await
}
async fn power_off_board(
Path(session_id): Path<String>,
State(state): State<AppState>,
) -> Result<axum::Json<ActionResponse>, ApiError> {
run_board_power_action(&state, &session_id, false).await
}
async fn list_session_files(
Path(session_id): Path<String>,
State(state): State<AppState>,
) -> Result<axum::Json<Vec<FileResponse>>, ApiError> {
let board = state
.session_board(&session_id)
.await
.ok_or_else(|| ApiError::not_found("session board not found"))?;
Ok(axum::Json(
session_file_responses(&state, &session_id, &board).await?,
))
}
async fn put_session_file(
Path(session_id): Path<String>,
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> Result<(StatusCode, axum::Json<FileResponse>), ApiError> {
let session = state
.session_state(&session_id)
.await
.ok_or_else(|| ApiError::not_found(format!("session `{session_id}` not found")))?;
if session.is_releasing() {
return Err(ApiError::conflict(format!(
"session `{session_id}` is releasing"
)));
}
let board = state
.session_board(&session_id)
.await
.ok_or_else(|| ApiError::not_found("session board not found"))?;
let relative_path = headers
.get("X-File-Path")
.and_then(|value| value.to_str().ok())
.ok_or_else(|| ApiError::bad_request("missing X-File-Path header"))?;
let relative_path = parse_relative_path(relative_path)?;
if !state.config.read().await.tftp.enabled() {
return Err(ApiError::conflict("TFTP provider is disabled"));
}
let manager = state.tftp_manager.read().await.clone();
let file = manager
.put_session_file(&session_id, &relative_path, &body)
.await
.map_err(|err| ApiError::service_unavailable(format!("{err:#}")))?;
let response = file_response_for_board(&state, &board, file).await?;
Ok((StatusCode::CREATED, axum::Json(response)))
}
async fn get_session_file(
Path((session_id, path)): Path<(String, String)>,
State(state): State<AppState>,
) -> Result<axum::Json<FileResponse>, ApiError> {
let relative_path = parse_relative_path(&path)?;
let board = state
.session_board(&session_id)
.await
.ok_or_else(|| ApiError::not_found("session board not found"))?;
let manager = state.tftp_manager.read().await.clone();
let file = manager
.get_session_file(&session_id, &relative_path)
.await?
.ok_or_else(|| ApiError::not_found(format!("file `{relative_path}` not found")))?;
Ok(axum::Json(
file_response_for_board(&state, &board, file).await?,
))
}
async fn reject_legacy_put_session_file(
Path((_session_id, _path)): Path<(String, String)>,
) -> Result<StatusCode, ApiError> {
Err(ApiError::not_found(
"upload files via PUT /api/v1/sessions/{session_id}/files with X-File-Path",
))
}
async fn delete_session_file(
Path((session_id, path)): Path<(String, String)>,
State(state): State<AppState>,
) -> Result<StatusCode, ApiError> {
let relative_path = parse_relative_path(&path)?;
let session = state
.session_state(&session_id)
.await
.ok_or_else(|| ApiError::not_found(format!("session `{session_id}` not found")))?;
if session.is_releasing() {
return Err(ApiError::conflict(format!(
"session `{session_id}` is releasing"
)));
}
let manager = state.tftp_manager.read().await.clone();
manager
.remove_session_file(&session_id, &relative_path)
.await?;
Ok(StatusCode::NO_CONTENT)
}
async fn get_session_tftp_status(
Path(session_id): Path<String>,
State(state): State<AppState>,
) -> Result<axum::Json<TftpSessionResponse>, ApiError> {
let board = state
.session_board(&session_id)
.await
.ok_or_else(|| ApiError::not_found("session board not found"))?;
let status = state.tftp_manager.read().await.status().await?;
let server_ip = resolved_board_network(&state, &board)
.await?
.and_then(|network| network.server_ip);
let files = session_file_responses(&state, &session_id, &board).await?;
Ok(axum::Json(TftpSessionResponse {
available: status.enabled && status.healthy && status.writable && server_ip.is_some(),
provider: status.provider,
server_ip,
netmask: resolved_board_network(&state, &board)
.await?
.and_then(|network| network.netmask),
writable: status.writable,
files,
}))
}
async fn get_session_or_404(
state: &AppState,
session_id: &str,
) -> Result<crate::session::Session, ApiError> {
state
.get_session(session_id)
.await
.ok_or_else(|| ApiError::not_found(format!("session `{session_id}` not found")))
}
async fn session_file_responses(
state: &AppState,
session_id: &str,
board: &BoardConfig,
) -> Result<Vec<FileResponse>, ApiError> {
let manager = state.tftp_manager.read().await.clone();
let files = manager.list_session_files(session_id).await?;
let mut responses = Vec::with_capacity(files.len());
for file in files {
responses.push(file_response_for_board(state, board, file).await?);
}
Ok(responses)
}
async fn file_response_for_board(
state: &AppState,
board: &BoardConfig,
file: TftpFileRef,
) -> Result<FileResponse, ApiError> {
let tftp_url = resolved_board_network(state, board)
.await?
.and_then(|network| network.server_ip)
.map(|server_ip| format!("tftp://{server_ip}/{}", file.relative_path));
Ok(FileResponse::from_file(file, tftp_url))
}
async fn run_board_power_action(
state: &AppState,
session_id: &str,
power_on: bool,
) -> Result<axum::Json<ActionResponse>, ApiError> {
let session = state
.session_state(session_id)
.await
.ok_or_else(|| ApiError::not_found("session board not found"))?;
if session.is_releasing() {
return Err(ApiError::conflict(format!(
"session `{session_id}` is releasing"
)));
}
let board = state
.session_board(session_id)
.await
.ok_or_else(|| ApiError::not_found("session board not found"))?;
let action = if power_on {
PowerAction::On
} else {
PowerAction::Off
};
let message = state
.execute_board_power_action(&board, action)
.await
.map_err(|err| match err {
PowerActionError::NotConfigured | PowerActionError::InvalidConfig(_) => {
ApiError::bad_request(err.to_string())
}
PowerActionError::Execution(err) => ApiError::from(err),
})?;
Ok(axum::Json(ActionResponse { ok: true, message }))
}
fn board_power_action(action: PowerAction) -> BoardPowerAction {
match action {
PowerAction::On => BoardPowerAction::PowerOn,
PowerAction::Off => BoardPowerAction::PowerOff,
}
}
fn parse_relative_path(raw: &str) -> Result<String, ApiError> {
normalize_relative_path(raw).map_err(|err| ApiError::bad_request(err.to_string()))
}
fn summarize_board_types(
boards: &BTreeMap<String, BoardConfig>,
runtimes: &BTreeMap<String, crate::state::BoardRuntimeState>,
) -> Vec<BoardTypeSummary> {
let mut aggregate = BTreeMap::<String, (BTreeSet<String>, usize, usize)>::new();
for board in boards.values().filter(|board| !board.disabled) {
let entry = aggregate
.entry(board.board_type.clone())
.or_insert_with(|| (BTreeSet::new(), 0, 0));
for tag in &board.tags {
entry.0.insert(tag.clone());
}
entry.1 += 1;
if runtimes
.get(&board.id)
.map(|runtime| runtime.lease_state == BoardLeaseState::Idle)
.unwrap_or(false)
{
entry.2 += 1;
}
}
aggregate
.into_iter()
.map(|(board_type, (tags, total, available))| BoardTypeSummary {
board_type,
tags: tags.into_iter().collect(),
total,
available,
})
.collect::<Vec<_>>()
}
async fn session_snapshots(state: &AppState) -> Vec<crate::session::Session> {
let sessions = state
.sessions
.read()
.await
.values()
.cloned()
.collect::<Vec<_>>();
join_all(
sessions
.into_iter()
.map(|session| async move { session.snapshot().await }),
)
.await
}
fn boards_referencing_dtb(boards: &BTreeMap<String, BoardConfig>, dtb_name: &str) -> Vec<String> {
boards
.values()
.filter(|board| board_preset_dtb_name(board) == Some(dtb_name))
.map(|board| board.id.clone())
.collect()
}
fn readonly_server_config(config: &crate::config::ServerConfig) -> AdminServerConfigReadonly {
AdminServerConfigReadonly {
listen_addr: config.listen_addr.to_string(),
data_dir: config.data_dir.display().to_string(),
board_dir: config.board_dir.display().to_string(),
dtb_dir: config.dtb_dir.display().to_string(),
}
}
fn server_config_response(config: &crate::config::ServerConfig) -> AdminServerConfigResponse {
AdminServerConfigResponse {
readonly: readonly_server_config(config),
editable: AdminServerConfigEditable {
network: config.network.clone(),
},
}
}
#[derive(Debug, Clone)]
struct ResolvedNetwork {
interface: Option<String>,
server_ip: Option<String>,
netmask: Option<String>,
}
fn resolve_server_network(config: &ServerConfig) -> Result<Option<ResolvedNetwork>, ApiError> {
let interface = if config.network.interface.trim().is_empty() {
default_non_loopback_interface_name()
} else {
Some(config.network.interface.trim().to_string())
};
let interfaces = discover_network_interfaces().map_err(|err| {
ApiError::service_unavailable(format!("failed to enumerate network interfaces: {err:#}"))
})?;
let matched = interfaces
.into_iter()
.find(|item| interface.as_deref() == Some(item.name.as_str()));
let server_ip = if let Some(interface_name) = interface.as_deref() {
resolve_interface_ipv4(interface_name).map_err(|err| {
ApiError::service_unavailable(format!("failed to resolve interface IP: {err}"))
})?
} else {
None
};
let netmask = matched.and_then(|item| item.netmask);
Ok(Some(ResolvedNetwork {
interface,
server_ip,
netmask,
}))
}
async fn resolved_board_network(
state: &AppState,
board: &BoardConfig,
) -> Result<Option<ResolvedNetwork>, ApiError> {
let BootConfig::Uboot(profile) = &board.boot else {
return Ok(None);
};
if !profile.use_tftp {
return Ok(None);
}
let config = state.config.read().await.clone();
resolve_server_network(&config)
}
fn dtb_name_header(headers: &HeaderMap, name: &str) -> Result<String, ApiError> {
let value = headers
.get(name)
.and_then(|value| value.to_str().ok())
.ok_or_else(|| ApiError::bad_request(format!("missing {name} header")))?;
normalize_dtb_name(value).map_err(|err| ApiError::bad_request(err.to_string()))
}
fn board_preset_dtb_name(board: &BoardConfig) -> Option<&str> {
let BootConfig::Uboot(profile) = &board.boot else {
return None;
};
profile.dtb_name.as_deref()
}
fn session_dtb_file_path(dtb_name: &str) -> String {
format!("boot/dtb/{dtb_name}")
}
fn session_dtb_relative_path(session_id: &str, dtb_name: &str) -> String {
format!(
"ostool/sessions/{session_id}/{}",
session_dtb_file_path(dtb_name)
)
}
async fn ensure_session_preset_dtb_file(
state: &AppState,
session_id: &str,
board: &BoardConfig,
) -> Result<Option<TftpFileRef>, ApiError> {
let Some(dtb_name) = board_preset_dtb_name(board) else {
return Ok(None);
};
let file_path = session_dtb_file_path(dtb_name);
let manager = state.tftp_manager.read().await.clone();
if let Some(existing) = manager.get_session_file(session_id, &file_path).await? {
return Ok(Some(existing));
}
let bytes = state.dtb_store.read(dtb_name).await.map_err(|err| {
let message = err.to_string();
if message.contains("No such file") || message.contains("not found") {
ApiError::not_found(format!("preset DTB `{dtb_name}` not found"))
} else {
ApiError::from(err)
}
})?;
let file = manager
.put_session_file(session_id, &file_path, &bytes)
.await
.map_err(|err| ApiError::service_unavailable(format!("{err:#}")))?;
Ok(Some(file))
}
async fn rewrite_board_dtb_references(
state: &AppState,
old_name: &str,
new_name: &str,
) -> Result<(), ApiError> {
let affected = {
let boards = state.boards.read().await;
boards
.values()
.filter_map(|board| {
let mut next = board.clone();
let BootConfig::Uboot(profile) = &mut next.boot else {
return None;
};
if profile.dtb_name.as_deref() != Some(old_name) {
return None;
}
profile.dtb_name = Some(new_name.to_string());
Some(next)
})
.collect::<Vec<_>>()
};
for board in &affected {
state.board_store.write_board(board).await?;
}
if !affected.is_empty() {
let mut boards = state.boards.write().await;
for board in affected {
boards.insert(board.id.clone(), board);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::future;
use std::sync::Arc;
use axum::{
Router,
body::{Body, to_bytes},
http::{Request, StatusCode, header},
};
use serde_json::json;
#[cfg(unix)]
use serialport::{SerialPort, TTYPort};
use tempfile::tempdir;
use tokio::sync::{mpsc, oneshot};
#[cfg(unix)]
use tokio_modbus::{
ExceptionCode, Request as ModbusRequest, Response as ModbusResponse, SlaveRequest,
server::{Service, rtu::Server},
};
use tower::util::ServiceExt;
use super::{build_router, resolve_server_network};
use crate::{
api::models::{
BoardPowerStatusResponse, BoardRuntimeStatusResponse, SessionDetailResponse,
},
build_app_state,
config::{
BoardConfig, BootConfig, BuiltinTftpConfig, CustomPowerManagement,
PowerManagementConfig, SerialConfig, SerialPortKey, SerialPortKeyKind, ServerConfig,
TftpConfig, VirtualPowerManagement, ZhongshengRelayPowerManagement,
},
session::SessionLifecycleState,
state::BoardLeaseState,
tftp::service::{TftpManager, build_tftp_manager},
web::first_asset_path,
};
#[cfg(unix)]
type RelayServerHandle =
tokio::task::JoinHandle<std::io::Result<tokio_modbus::server::Terminated>>;
#[cfg(unix)]
type RelayRequestRx = mpsc::UnboundedReceiver<(u8, u16, bool)>;
#[cfg(unix)]
type RelayTestServer = (
String,
TTYPort,
RelayServerHandle,
RelayRequestRx,
oneshot::Sender<()>,
);
fn test_server_config(root: &std::path::Path) -> ServerConfig {
ServerConfig {
listen_addr: "127.0.0.1:0".parse().unwrap(),
data_dir: root.join("data"),
board_dir: root.join("boards"),
dtb_dir: root.join("dtbs"),
tftp: TftpConfig::Builtin(BuiltinTftpConfig::default_with_root(root.join("tftp"))),
network: crate::TftpNetworkConfig {
interface: "lo".into(),
},
}
}
async fn test_router() -> Router {
let temp = tempdir().unwrap();
let root = temp.path().to_path_buf();
std::mem::forget(temp);
let config_path = root.join(".ostool-server.toml");
let config = test_server_config(&root);
let manager: Arc<dyn TftpManager> = build_tftp_manager(&config.tftp);
let state = build_app_state(config_path, config, manager).await.unwrap();
state.ensure_data_dirs().await.unwrap();
build_router(state)
}
async fn create_board(app: &Router, request: serde_json::Value) -> StatusCode {
app.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/admin/boards")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(request.to_string()))
.unwrap(),
)
.await
.unwrap()
.status()
}
async fn create_session(app: &Router, board_type: &str) -> String {
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/sessions")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"board_type": board_type,
"required_tags": [],
"client_name": "test",
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
value["session_id"].as_str().unwrap().to_string()
}
async fn upload_dtb(app: &Router, name: &str, body: &'static str) -> StatusCode {
app.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/admin/dtbs")
.header("X-Dtb-Name", name)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap()
.status()
}
fn sample_board(board_id: &str) -> BoardConfig {
BoardConfig {
id: board_id.into(),
board_type: "rk3568".into(),
tags: vec!["lab".into(), "usb".into()],
serial: Some(SerialConfig {
key: SerialPortKey {
kind: SerialPortKeyKind::UsbPath,
value: "/dev/serial/by-path/pci-0000:00:14.0-usb-0:10.4:1.0-port0".into(),
},
baud_rate: 115_200,
resolved_device_path: None,
resolved_usb_path: None,
}),
power_management: PowerManagementConfig::Custom(CustomPowerManagement {
power_on_cmd: "echo on".into(),
power_off_cmd: "echo off".into(),
}),
boot: BootConfig::Uboot(crate::config::UbootProfile {
use_tftp: true,
..Default::default()
}),
notes: Some("rack-a".into()),
disabled: false,
}
}
fn sample_virtual_board(board_id: &str) -> BoardConfig {
let mut board = sample_board(board_id);
board.power_management = PowerManagementConfig::Virtual(VirtualPowerManagement::default());
board
}
#[cfg(unix)]
#[derive(Clone)]
struct RecordingRelayService {
requests: mpsc::UnboundedSender<(u8, u16, bool)>,
}
#[cfg(unix)]
impl Service for RecordingRelayService {
type Request = SlaveRequest<'static>;
type Response = ModbusResponse;
type Exception = ExceptionCode;
type Future = future::Ready<std::result::Result<Self::Response, Self::Exception>>;
fn call(&self, req: Self::Request) -> Self::Future {
match req.request {
ModbusRequest::WriteSingleCoil(address, coil) => {
self.requests.send((req.slave, address, coil)).unwrap();
future::ready(Ok(ModbusResponse::WriteSingleCoil(address, coil)))
}
_ => future::ready(Err(ExceptionCode::IllegalFunction)),
}
}
}
#[cfg(unix)]
fn spawn_relay_test_server() -> RelayTestServer {
let (master, mut slave) = TTYPort::pair().unwrap();
slave.set_exclusive(false).unwrap();
let slave_path = slave.name().unwrap();
let server_stream = tokio_serial::SerialStream::try_from(master).unwrap();
let (request_tx, request_rx) = mpsc::unbounded_channel();
let (stop_tx, stop_rx) = oneshot::channel();
let task = tokio::spawn(async move {
Server::new(server_stream)
.serve_until(
RecordingRelayService {
requests: request_tx,
},
async move {
let _ = stop_rx.await;
},
)
.await
});
(slave_path, slave, task, request_rx, stop_tx)
}
#[tokio::test]
async fn admin_route_serves_embedded_index() {
let app: Router = test_router().await;
let response = app
.oneshot(
Request::builder()
.uri("/admin")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get(header::CONTENT_TYPE).unwrap(),
"text/html"
);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let body = String::from_utf8(body.to_vec()).unwrap();
assert!(body.contains("ostool-server 管理台"));
}
#[tokio::test]
async fn admin_asset_route_serves_embedded_asset() {
let asset_path = first_asset_path().expect("missing built frontend asset");
let app: Router = test_router().await;
let response = app
.oneshot(
Request::builder()
.uri(format!("/admin/{asset_path}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(response.headers().contains_key(header::CONTENT_TYPE));
}
#[tokio::test]
async fn admin_history_fallback_serves_index() {
let app: Router = test_router().await;
let response = app
.oneshot(
Request::builder()
.uri("/admin/boards/demo-board")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let body = String::from_utf8(body.to_vec()).unwrap();
assert!(body.contains("id=\"app\""));
}
#[tokio::test]
async fn server_config_endpoint_updates_only_network() {
let app: Router = test_router().await;
let response = app
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/v1/admin/server-config")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"network":{"interface":"lo"}}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(value["editable"]["network"]["interface"], "lo");
assert!(value["readonly"]["listen_addr"].is_string());
}
#[test]
fn resolve_server_network_uses_configured_interface() {
let mut config = ServerConfig::default();
config.network.interface = "lo".into();
let resolved = resolve_server_network(&config).unwrap().unwrap();
assert_eq!(resolved.interface.as_deref(), Some("lo"));
}
#[test]
fn board_config_new_uboot_profile_supports_use_tftp() {
let board = BoardConfig {
id: "demo".into(),
board_type: "demo".into(),
tags: vec![],
serial: None,
power_management: PowerManagementConfig::Custom(CustomPowerManagement {
power_on_cmd: "echo on".into(),
power_off_cmd: "echo off".into(),
}),
boot: BootConfig::Uboot(crate::config::UbootProfile {
use_tftp: true,
..Default::default()
}),
notes: None,
disabled: false,
};
match board.boot {
BootConfig::Uboot(profile) => assert!(profile.use_tftp),
BootConfig::Pxe(_) => panic!("expected uboot"),
}
}
#[tokio::test]
async fn get_serial_ports_endpoint_returns_ok() {
let app = test_router().await;
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/v1/admin/serial-ports")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let _: Vec<crate::api::models::SerialPortSummary> = serde_json::from_slice(&body).unwrap();
}
#[tokio::test]
async fn get_board_returns_board_config() {
let app = test_router().await;
let board = sample_board("demo-board");
assert_eq!(
create_board(&app, serde_json::to_value(&board).unwrap()).await,
StatusCode::CREATED
);
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/v1/admin/boards/demo-board")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let returned: BoardConfig = serde_json::from_slice(&body).unwrap();
assert_eq!(returned.id, "demo-board");
assert_eq!(returned.board_type, "rk3568");
}
#[tokio::test]
async fn create_board_persists_request_payload_and_returns_board_config() {
let app = test_router().await;
let request = serde_json::to_value(sample_board("create-me")).unwrap();
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/admin/boards")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_vec(&request).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let board: BoardConfig = serde_json::from_slice(&body).unwrap();
assert_eq!(board.id, "create-me");
let serial = board.serial.unwrap();
assert_eq!(serial.key.kind, SerialPortKeyKind::UsbPath);
assert_eq!(
serial.key.value,
"/dev/serial/by-path/pci-0000:00:14.0-usb-0:10.4:1.0-port0"
);
}
#[tokio::test]
async fn create_board_assigns_next_available_id_when_id_is_blank() {
let app = test_router().await;
let board = sample_board("demo-board");
assert_eq!(
create_board(&app, serde_json::to_value(&board).unwrap()).await,
StatusCode::CREATED
);
assert_eq!(
create_board(
&app,
json!({
"id": "rk3568-1",
"board_type": "rk3568",
"tags": [],
"serial": null,
"power_management": { "kind": "custom", "power_on_cmd": "echo on", "power_off_cmd": "echo off" },
"boot": { "kind": "pxe", "notes": null },
"notes": null,
"disabled": false
}),
)
.await,
StatusCode::CREATED
);
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/admin/boards")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"id": "",
"board_type": "rk3568",
"tags": [" lab "],
"serial": null,
"power_management": { "kind": "custom", "power_on_cmd": "echo on", "power_off_cmd": "echo off" },
"boot": { "kind": "pxe", "notes": null },
"notes": " ",
"disabled": false
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let board: BoardConfig = serde_json::from_slice(&body).unwrap();
assert_eq!(board.id, "rk3568-2");
assert_eq!(board.tags, vec!["lab"]);
assert!(board.notes.is_none());
}
#[tokio::test]
async fn update_board_keeps_original_id_when_request_id_is_blank() {
let app = test_router().await;
let board = sample_board("demo-board");
assert_eq!(
create_board(&app, serde_json::to_value(&board).unwrap()).await,
StatusCode::CREATED
);
let response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/v1/admin/boards/demo-board")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"id": " ",
"board_type": "rk3568",
"tags": ["usb"],
"serial": null,
"power_management": { "kind": "custom", "power_on_cmd": "echo on", "power_off_cmd": "echo off" },
"boot": { "kind": "uboot", "use_tftp": false },
"notes": "updated",
"disabled": true
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let updated: BoardConfig = serde_json::from_slice(&body).unwrap();
assert_eq!(updated.id, "demo-board");
assert!(updated.serial.is_none());
assert!(updated.disabled);
}
#[tokio::test]
async fn update_board_allows_explicit_rename() {
let app = test_router().await;
let board = sample_board("demo-board");
assert_eq!(
create_board(&app, serde_json::to_value(&board).unwrap()).await,
StatusCode::CREATED
);
let response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/v1/admin/boards/demo-board")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"id": "demo-board-renamed",
"board_type": "rk3568",
"tags": ["lab"],
"serial": null,
"power_management": { "kind": "custom", "power_on_cmd": "echo on", "power_off_cmd": "echo off" },
"boot": { "kind": "pxe", "notes": "pxe" },
"notes": null,
"disabled": false
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let updated: BoardConfig = serde_json::from_slice(&body).unwrap();
assert_eq!(updated.id, "demo-board-renamed");
let boards_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/v1/admin/boards")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let boards_body = to_bytes(boards_response.into_body(), usize::MAX)
.await
.unwrap();
let boards: Vec<BoardConfig> = serde_json::from_slice(&boards_body).unwrap();
assert_eq!(boards[0].id, "demo-board-renamed");
}
#[tokio::test]
async fn update_board_accepts_virtual_power_management() {
let app = test_router().await;
let board = sample_board("demo-board");
assert_eq!(
create_board(&app, serde_json::to_value(&board).unwrap()).await,
StatusCode::CREATED
);
let response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/v1/admin/boards/demo-board")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"id": "demo-board",
"board_type": "rk3568",
"tags": ["lab"],
"serial": null,
"power_management": { "kind": "virtual" },
"boot": { "kind": "pxe", "notes": null },
"notes": null,
"disabled": false
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let status_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/v1/admin/boards/demo-board/power-status")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(status_response.status(), StatusCode::OK);
let body = to_bytes(status_response.into_body(), usize::MAX)
.await
.unwrap();
let status: BoardPowerStatusResponse = serde_json::from_slice(&body).unwrap();
assert!(status.available);
assert_eq!(status.powered, Some(false));
assert!(status.last_action.is_none());
}
#[tokio::test]
async fn power_actions_execute_custom_power_management_commands() {
let app = test_router().await;
let mut board = sample_board("power-board");
board.power_management = PowerManagementConfig::Custom(CustomPowerManagement {
power_on_cmd: "printf power-on >/dev/null".into(),
power_off_cmd: "printf power-off >/dev/null".into(),
});
assert_eq!(
create_board(&app, serde_json::to_value(&board).unwrap()).await,
StatusCode::CREATED
);
let session = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/sessions")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"board_type": "rk3568",
"required_tags": [],
"client_name": "test",
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let session_body = to_bytes(session.into_body(), usize::MAX).await.unwrap();
let session_value: serde_json::Value = serde_json::from_slice(&session_body).unwrap();
let session_id = session_value["session_id"].as_str().unwrap();
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/v1/sessions/{session_id}/board/power-on"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(value["message"], "executed `printf power-on >/dev/null`");
}
#[tokio::test]
async fn virtual_power_status_reports_board_state_before_and_after_actions() {
let app = test_router().await;
let board = sample_virtual_board("virtual-board");
assert_eq!(
create_board(&app, serde_json::to_value(&board).unwrap()).await,
StatusCode::CREATED
);
let initial = app
.clone()
.oneshot(
Request::builder()
.uri("/api/v1/admin/boards/virtual-board/power-status")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(initial.status(), StatusCode::OK);
let initial_body = to_bytes(initial.into_body(), usize::MAX).await.unwrap();
let initial_status: BoardPowerStatusResponse =
serde_json::from_slice(&initial_body).unwrap();
assert!(initial_status.available);
assert_eq!(initial_status.powered, Some(false));
assert!(initial_status.last_action.is_none());
assert!(initial_status.updated_at.is_none());
let session_id = create_session(&app, "rk3568").await;
let power_on = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/v1/sessions/{session_id}/board/power-on"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(power_on.status(), StatusCode::OK);
let power_on_body = to_bytes(power_on.into_body(), usize::MAX).await.unwrap();
let power_on_value: serde_json::Value = serde_json::from_slice(&power_on_body).unwrap();
assert_eq!(
power_on_value["message"],
"recorded virtual power-on for board `virtual-board`"
);
let powered = app
.clone()
.oneshot(
Request::builder()
.uri("/api/v1/admin/boards/virtual-board/power-status")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let powered_body = to_bytes(powered.into_body(), usize::MAX).await.unwrap();
let powered_status: BoardPowerStatusResponse =
serde_json::from_slice(&powered_body).unwrap();
assert_eq!(powered_status.powered, Some(true));
assert_eq!(
powered_status.last_action,
Some(crate::api::models::BoardPowerAction::PowerOn)
);
assert!(powered_status.updated_at.is_some());
let power_off = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/v1/sessions/{session_id}/board/power-off"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(power_off.status(), StatusCode::OK);
let power_off_body = to_bytes(power_off.into_body(), usize::MAX).await.unwrap();
let power_off_value: serde_json::Value = serde_json::from_slice(&power_off_body).unwrap();
assert_eq!(
power_off_value["message"],
"recorded virtual power-off for board `virtual-board`"
);
let powered_off = app
.clone()
.oneshot(
Request::builder()
.uri("/api/v1/admin/boards/virtual-board/power-status")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let powered_off_body = to_bytes(powered_off.into_body(), usize::MAX).await.unwrap();
let powered_off_status: BoardPowerStatusResponse =
serde_json::from_slice(&powered_off_body).unwrap();
assert_eq!(powered_off_status.powered, Some(false));
assert_eq!(
powered_off_status.last_action,
Some(crate::api::models::BoardPowerAction::PowerOff)
);
assert!(powered_off_status.updated_at.is_some());
}
#[tokio::test]
async fn board_runtime_status_reports_idle_board() {
let app = test_router().await;
let board = sample_virtual_board("runtime-board");
assert_eq!(
create_board(&app, serde_json::to_value(&board).unwrap()).await,
StatusCode::CREATED
);
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/v1/admin/boards/runtime-board/runtime-status")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let status: BoardRuntimeStatusResponse = serde_json::from_slice(&body).unwrap();
assert_eq!(status.lease_state, BoardLeaseState::Idle);
assert!(status.active_session_id.is_none());
assert!(status.last_release_error.is_none());
}
#[tokio::test]
async fn delete_session_returns_accepted_and_marks_session_releasing() {
let app = test_router().await;
let mut board = sample_board("release-board");
board.serial = None;
board.power_management = PowerManagementConfig::Custom(CustomPowerManagement {
power_on_cmd: "printf power-on >/dev/null".into(),
power_off_cmd: "sh -c 'sleep 1'".into(),
});
assert_eq!(
create_board(&app, serde_json::to_value(&board).unwrap()).await,
StatusCode::CREATED
);
let session_id = create_session(&app, "rk3568").await;
let delete_response = app
.clone()
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/api/v1/sessions/{session_id}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(delete_response.status(), StatusCode::ACCEPTED);
let mut state = SessionLifecycleState::Active;
for _ in 0..10 {
let session_response = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/v1/sessions/{session_id}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(session_response.status(), StatusCode::OK);
let session_body = to_bytes(session_response.into_body(), usize::MAX)
.await
.unwrap();
let detail: SessionDetailResponse = serde_json::from_slice(&session_body).unwrap();
state = detail.session.state;
if state == SessionLifecycleState::Releasing {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
assert_eq!(state, SessionLifecycleState::Releasing);
}
#[tokio::test]
#[cfg(unix)]
async fn power_actions_execute_zhongsheng_relay_via_modbus_rtu() {
let app = test_router().await;
let (relay_port, _relay_handle, server, mut requests, stop_tx) = spawn_relay_test_server();
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
let mut board = sample_board("relay-board");
board.power_management =
PowerManagementConfig::ZhongshengRelay(ZhongshengRelayPowerManagement {
serial_port: relay_port.clone(),
});
assert_eq!(
create_board(&app, serde_json::to_value(&board).unwrap()).await,
StatusCode::CREATED
);
let session = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/sessions")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"board_type": "rk3568",
"required_tags": [],
"client_name": "test",
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let session_body = to_bytes(session.into_body(), usize::MAX).await.unwrap();
let session_value: serde_json::Value = serde_json::from_slice(&session_body).unwrap();
let session_id = session_value["session_id"].as_str().unwrap();
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/v1/sessions/{session_id}/board/power-off"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
value["message"],
format!("executed Zhongsheng relay power-off via {relay_port}")
);
let request = tokio::time::timeout(std::time::Duration::from_secs(1), requests.recv())
.await
.unwrap()
.unwrap();
assert_eq!(request, (1, 0, false));
let _ = stop_tx.send(());
let _ = server.await.unwrap();
}
#[tokio::test]
async fn create_board_rejects_duplicate_ids_and_missing_required_fields() {
let app = test_router().await;
let board = sample_board("demo-board");
assert_eq!(
create_board(&app, serde_json::to_value(&board).unwrap()).await,
StatusCode::CREATED
);
let duplicate_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/admin/boards")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_vec(&board).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(duplicate_response.status(), StatusCode::CONFLICT);
let invalid_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/admin/boards")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"id": null,
"board_type": " ",
"tags": [],
"serial": null,
"power_management": { "kind": "custom", "power_on_cmd": "echo on", "power_off_cmd": "echo off" },
"boot": { "kind": "pxe", "notes": null },
"notes": null,
"disabled": false
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(invalid_response.status(), StatusCode::BAD_REQUEST);
let missing_power_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/admin/boards")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"id": null,
"board_type": "rk3568",
"tags": [],
"serial": null,
"power_management": null,
"boot": { "kind": "pxe", "notes": null },
"notes": null,
"disabled": false
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
missing_power_response.status(),
StatusCode::UNPROCESSABLE_ENTITY
);
}
#[tokio::test]
async fn admin_dtb_endpoints_support_create_rename_replace_and_delete() {
let app = test_router().await;
assert_eq!(
upload_dtb(&app, "board.dtb", "dtb-v1").await,
StatusCode::CREATED
);
let rename_response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/v1/admin/dtbs/board.dtb")
.header("X-Dtb-Name", "board-v2.dtb")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(rename_response.status(), StatusCode::OK);
let replace_response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/v1/admin/dtbs/board-v2.dtb")
.body(Body::from("dtb-v2"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(replace_response.status(), StatusCode::OK);
let replace_body = to_bytes(replace_response.into_body(), usize::MAX)
.await
.unwrap();
let file: crate::api::models::DtbFileResponse =
serde_json::from_slice(&replace_body).unwrap();
assert_eq!(file.name, "board-v2.dtb");
assert_eq!(file.size, 6);
assert_eq!(file.relative_tftp_path_template, "boot/dtb/board-v2.dtb");
let delete_response = app
.clone()
.oneshot(
Request::builder()
.method("DELETE")
.uri("/api/v1/admin/dtbs/board-v2.dtb")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(delete_response.status(), StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn renaming_dtb_updates_board_references_and_referenced_dtb_cannot_be_deleted() {
let app = test_router().await;
assert_eq!(
upload_dtb(&app, "board.dtb", "dtb").await,
StatusCode::CREATED
);
assert_eq!(
create_board(
&app,
json!({
"id": "rk3568-dtb",
"board_type": "rk3568",
"tags": [],
"serial": null,
"power_management": { "kind": "custom", "power_on_cmd": "echo on", "power_off_cmd": "echo off" },
"boot": { "kind": "uboot", "use_tftp": true, "dtb_name": "board.dtb" },
"notes": null,
"disabled": false
}),
)
.await,
StatusCode::CREATED
);
let delete_referenced = app
.clone()
.oneshot(
Request::builder()
.method("DELETE")
.uri("/api/v1/admin/dtbs/board.dtb")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(delete_referenced.status(), StatusCode::CONFLICT);
let rename_response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/v1/admin/dtbs/board.dtb")
.header("X-Dtb-Name", "board-renamed.dtb")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(rename_response.status(), StatusCode::OK);
let board_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/v1/admin/boards/rk3568-dtb")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let board_body = to_bytes(board_response.into_body(), usize::MAX)
.await
.unwrap();
let board: BoardConfig = serde_json::from_slice(&board_body).unwrap();
match board.boot {
BootConfig::Uboot(profile) => {
assert_eq!(profile.dtb_name.as_deref(), Some("board-renamed.dtb"))
}
BootConfig::Pxe(_) => panic!("expected uboot"),
}
}
#[tokio::test]
async fn session_dtb_endpoint_stages_preset_file_and_supports_download() {
let app = test_router().await;
assert_eq!(
upload_dtb(&app, "board.dtb", "dtb-bytes").await,
StatusCode::CREATED
);
assert_eq!(
create_board(
&app,
json!({
"id": "rk3568-dtb",
"board_type": "rk3568",
"tags": [],
"serial": {
"key": {
"kind": "usb_path",
"value": "/dev/serial/by-path/pci-0000:00:14.0-usb-0:10.4:1.0-port0"
},
"baud_rate": 115200
},
"power_management": { "kind": "custom", "power_on_cmd": "echo on", "power_off_cmd": "echo off" },
"boot": { "kind": "uboot", "use_tftp": true, "dtb_name": "board.dtb" },
"notes": null,
"disabled": false
}),
)
.await,
StatusCode::CREATED
);
let session_id = create_session(&app, "rk3568").await;
let dtb_response = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/v1/sessions/{session_id}/dtb"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(dtb_response.status(), StatusCode::OK);
let dtb_body = to_bytes(dtb_response.into_body(), usize::MAX)
.await
.unwrap();
let dtb: crate::api::models::SessionDtbResponse =
serde_json::from_slice(&dtb_body).unwrap();
assert_eq!(dtb.dtb_name.as_deref(), Some("board.dtb"));
assert_eq!(
dtb.relative_path.as_deref(),
Some(format!("ostool/sessions/{session_id}/boot/dtb/board.dtb").as_str())
);
assert_eq!(dtb.session_file_path.as_deref(), Some("boot/dtb/board.dtb"));
let download_response = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/v1/sessions/{session_id}/dtb/download"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(download_response.status(), StatusCode::OK);
let download_body = to_bytes(download_response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(download_body.as_ref(), b"dtb-bytes");
}
#[tokio::test]
async fn board_types_endpoint_returns_aggregated_counts() {
let app = test_router().await;
let board_a = json!({
"id": "rk3568-01",
"board_type": "rk3568",
"tags": ["lab-a", "usbboot"],
"serial": {
"key": {
"kind": "usb_path",
"value": "/dev/serial/by-path/pci-0000:00:14.0-usb-0:10.4:1.0-port0"
},
"baud_rate": 115200
},
"power_management": { "kind": "custom", "power_on_cmd": "echo on", "power_off_cmd": "echo off" },
"boot": { "kind": "uboot", "use_tftp": false },
"notes": null,
"disabled": false
});
let board_b = json!({
"id": "rk3568-02",
"board_type": "rk3568",
"tags": ["lab-b"],
"serial": {
"key": {
"kind": "serial_number",
"value": "BG02M9TR"
},
"baud_rate": 115200
},
"power_management": { "kind": "custom", "power_on_cmd": "echo on", "power_off_cmd": "echo off" },
"boot": { "kind": "uboot", "use_tftp": false },
"notes": null,
"disabled": false
});
assert_eq!(create_board(&app, board_a).await, StatusCode::CREATED);
assert_eq!(create_board(&app, board_b).await, StatusCode::CREATED);
let session_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/sessions")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"board_type": "rk3568",
"required_tags": [],
"client_name": "test",
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(session_response.status(), StatusCode::CREATED);
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/v1/board-types")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(value[0]["board_type"], "rk3568");
assert_eq!(value[0]["total"], 2);
assert_eq!(value[0]["available"], 1);
assert_eq!(value[0]["tags"], json!(["lab-a", "lab-b", "usbboot"]));
}
#[tokio::test]
async fn session_file_endpoints_support_nested_paths() {
let app = test_router().await;
assert_eq!(
create_board(
&app,
serde_json::to_value(sample_board("nested-files")).unwrap()
)
.await,
StatusCode::CREATED
);
let session_id = create_session(&app, "rk3568").await;
let upload = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri(format!("/api/v1/sessions/{session_id}/files"))
.header("X-File-Path", "boot/Image")
.body(Body::from("kernel-image"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(upload.status(), StatusCode::CREATED);
let upload_body = to_bytes(upload.into_body(), usize::MAX).await.unwrap();
let uploaded: serde_json::Value = serde_json::from_slice(&upload_body).unwrap();
assert_eq!(uploaded["filename"], "Image");
assert_eq!(
uploaded["relative_path"],
format!("ostool/sessions/{session_id}/boot/Image")
);
assert_eq!(
uploaded["tftp_url"],
format!("tftp://127.0.0.1/ostool/sessions/{session_id}/boot/Image")
);
let get_response = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/v1/sessions/{session_id}/files/boot/Image"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(get_response.status(), StatusCode::OK);
let delete_response = app
.clone()
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/api/v1/sessions/{session_id}/files/boot/Image"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(delete_response.status(), StatusCode::NO_CONTENT);
let missing_response = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/v1/sessions/{session_id}/files/boot/Image"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(missing_response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn session_file_list_supports_multiple_paths_and_overwrite() {
let app = test_router().await;
assert_eq!(
create_board(
&app,
serde_json::to_value(sample_board("list-files")).unwrap()
)
.await,
StatusCode::CREATED
);
let session_id = create_session(&app, "rk3568").await;
for (path, body) in [
("boot/Image", "v1"),
("boot/dtb/board.dtb", "dtb"),
("boot/Image", "updated-kernel"),
] {
let response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri(format!("/api/v1/sessions/{session_id}/files"))
.header("X-File-Path", path)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
}
let list_response = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/v1/sessions/{session_id}/files"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(list_response.status(), StatusCode::OK);
let list_body = to_bytes(list_response.into_body(), usize::MAX)
.await
.unwrap();
let files: serde_json::Value = serde_json::from_slice(&list_body).unwrap();
assert_eq!(files.as_array().unwrap().len(), 2);
assert_eq!(
files[0]["relative_path"],
format!("ostool/sessions/{session_id}/boot/Image")
);
assert_eq!(files[0]["size"], "updated-kernel".len());
assert_eq!(
files[1]["relative_path"],
format!("ostool/sessions/{session_id}/boot/dtb/board.dtb")
);
}
#[tokio::test]
async fn legacy_slot_file_route_is_removed() {
let app = test_router().await;
assert_eq!(
create_board(
&app,
serde_json::to_value(sample_board("legacy-files")).unwrap()
)
.await,
StatusCode::CREATED
);
let session_id = create_session(&app, "rk3568").await;
let response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri(format!("/api/v1/sessions/{session_id}/files/kernel"))
.body(Body::from("legacy"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn create_session_returns_created_when_board_is_available() {
let app = test_router().await;
let board = json!({
"id": "demo-01",
"board_type": "demo",
"tags": [],
"serial": {
"key": {
"kind": "usb_path",
"value": "/dev/serial/by-path/pci-0000:00:14.0-usb-0:10.4:1.0-port0"
},
"baud_rate": 115200
},
"power_management": { "kind": "custom", "power_on_cmd": "echo on", "power_off_cmd": "echo off" },
"boot": { "kind": "uboot", "use_tftp": false },
"notes": null,
"disabled": false
});
assert_eq!(create_board(&app, board).await, StatusCode::CREATED);
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/sessions")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"board_type": "demo",
"required_tags": [],
"client_name": "test",
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(value["board_id"], "demo-01");
assert_eq!(value["serial_available"], true);
}
#[tokio::test]
async fn create_session_returns_conflict_without_waiting_when_pool_is_busy() {
let app = test_router().await;
let board = json!({
"id": "demo-01",
"board_type": "demo",
"tags": [],
"serial": {
"key": {
"kind": "usb_path",
"value": "/dev/serial/by-path/pci-0000:00:14.0-usb-0:10.4:1.0-port0"
},
"baud_rate": 115200
},
"power_management": { "kind": "custom", "power_on_cmd": "echo on", "power_off_cmd": "echo off" },
"boot": { "kind": "uboot", "use_tftp": false },
"notes": null,
"disabled": false
});
assert_eq!(create_board(&app, board).await, StatusCode::CREATED);
let first = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/sessions")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"board_type": "demo",
"required_tags": [],
"client_name": "first",
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(first.status(), StatusCode::CREATED);
let second = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/sessions")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"board_type": "demo",
"required_tags": [],
"client_name": "second",
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(second.status(), StatusCode::CONFLICT);
let body = to_bytes(second.into_body(), usize::MAX).await.unwrap();
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(value["code"], "conflict");
assert_eq!(value["message"], "no available board for type `demo`");
}
#[tokio::test]
async fn create_session_returns_not_found_when_board_type_is_missing() {
let app = test_router().await;
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/sessions")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"board_type": "missing-demo",
"required_tags": [],
"client_name": "test",
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(value["code"], "not_found");
assert_eq!(value["message"], "board type `missing-demo` not found");
}
#[tokio::test]
async fn create_session_rejects_empty_board_type() {
let app = test_router().await;
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/sessions")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"board_type": "",
"required_tags": [],
"client_name": "test",
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(value["code"], "bad_request");
assert_eq!(value["message"], "board_type must not be empty");
}
}