copy methods over from old staxman

This commit is contained in:
Hamcha 2024-04-14 17:21:57 +02:00
parent a168b43fec
commit 915b680070
Signed by: hamcha
GPG Key ID: 1669C533B8CF6D89
13 changed files with 459 additions and 2 deletions

1
Cargo.lock generated
View File

@ -1092,6 +1092,7 @@ dependencies = [
"bollard",
"clap",
"dotenvy",
"futures-util",
"git2",
"serde",
"serde_json",

View File

@ -20,3 +20,4 @@ tracing = "0.1"
tracing-subscriber = "0.3"
thiserror = "1"
sysinfo = "0.30"
futures-util = "0.3"

53
src/http/accept.rs Normal file
View File

@ -0,0 +1,53 @@
use axum::{
async_trait,
extract::FromRequestParts,
http::{
header::{HeaderValue, ACCEPT},
request::Parts,
StatusCode,
},
};
/// Extractor for the Accept header
pub struct ExtractAccept(pub HeaderValue);
#[async_trait]
impl<S> FromRequestParts<S> for ExtractAccept
where
S: Send + Sync,
{
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
if let Some(accept) = parts.headers.get(ACCEPT) {
Ok(ExtractAccept(accept.clone()))
} else {
Err((StatusCode::NOT_ACCEPTABLE, "`Accept` header is missing"))
}
}
}
#[cfg(test)]
mod tests {
use axum::{
extract::FromRequest,
http::{HeaderValue, Request},
};
use super::*;
#[tokio::test]
async fn test_accept() {
let req = Request::builder()
.header(ACCEPT, "application/json; charset=utf-8")
.body(axum::body::Body::empty())
.unwrap();
let extract = ExtractAccept::from_request(req, &()).await.unwrap();
assert_eq!(
extract.0,
HeaderValue::from_static("application/json; charset=utf-8")
);
}
}

105
src/http/error.rs Normal file
View File

