silserv/src/api.rs
2025-09-03 14:39:52 +07:00

675 lines
19 KiB
Rust

use aws_sdk_s3::operation::list_objects_v2::ListObjectsV2Output;
use axum::{
Router,
body::Body,
extract::{Path, Query, State},
http::StatusCode,
response::{IntoResponse, Json, Response},
routing::{delete, get, post, put},
};
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, sync::Arc};
use tokio::sync::Mutex;
use tower::ServiceBuilder;
use tower_http::{
cors::{Any, CorsLayer},
trace::{DefaultMakeSpan, TraceLayer},
};
use tracing::{debug, error, info};
use crate::{
discovery::ContainerDiscovery,
log_info,
logging::{LogDatesResponse, LogFilters, LogManager, LogResponse},
storage::Storage,
types::{ApiResponse, HealthResponse, ManagedContainer, StatsResponse},
updater::UpdateManager,
};
pub struct ApiServer {
port: u16,
update_manager: Arc<Mutex<UpdateManager>>,
discovery: Arc<ContainerDiscovery>,
storage: Arc<Storage>,
log_manager: Arc<LogManager>,
s3_client: aws_sdk_s3::Client,
garage_endpoint: String,
}
#[derive(Clone)]
pub struct AppState {
pub update_manager: Arc<Mutex<UpdateManager>>,
pub discovery: Arc<ContainerDiscovery>,
pub storage: Arc<Storage>,
pub log_manager: Arc<LogManager>,
// garage
pub s3_client: aws_sdk_s3::Client,
pub garage_endpoint: String,
}
impl ApiServer {
pub fn new(
port: u16,
update_manager: Arc<Mutex<UpdateManager>>,
discovery: Arc<ContainerDiscovery>,
storage: Arc<Storage>,
log_manager: Arc<LogManager>,
s3_client: aws_sdk_s3::Client,
garage_endpoint: String,
) -> Self {
Self {
port,
update_manager,
discovery,
storage,
log_manager,
s3_client,
garage_endpoint,
}
}
pub async fn run(self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let state = AppState {
update_manager: self.update_manager,
discovery: self.discovery,
storage: self.storage,
log_manager: self.log_manager.clone(),
s3_client: self.s3_client,
garage_endpoint: self.garage_endpoint,
};
let app = Router::new()
// Health and info endpoints
.route("/health", get(health_check))
.route("/", get(root))
// Container management endpoints
.route("/api/containers", get(list_containers))
.route("/api/containers/{id}", get(get_container))
.route("/api/containers/{id}/update", post(trigger_update))
.route("/api/containers/{id}/force-update", post(force_update))
.route("/api/containers/{id}/rollback", post(force_rollback))
// Discovery endpoints
.route("/api/discovery/scan", post(force_discovery))
.route("/api/discovery/containers", get(get_discovered_containers))
// System endpoints
.route("/api/status", get(system_status))
.route("/api/stats", get(get_stats))
.route("/api/logs", get(get_logs))
.route("/api/logs/dates", get(get_log_dates))
// Bulk operations
.route("/api/bulk/update-check", post(bulk_update_check))
// TODO: communicate with garage s3
// .route("/api/s3/{bucket}", get(handler))
// .route("/api/s3/{bucket}", put(handler))
// .route("/api/s3/{bucket}", delete(handler))
// .route("/api/s3/{bucket}/{key}", get(handler))
// .route("/api/s3/{bucket}/{key}", put(handler))
// .route("/api/s3/{bucket}/{key}", delete(handler))
// TODO: Online installations
.with_state(state)
.layer(
ServiceBuilder::new()
.layer(
TraceLayer::new_for_http()
.make_span_with(DefaultMakeSpan::new().include_headers(true)),
)
.layer(
CorsLayer::new()
.allow_origin(Any)
.allow_headers(Any)
.allow_methods(Any),
),
);
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", self.port)).await?;
info!("🌐 API server listening on http://0.0.0.0:{}", self.port);
log_info!(
self.log_manager.clone(),
"init",
format!("🌐 API server listening on http://0.0.0.0:{}", self.port)
);
axum::serve(listener, app).await?;
Ok(())
}
}
// Handler functions
async fn root() -> Json<serde_json::Value> {
Json(serde_json::json!({
"service": "Docker Update Manager",
"version": "1.0.0",
"description": "Automatic Docker container update management with service discovery",
"endpoints": {
"health": "/health",
"status": "/api/status",
"containers": "/api/containers",
"discovery": "/api/discovery/scan",
"stats": "/api/stats",
"bulk_update": "/api/bulk/update-check"
}
}))
}
async fn health_check(State(state): State<AppState>) -> Json<ApiResponse<HealthResponse>> {
log_info!(state.log_manager, "/health", "get health check...");
// let containers = match state.update_manager.lock().await.get_all_containers().await {
// Ok(containers) => containers,
// Err(_) => vec![],
// };
let containers = {
let mgr = state.update_manager.lock().await;
let _containers = mgr.get_all_containers().await;
drop(mgr);
match _containers {
Ok(containers) => containers,
Err(_) => vec![],
}
};
debug!("containers: {:?}", containers);
let active_updates = {
let mgr = state.update_manager.lock().await;
let _active_updates = mgr.get_active_updates_count().await;
drop(mgr);
_active_updates
};
let update_queue_size = {
let mgr = state.update_manager.lock().await;
let _update_queue_size = mgr.get_update_queue_size().await;
drop(mgr);
_update_queue_size
};
let health = HealthResponse {
status: "healthy".to_string(),
version: "1.0.0".to_string(),
uptime_seconds: 0, // TODO: Track actual uptime
managed_containers: containers.len(),
active_updates,
update_queue_size,
};
Json(ApiResponse::success(health))
}
async fn system_status(State(state): State<AppState>) -> Json<ApiResponse<SystemStatusResponse>> {
// let containers = state
// .update_manager
// .lock()
// .await
// .get_all_containers()
// .await
// .unwrap_or_default();
let containers = {
let mgr = state.update_manager.lock().await;
let _containers = mgr.get_all_containers().await.unwrap_or_default();
drop(mgr);
_containers
};
let discovered = state
.discovery
.get_discovered_containers()
.await
.unwrap_or_default();
let active_updates = {
let mgr = state.update_manager.lock().await;
let _active_updates = mgr.get_active_updates_count().await;
drop(mgr);
_active_updates
};
let update_queue_size = {
let mgr = state.update_manager.lock().await;
let _update_queue_size = mgr.get_update_queue_size().await;
drop(mgr);
_update_queue_size
};
let status = SystemStatusResponse {
service: "Silserv".to_string(),
version: "0.1.1".to_string(),
managed_containers: containers.len(),
discovered_containers: discovered.len(),
active_updates,
update_queue_size,
containers: containers
.into_iter()
.map(|c| ContainerSummary {
id: c.id,
name: c.name,
image_name: c.image_name,
current_version: c.current_version,
status: c.status,
last_seen: c.last_seen,
update_available: false, // TODO: Check if update is available
})
.collect(),
};
Json(ApiResponse::success(status))
}
async fn list_containers(
State(state): State<AppState>,
Query(params): Query<ListContainersQuery>,
) -> Result<Json<ApiResponse<Vec<ManagedContainer>>>, ApiError> {
let containers = {
let mgr = state.update_manager.lock().await;
let _containers = mgr.get_all_containers().await.unwrap_or_default();
drop(mgr);
_containers
};
let filtered_containers = if let Some(status_filter) = params.status {
containers
.into_iter()
.filter(|c| format!("{:?}", c.status).to_lowercase() == status_filter.to_lowercase())
.collect()
} else {
containers
};
Ok(Json(ApiResponse::success(filtered_containers)))
}
async fn get_container(
Path(id): Path<String>,
State(state): State<AppState>,
) -> Result<Json<ApiResponse<ManagedContainer>>, ApiError> {
let container = state.update_manager.lock().await.get_container(&id).await?;
Ok(Json(ApiResponse::success(container)))
}
async fn trigger_update(
Path(id): Path<String>,
State(state): State<AppState>,
) -> Result<Json<ApiResponse<UpdateTriggeredResponse>>, ApiError> {
// state
// .update_manager
// .lock()
// .await
// .check_container_for_updates(id.clone())
// .await?;
info!("request update ...");
{
UpdateManager::check_container_for_updates(state.update_manager.clone(), id.clone())
.await?;
}
let response = UpdateTriggeredResponse {
container_id: id.clone(),
message: "Update check triggered".to_string(),
};
Ok(Json(ApiResponse::success(response)))
}
async fn force_update(
Path(id): Path<String>,
State(state): State<AppState>,
Json(payload): Json<ForceUpdateRequest>,
) -> Result<Json<ApiResponse<UpdateTriggeredResponse>>, ApiError> {
// state
// .update_manager
// .lock()
// .await
// .force_update(id.clone(), payload.target_version)
// .await?;
{
// let mgr = state.update_manager.lock().await;
UpdateManager::force_update(
state.update_manager.clone(),
id.clone(),
payload.target_version,
)
.await?;
}
let response = UpdateTriggeredResponse {
container_id: id.clone(),
message: "Forced update queued".to_string(),
};
Ok(Json(ApiResponse::success(response)))
}
async fn force_discovery(
State(state): State<AppState>,
) -> Result<Json<ApiResponse<DiscoveryResponse>>, ApiError> {
state.discovery.force_discovery().await?;
let response = DiscoveryResponse {
message: "Container discovery scan completed".to_string(),
};
Ok(Json(ApiResponse::success(response)))
}
async fn force_rollback(
Path(id): Path<String>,
State(state): State<AppState>,
) -> Result<Json<ApiResponse<RollbackResponse>>, ApiError> {
// find images with format of <name>-backup-<timestamp>
{
let mgr = state.update_manager.lock().await;
let container = mgr.get_container(&id).await;
match container {
Ok(mut c) => {
mgr.attempt_rollback(&mut c).await?;
}
Err(err) => {
error!("Failed to get container: {}", err);
return Ok(Json(ApiResponse::error(err.to_string())));
}
}
}
let success = RollbackResponse {
message: "Rollback completed successfully".to_string(),
};
Ok(Json(ApiResponse::success(success)))
}
async fn get_discovered_containers(
State(state): State<AppState>,
) -> Result<Json<ApiResponse<Vec<String>>>, ApiError> {
let containers = state.discovery.get_discovered_containers().await?;
Ok(Json(ApiResponse::success(containers)))
}
async fn get_stats(
State(state): State<AppState>,
) -> Result<Json<ApiResponse<crate::types::StatsResponse>>, ApiError> {
let mut error_flag = false;
let stats = {
let mgr = state.update_manager.lock().await;
let _stats = mgr.get_update_stats().await;
drop(mgr);
match _stats {
Ok(stats) => stats,
Err(err) => {
error!("Failed to get update stats: {}", err);
error_flag = true;
StatsResponse::default()
}
}
};
Ok(Json(ApiResponse::success(stats)))
}
// Logs endpoint handler
async fn get_logs(
State(state): State<AppState>,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> Result<Json<ApiResponse<LogResponse>>, ApiError> {
let filters = LogFilters {
date: params.get("date").cloned(),
level: params.get("level").cloned(),
component: params.get("component").cloned(),
container: params.get("container").cloned(),
search: params.get("search").cloned(),
page: params
.get("page")
.and_then(|p| p.parse().ok())
.unwrap_or(1)
.max(1),
limit: params
.get("limit")
.and_then(|l| l.parse().ok())
.unwrap_or(100)
.min(1000) // Max 1000 logs per request
.max(1),
};
let logs = state.log_manager.get_logs(filters).await?;
Ok(Json(ApiResponse::success(logs)))
}
async fn get_log_dates(
State(state): State<AppState>,
) -> Result<Json<ApiResponse<LogDatesResponse>>, ApiError> {
let available_dates = state.log_manager.get_available_dates().await?;
let current_date = chrono::Utc::now().format("%Y-%m-%d").to_string();
let response = LogDatesResponse {
available_dates,
current_date,
};
Ok(Json(ApiResponse::success(response)))
}
async fn bulk_update_check(
State(state): State<AppState>,
Json(payload): Json<BulkUpdateRequest>,
) -> Result<Json<ApiResponse<BulkUpdateResponse>>, ApiError> {
let containers = if payload.container_ids.is_empty() {
state
.update_manager
.lock()
.await
.get_all_containers()
.await?
} else {
let mut selected = Vec::new();
for id in &payload.container_ids {
if let Ok(container) = state.update_manager.lock().await.get_container(id).await {
selected.push(container);
}
}
selected
};
let mut results = Vec::new();
for container in containers {
let status = {
let update_manager = state.update_manager.clone();
let container_id = container.container_id.clone();
async move { UpdateManager::check_container_for_updates(update_manager, container_id).await }
};
match status.await {
Ok(_) => {
results.push(BulkUpdateResult {
container_id: container.container_id,
container_name: container.name,
status: "triggered".to_string(),
error: None,
});
}
Err(e) => {
results.push(BulkUpdateResult {
container_id: container.container_id,
container_name: container.name,
status: "failed".to_string(),
error: Some(e.to_string()),
});
}
}
}
let response = BulkUpdateResponse { results };
Ok(Json(ApiResponse::success(response)))
}
// get object list
async fn list_objects(
State(state): State<AppState>,
Path(bucket): Path<String>,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> Result<Json<ApiResponse<serde_json::Value>>, ApiError> {
let mut list_request = state.s3_client.list_objects_v2().bucket(&bucket);
if let Some(prefix) = params.get("prefix") {
list_request = list_request.prefix(prefix);
}
let response: ListObjectsV2Output = list_request.send().await?;
if response.contents.is_some() {
let mapped = response
.contents()
.iter()
.map(|obj| {
let mut hashmap = HashMap::new();
hashmap.insert("Key".to_string(), obj.key().unwrap().to_string());
hashmap.insert("Size".to_string(), obj.size().unwrap().to_string());
hashmap.insert(
"LastModified".to_string(),
obj.last_modified().unwrap().to_string(),
);
hashmap.insert("ETag".to_string(), obj.e_tag().unwrap().to_string());
hashmap.insert(
"StorageClass".to_string(),
obj.storage_class().unwrap().to_string(),
);
hashmap.insert(
"Owner".to_string(),
obj.owner().unwrap().id().unwrap().to_string(),
);
hashmap
})
.collect::<Vec<HashMap<String, String>>>();
// let mut response = Response::builder()
// .status(StatusCode::OK)
// .body(Body::new(mapped))
// .unwrap();
Ok(Json(ApiResponse::success(serde_json::json!(mapped))))
} else {
Ok(Json(ApiResponse::success(serde_json::json!({}))))
}
}
// Request/Response types
#[derive(Debug, Deserialize)]
struct ListContainersQuery {
status: Option<String>,
}
#[derive(Debug, Deserialize)]
struct LogsQuery {
page: Option<u32>,
limit: Option<u32>,
level: Option<String>,
container: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ForceUpdateRequest {
target_version: Option<String>,
}
#[derive(Debug, Deserialize)]
struct BulkUpdateRequest {
container_ids: Vec<String>,
}
#[derive(Debug, Serialize)]
struct SystemStatusResponse {
service: String,
version: String,
managed_containers: usize,
discovered_containers: usize,
active_updates: usize,
update_queue_size: usize,
containers: Vec<ContainerSummary>,
}
#[derive(Debug, Serialize)]
struct ContainerSummary {
id: String,
name: String,
image_name: String,
current_version: String,
status: crate::types::ContainerStatus,
last_seen: chrono::DateTime<chrono::Utc>,
update_available: bool,
}
#[derive(Debug, Serialize)]
struct UpdateTriggeredResponse {
container_id: String,
message: String,
}
#[derive(Debug, Serialize)]
struct DiscoveryResponse {
message: String,
}
#[derive(Debug, Serialize)]
struct LogsResponse {
logs: Vec<LogEntry>,
total: u32,
page: u32,
limit: u32,
}
#[derive(Debug, Serialize)]
struct LogEntry {
timestamp: String,
level: String,
message: String,
container: Option<String>,
}
#[derive(Debug, Serialize)]
struct BulkUpdateResponse {
results: Vec<BulkUpdateResult>,
}
#[derive(Debug, Serialize)]
struct BulkUpdateResult {
container_id: String,
container_name: String,
status: String,
error: Option<String>,
}
#[derive(Debug, Serialize)]
struct RollbackResponse {
message: String,
}
// Error handling
#[derive(Debug)]
struct ApiError(anyhow::Error);
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
error!("API Error: {}", self.0);
let error_response = ApiResponse::<()>::error(self.0.to_string());
(StatusCode::INTERNAL_SERVER_ERROR, Json(error_response)).into_response()
}
}
impl<E> From<E> for ApiError
where
E: Into<anyhow::Error>,
{
fn from(err: E) -> Self {
Self(err.into())
}
}