@ -0,0 +1,105 @@
use axum::{
extract::rejection::JsonRejection,
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use thiserror::Error;
/// Result type for HTTP requests
pub type Result<T> = core::result::Result<T, AppError>;
/// Error type that can be returned from HTTP requests
#[derive(Error, Debug)]
pub enum AppError {
/// Client error (e.g. bad input, not found)
#[error("client error: <{code}> {message}")]
Client {
status: StatusCode,
code: &'static str,
message: String,
},
/// Server error caused by Docker API errors
#[error("docker error: {0}")]
DockerError(#[from] bollard::errors::Error),
/// Server error caused by file operation errors
#[error("file error: {0}")]
FileError(#[from] std::io::Error),
/// Server error caused by unexpected internal errors
#[error("unexpected internal error: {0}")]
Internal(#[from] anyhow::Error),
/// Errors caused by JSON parsing errors (both incoming and outgoing)
#[error("incoming JSON format error: {0}")]
JSONFormat(#[from] JsonRejection),
}
pub(super) struct ErrorInfo {
pub(super) status: StatusCode,
pub(super) code: String,
pub(super) message: String,
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let info = match self {
AppError::Internal(err) => ErrorInfo {
status: StatusCode::INTERNAL_SERVER_ERROR,
code: "server-error".to_string(),
message: err.to_string(),
},
AppError::FileError(err) => ErrorInfo {
status: StatusCode::INTERNAL_SERVER_ERROR,
code: "file-error".to_string(),
message: err.to_string(),
},
AppError::Client {
status,
code,
message,
} => ErrorInfo {
status,
code: code.to_string(),
message: message.to_string(),
},
AppError::DockerError(err) => match err {
bollard::errors::Error::DockerResponseServerError {
status_code,
message,
} => ErrorInfo {
status: StatusCode::from_u16(status_code).unwrap(),
code: "docker-error".to_string(),
message,
},
_ => ErrorInfo {
status: StatusCode::INTERNAL_SERVER_ERROR,
code: "docker-error".to_string(),
message: err.to_string(),
},
},
AppError::JSONFormat(rejection) => {
let status = match rejection {
JsonRejection::JsonDataError(_) => StatusCode::UNPROCESSABLE_ENTITY,
JsonRejection::JsonSyntaxError(_) => StatusCode::BAD_REQUEST,
JsonRejection::MissingJsonContentType(_) => StatusCode::UNSUPPORTED_MEDIA_TYPE,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
ErrorInfo {
status,
code: "invalid-body".to_string(),
message: rejection.body_text().clone(),
}
}
};
(
info.status,
Json(json!({"code": info.code, "message": info.message})),
)
.into_response()
}
}

2
src/http/mod.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod accept;
pub mod error;

View File

@ -4,6 +4,7 @@ use clap::{arg, Parser};
use std::{net::SocketAddr, path::PathBuf};
use tokio::{fs, net::TcpListener};
mod http;
mod node;
mod route;

137
src/node/container.rs Normal file
View File

@ -0,0 +1,137 @@
use crate::http::error::Result;
use bollard::{
container::{
InspectContainerOptions, KillContainerOptions, ListContainersOptions,
RemoveContainerOptions, RestartContainerOptions, StartContainerOptions,
StopContainerOptions,
},
service::{ContainerInspectResponse, ContainerSummary, MountPoint},
Docker,
};
use serde::Serialize;
use std::collections::HashMap;
use time::OffsetDateTime;
#[derive(Serialize)]
pub struct ContainerInfo {
pub id: String,
pub name: String,
pub state: String,
pub image: String,
pub image_id: String,
pub created_at: String,
pub volumes: Option<Vec<MountPoint>>,
pub env: Option<Vec<String>>,
pub labels: Option<HashMap<String, String>>,
}
impl ContainerInfo {
pub fn stack(&self) -> Option<String> {
self.labels
.clone()
.and_then(|lab| lab.get("com.docker.compose.project").cloned())
}
pub fn running(&self) -> bool {
self.state == "running"
}
}
impl From<ContainerSummary> for ContainerInfo {
fn from(value: ContainerSummary) -> Self {
let created = OffsetDateTime::from_unix_timestamp(value.created.unwrap())
.unwrap_or(OffsetDateTime::UNIX_EPOCH);
ContainerInfo {
id: value.id.unwrap_or_default(),
name: value.names.unwrap_or_default()[0]
.trim_start_matches('/')
.to_string(),
state: value.state.unwrap_or_default(),
image: value.image.unwrap_or_default(),
image_id: value.image_id.unwrap_or_default(),
created_at: created.time().to_string(),
volumes: value.mounts,
labels: value.labels,
env: None,
}
}
}
impl From<ContainerInspectResponse> for ContainerInfo {
fn from(value: ContainerInspectResponse) -> Self {
let config = value.config.unwrap_or_default();
ContainerInfo {
id: value.id.unwrap_or_default(),
name: value.name.unwrap_or_default(),
state: value
.state
.and_then(|s| s.status)
.unwrap_or_else(|| bollard::service::ContainerStateStatusEnum::EMPTY)
.to_string(),
image: config.image.unwrap_or_default(),
image_id: value.image.unwrap_or_default(),
created_at: value.created.unwrap_or_default(),
volumes: value.mounts,
labels: config.labels,
env: config.env,
}
}
}
pub async fn get_info(docker: &Docker, name: &str) -> Result<ContainerInfo> {
let info = docker
.inspect_container(name, Some(InspectContainerOptions { size: true }))
.await?;
Ok(info.into())
}
pub async fn start(docker: &Docker, name: &str) -> Result<()> {
Ok(docker
.start_container(&name, None::<StartContainerOptions<String>>)
.await?)
}
pub async fn restart(docker: &Docker, name: &str) -> Result<()> {
Ok(docker
.restart_container(&name, Some(RestartContainerOptions { t: 30 }))
.await?)
}
pub async fn stop(docker: &Docker, name: &str) -> Result<()> {
Ok(docker
.stop_container(&name, Some(StopContainerOptions { t: 30 }))
.await?)
}
pub async fn kill(docker: &Docker, name: &str) -> Result<()> {
Ok(docker
.kill_container(&name, None::<KillContainerOptions<String>>)
.await?)
}
pub async fn remove(docker: &Docker, name: &str) -> Result<()> {
Ok(docker
.remove_container(
&name,
Some(RemoveContainerOptions {
v: false,
force: true,
link: false,
}),
)
.await?)
}
pub async fn list(docker: &Docker) -> Result<Vec<ContainerInfo>> {
Ok(docker
.list_containers(Some(ListContainersOptions::<String> {
all: true,
limit: None,
..Default::default()
}))
.await?
.iter()
.map(|c| c.clone().into())
.collect())
}

View File

@ -1 +1,2 @@
pub mod container;
pub mod system;

View File

@ -35,6 +35,7 @@ pub struct SystemInfo {
pub disks: Vec<DiskInfo>,
// CPU
pub cpu_count: usize,
pub load_average: (f64, f64, f64),
}
@ -54,6 +55,7 @@ pub fn system_info() -> SystemInfo {
})
.collect();
let cpus = sys.cpus();
let avg = sysinfo::System::load_average();
let disks = Disks::new_with_refreshed_list()
@ -72,6 +74,7 @@ pub fn system_info() -> SystemInfo {
host_name: sysinfo::System::host_name().unwrap_or_default(),
os_version: sysinfo::System::os_version().unwrap_or_default(),
kernel_version: sysinfo::System::kernel_version().unwrap_or_default(),
cpu_count: cpus.len(),
total_memory: sys.total_memory(),
used_memory: sys.used_memory(),
total_swap: sys.total_swap(),

View File

@ -0,0 +1,74 @@
use std::convert::Infallible;
use axum::{
extract::{Path, State},
response::{
sse::{Event, KeepAlive},
IntoResponse, Sse,
},
};
use bollard::container::LogsOptions;
use futures_util::StreamExt;
use serde_json::json;
use crate::{http::accept::ExtractAccept, AppState};
pub(super) async fn get(
Path(container_name): Path<String>,
State(state): State<AppState>,
ExtractAccept(accept): ExtractAccept,
) -> impl IntoResponse {
match accept.to_str() {
Ok("text/event-stream") => get_log_stream(container_name, state).await.into_response(),
_ => get_log_string(container_name, state).await.into_response(),
}
}
async fn get_log_string(container_name: String, state: AppState) -> impl IntoResponse {
state
.docker
.logs(
&container_name,
Some(LogsOptions::<String> {
follow: false,
stdout: true,
stderr: true,
timestamps: true,
..Default::default()
}),
)
.map(|ev| match ev {
Ok(output) => output.to_string(),
Err(error) => error.to_string(),
})
.collect::<Vec<_>>()
.await
.join("\n")
}
async fn get_log_stream(container_name: String, state: AppState) -> impl IntoResponse {
let stream = state
.docker
.logs(
&container_name,
Some(LogsOptions::<String> {
follow: true,
stdout: true,
stderr: true,
timestamps: true,
..Default::default()
}),
)
.map(|msg| match msg {
Ok(output) => Ok::<_, Infallible>(
Event::default()
.json_data(json!({ "lines": output.to_string() }))
.unwrap(),
),
Err(error) => Ok(Event::default()
.json_data(json!({ "error": error.to_string() }))
.unwrap()),
});
Sse::new(stream).keep_alive(KeepAlive::default())
}

View File

@ -0,0 +1,49 @@
use crate::{
http::error::Result,
node::container::{kill, remove, restart, start, stop},
AppState,
};
use axum::{
extract::{Path, State},
response::Redirect,
routing::{get, post},
Router,
};
mod log;
mod read;
macro_rules! container_command {
($cmd: ident) => {
move |Path(cont_name): Path<String>, State(state): State<AppState>| async move {
$cmd(&state.docker, &cont_name).await?;
Ok(Redirect::to("./")) as Result<Redirect>
}
};
}
pub(super) fn router() -> Router<AppState> {
Router::new()
.route("/", get(read::get_all))
.route(
"/:container",
get(|Path(cont_name): Path<String>| async move {
Redirect::permanent(format!("{}/", &cont_name).as_str())
}),
)
.route("/:container/", get(read::get_one))
.route("/:container/log", get(log::get))
.route("/:container/start", post(container_command!(start)))
.route("/:container/stop", post(container_command!(stop)))
.route("/:container/restart", post(container_command!(restart)))
.route("/:container/kill", post(container_command!(kill)))
.route(
"/:container/remove",
post(
move |Path(cont_name): Path<String>, State(state): State<AppState>| async move {
remove(&state.docker, &cont_name).await?;
Ok(Redirect::to("/")) as Result<Redirect>
},
),
)
}

View File

@ -0,0 +1,27 @@
use crate::http::error::Result;
use crate::node::container::list;
use crate::{node::container::get_info, AppState};
use axum::{
extract::{Path, State},
response::IntoResponse,
Json,
};
use serde_json::json;
pub(super) async fn get_one(
Path(container_name): Path<String>,
State(state): State<AppState>,
) -> Result<impl IntoResponse> {
let info = get_info(&state.docker, &container_name).await?;
Ok(Json(json!({
"name": container_name,
"info": info,
})))
}
pub(super) async fn get_all(State(state): State<AppState>) -> Result<impl IntoResponse> {
let containers = list(&state.docker).await?;
Ok(Json(json!(containers)))
}

View File

@ -1,11 +1,14 @@
use crate::{node::system::system_info, AppState};
use axum::{response::IntoResponse, routing::get, Json, Router};
use crate::{node::system::system_info, AppState};
mod container;
async fn get_sys_info() -> impl IntoResponse {
Json(system_info())
}
pub(crate) fn router() -> Router<AppState> {
Router::new().route("/system-info", get(get_sys_info))
Router::new()
.route("/system-info", get(get_sys_info))
.nest("/containers", container::router())
}