diff --git a/src/node/nix.rs b/src/node/nix.rs index 18081a8..e294c6a 100644 --- a/src/node/nix.rs +++ b/src/node/nix.rs @@ -7,20 +7,23 @@ pub struct StackComposeInfo { pub services: Vec, } +const PROJECT_KEY: &str = "project\0name"; +const SERVICES_PREFIX: &str = "services\0"; + pub fn parse_arion_compose(file: &str) -> Result { let ast = rnix::Root::parse(file).ok()?; let expr = ast.expr().ok_or_else(|| anyhow!("invalid nix root"))?; let flattened = flatten_set(expr)?; - let project = flattened["project\0name"] - .clone() - .trim_matches('"') - .to_string(); + if !flattened.contains_key(PROJECT_KEY) { + return Err(anyhow!("missing required key 'project.name'")); + } + let project = flattened[PROJECT_KEY].clone().trim_matches('"').to_string(); let services = flattened .iter() .filter_map(|(key, _)| { - key.strip_prefix("services\0") + key.strip_prefix(SERVICES_PREFIX) .and_then(|key| key.split_once('\0')) .and_then(|k| Some(k.0.to_string())) }) diff --git a/src/route/container.rs b/src/route/container.rs deleted file mode 100644 index 78e9629..0000000 --- a/src/route/container.rs +++ /dev/null @@ -1,143 +0,0 @@ -use crate::{ - http::{ - accept::ExtractAccept, - response::{reply, HandlerResponse}, - }, - node::container::{get_info, kill, remove, restart, start, stop, ContainerInfo}, - route::AppError, - AppState, -}; -use askama::Template; -use askama_axum::IntoResponse; -use axum::{ - extract::{Path, State}, - response::{ - sse::{Event, KeepAlive}, - Redirect, Sse, - }, - routing::{get, post}, - Router, -}; -use bollard::container::LogsOptions; -use futures_util::stream::StreamExt; -use serde_json::json; -use std::convert::Infallible; - -#[derive(Template)] -#[template(path = "container/get-one.html")] -struct GetOneTemplate { - container_name: String, - info: ContainerInfo, -} - -async fn get_one( - Path(container_name): Path, - State(state): State, -) -> HandlerResponse { - let info = get_info(&state.docker, &container_name).await?; - - reply( - json!({ - "name": container_name, - "info": info, - }), - GetOneTemplate { - container_name, - info, - }, - ) -} - -async fn get_log( - Path(container_name): Path, - State(state): State, - 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:: { - 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::>() - .await - .join("\n") -} - -async fn get_log_stream(container_name: String, state: AppState) -> impl IntoResponse { - let stream = state - .docker - .logs( - &container_name, - Some(LogsOptions:: { - 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()) -} - -macro_rules! container_command { - ($cmd: ident) => { - move |Path(cont_name): Path, State(state): State| async move { - $cmd(&state.docker, &cont_name).await?; - Ok(Redirect::to("./")) as Result - } - }; -} - -pub(super) fn router() -> Router { - Router::new() - .route( - "/:container", - get(|Path(cont_name): Path| async move { - Redirect::permanent(format!("{}/", &cont_name).as_str()) - }), - ) - .route("/:container/", get(get_one)) - .route("/:container/log", get(get_log)) - .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, State(state): State| async move { - remove(&state.docker, &cont_name).await?; - Ok(Redirect::to("/")) as Result - }, - ), - ) -} diff --git a/src/route/container/log.rs b/src/route/container/log.rs new file mode 100644 index 0000000..e42b567 --- /dev/null +++ b/src/route/container/log.rs @@ -0,0 +1,73 @@ +use std::convert::Infallible; + +use askama_axum::IntoResponse; +use axum::{ + extract::{Path, State}, + response::sse::{Event, KeepAlive}, + response::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, + State(state): State, + 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:: { + 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::>() + .await + .join("\n") +} + +async fn get_log_stream(container_name: String, state: AppState) -> impl IntoResponse { + let stream = state + .docker + .logs( + &container_name, + Some(LogsOptions:: { + 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()) +} diff --git a/src/route/container/mod.rs b/src/route/container/mod.rs new file mode 100644 index 0000000..2cc4246 --- /dev/null +++ b/src/route/container/mod.rs @@ -0,0 +1,48 @@ +use crate::{ + node::container::{kill, remove, restart, start, stop}, + route::AppError, + 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, State(state): State| async move { + $cmd(&state.docker, &cont_name).await?; + Ok(Redirect::to("./")) as Result + } + }; +} + +pub(super) fn router() -> Router { + Router::new() + .route( + "/:container", + get(|Path(cont_name): Path| 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, State(state): State| async move { + remove(&state.docker, &cont_name).await?; + Ok(Redirect::to("/")) as Result + }, + ), + ) +} diff --git a/src/route/container/read.rs b/src/route/container/read.rs new file mode 100644 index 0000000..a458733 --- /dev/null +++ b/src/route/container/read.rs @@ -0,0 +1,34 @@ +use askama::Template; +use axum::extract::{Path, State}; +use serde_json::json; + +use crate::{ + http::response::{reply, HandlerResponse}, + node::container::{get_info, ContainerInfo}, + AppState, +}; + +#[derive(Template)] +#[template(path = "container/get-one.html")] +struct GetOneTemplate { + container_name: String, + info: ContainerInfo, +} + +pub(super) async fn get_one( + Path(container_name): Path, + State(state): State, +) -> HandlerResponse { + let info = get_info(&state.docker, &container_name).await?; + + reply( + json!({ + "name": container_name, + "info": info, + }), + GetOneTemplate { + container_name, + info, + }, + ) +} diff --git a/src/route/stack.rs b/src/route/stack.rs deleted file mode 100644 index 51dcb48..0000000 --- a/src/route/stack.rs +++ /dev/null @@ -1,198 +0,0 @@ -use crate::{ - http::{ - error::AppError, - response::{reply, HandlerResponse}, - }, - node::{ - container::ContainerInfo, - nix::parse_arion_compose, - stack::{ - check_compose, command, commit_compose, create_new, get_compose, get_containers, - remove, write_compose, StackCommand, - }, - }, - AppState, -}; -use askama::Template; -use askama_axum::IntoResponse; -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::Redirect, - routing::{get, post}, - Form, Router, -}; -use futures_util::join; -use serde::Deserialize; -use serde_json::json; - -#[derive(Template)] -#[template(path = "stack/get-one.html")] -struct GetOneTemplate { - stack_folder: String, - stack_name: String, - file_contents: String, - containers: Vec, -} - -#[derive(Template)] -#[template(path = "stack/new-form.html")] -struct CreateTemplate {} - -#[derive(Template)] -#[template(path = "stack/delete-one.html")] -struct ConfirmDeleteTemplate { - stack_name: String, -} - -async fn get_one(Path(stack_name): Path, State(state): State) -> HandlerResponse { - let file_contents = get_compose(&state.stack_dir, &stack_name).await?; - let info = parse_arion_compose(&file_contents)?; - let containers = get_containers(&state.docker, &info.project).await?; - - reply( - json!({ - "folder": stack_name, - "name": info.project, - "file": file_contents, - "containers": containers, - }), - GetOneTemplate { - stack_folder: stack_name, - stack_name: info.project, - file_contents, - containers: containers.iter().map(|c| c.clone().into()).collect(), - }, - ) -} - -async fn new_stack_page() -> impl IntoResponse { - CreateTemplate {} -} - -#[derive(Deserialize)] -struct CreateStackForm { - name: String, - source: String, -} - -async fn create_stack( - State(state): State, - Form(form): Form, -) -> Result { - // Make sure body is is ok - check_compose(&state.arion_bin, &form.source).await?; - - create_new(state.repository, &form.name, &form.source).await?; - - Ok(Redirect::to(format!("/stack/{}/", form.name).as_str())) -} - -#[derive(Deserialize)] -struct EditStackForm { - source: String, - commit_message: String, -} - -async fn edit_stack( - Path(stack_name): Path, - State(state): State, - Form(form): Form, -) -> Result { - let commit_message = if form.commit_message.trim().is_empty() { - format!("Update {}", stack_name) - } else { - form.commit_message - }; - - if form.source.trim().is_empty() { - return Err(AppError::Client { - status: StatusCode::BAD_REQUEST, - code: "invalid-source", - message: "provided stack source is empty".to_string(), - }); - }; - - // Cleanup source (like line endings) - let source = form.source.replace("\r\n", "\n"); - - // Make sure file is ok - check_compose(&state.arion_bin, &source).await?; - - // Write compose file - write_compose(&state.stack_dir, &stack_name, &source).await?; - - // Git commit - commit_compose(state.repository, &stack_name, &commit_message)?; - - // Update stack - command( - &state.stack_dir, - &stack_name, - &state.arion_bin, - StackCommand::Start, - ) - .await?; - - Ok(Redirect::to("./")) -} - -async fn check_stack_file( - State(state): State, - body: String, -) -> Result { - check_compose(&state.arion_bin, &body).await?; - Ok(StatusCode::NO_CONTENT) -} - -async fn confirm_deletion_page(Path(stack_name): Path) -> impl IntoResponse { - ConfirmDeleteTemplate { stack_name } -} - -async fn delete_stack( - Path(stack_name): Path, - State(state): State, -) -> Result { - remove( - &state.stack_dir, - &state.arion_bin, - state.repository, - &stack_name, - ) - .await?; - Ok(Redirect::to("/")) -} - -macro_rules! stack_command { - ($cmd: expr) => { - move |Path(stack_name): Path, State(state): State| async move { - command(&state.stack_dir, &stack_name, &state.arion_bin, $cmd).await?; - Ok(Redirect::to("./")) as Result - } - }; -} - -pub(super) fn router() -> Router { - Router::new() - .route("/_/new", get(new_stack_page).post(create_stack)) - .route("/_/check", post(check_stack_file)) - .route( - "/:stack", - get(|Path(stack_name): Path| async move { - Redirect::permanent(format!("{}/", &stack_name).as_str()) - }), - ) - .route("/:stack/", get(get_one)) - .route("/:stack/start", post(stack_command!(StackCommand::Start))) - .route( - "/:stack/restart", - post(stack_command!(StackCommand::Restart)), - ) - .route("/:stack/stop", post(stack_command!(StackCommand::Stop))) - .route("/:stack/down", post(stack_command!(StackCommand::Down))) - .route("/:stack/edit", post(edit_stack)) - .route( - "/:stack/delete", - get(confirm_deletion_page).post(delete_stack), - ) -} diff --git a/src/route/stack/check.rs b/src/route/stack/check.rs new file mode 100644 index 0000000..dad670f --- /dev/null +++ b/src/route/stack/check.rs @@ -0,0 +1,11 @@ +use axum::{extract::State, http::StatusCode}; + +use crate::{http::error::AppError, node::stack::check_compose, AppState}; + +pub(super) async fn check_stack_file( + State(state): State, + body: String, +) -> Result { + check_compose(&state.arion_bin, &body).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/route/stack/create.rs b/src/route/stack/create.rs new file mode 100644 index 0000000..308cef3 --- /dev/null +++ b/src/route/stack/create.rs @@ -0,0 +1,35 @@ +use askama::Template; +use askama_axum::IntoResponse; +use axum::{extract::State, response::Redirect, Form}; +use serde::Deserialize; + +use crate::{ + http::error::AppError, + node::stack::{check_compose, create_new}, + AppState, +}; + +#[derive(Template)] +#[template(path = "stack/new-form.html")] +struct CreateTemplate {} + +#[derive(Deserialize)] +pub(super) struct CreateStackForm { + source: String, +} + +pub(super) async fn create_stack( + State(state): State, + Form(form): Form, +) -> Result { + // Make sure body is is ok + let name = check_compose(&state.arion_bin, &form.source).await?; + + create_new(state.repository, &name, &form.source).await?; + + Ok(Redirect::to(format!("/stack/{}/", name).as_str())) +} + +pub(super) async fn new_stack_page() -> impl IntoResponse { + CreateTemplate {} +} diff --git a/src/route/stack/delete.rs b/src/route/stack/delete.rs new file mode 100644 index 0000000..620cc60 --- /dev/null +++ b/src/route/stack/delete.rs @@ -0,0 +1,32 @@ +use askama::Template; +use askama_axum::IntoResponse; +use axum::{ + extract::{Path, State}, + response::Redirect, +}; + +use crate::{http::error::AppError, node::stack::remove, AppState}; + +#[derive(Template)] +#[template(path = "stack/delete-one.html")] +struct ConfirmDeleteTemplate { + stack_name: String, +} + +pub(super) async fn confirm_deletion_page(Path(stack_name): Path) -> impl IntoResponse { + ConfirmDeleteTemplate { stack_name } +} + +pub(super) async fn delete_stack( + Path(stack_name): Path, + State(state): State, +) -> Result { + remove( + &state.stack_dir, + &state.arion_bin, + state.repository, + &stack_name, + ) + .await?; + Ok(Redirect::to("/")) +} diff --git a/src/route/stack/edit.rs b/src/route/stack/edit.rs new file mode 100644 index 0000000..fe5282a --- /dev/null +++ b/src/route/stack/edit.rs @@ -0,0 +1,62 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::Redirect, + Form, +}; +use serde::Deserialize; + +use crate::{ + http::error::AppError, + node::stack::{check_compose, command, commit_compose, write_compose, StackCommand}, + AppState, +}; + +#[derive(Deserialize)] +pub(super) struct EditStackForm { + source: String, + commit_message: String, +} + +pub(super) async fn edit_stack( + Path(stack_name): Path, + State(state): State, + Form(form): Form, +) -> Result { + let commit_message = if form.commit_message.trim().is_empty() { + format!("Update {}", stack_name) + } else { + form.commit_message + }; + + if form.source.trim().is_empty() { + return Err(AppError::Client { + status: StatusCode::BAD_REQUEST, + code: "invalid-source", + message: "provided stack source is empty".to_string(), + }); + }; + + // Cleanup source (like line endings) + let source = form.source.replace("\r\n", "\n"); + + // Make sure file is ok + check_compose(&state.arion_bin, &source).await?; + + // Write compose file + write_compose(&state.stack_dir, &stack_name, &source).await?; + + // Git commit + commit_compose(state.repository, &stack_name, &commit_message)?; + + // Update stack + command( + &state.stack_dir, + &stack_name, + &state.arion_bin, + StackCommand::Start, + ) + .await?; + + Ok(Redirect::to("./")) +} diff --git a/src/route/stack/mod.rs b/src/route/stack/mod.rs new file mode 100644 index 0000000..39bf3a1 --- /dev/null +++ b/src/route/stack/mod.rs @@ -0,0 +1,54 @@ +use axum::{ + extract::{Path, State}, + response::Redirect, + routing::post, + Router, +}; + +use crate::{http::error::AppError, node::stack::command, node::stack::StackCommand, AppState}; + +use axum::routing::get; + +mod check; +mod create; +mod delete; +mod edit; +mod read; + +macro_rules! stack_command { + ($cmd: expr) => { + move |Path(stack_name): Path, State(state): State| async move { + command(&state.stack_dir, &stack_name, &state.arion_bin, $cmd).await?; + Ok(Redirect::to("./")) as Result + } + }; +} + +pub(super) fn router() -> Router { + Router::new() + .route( + "/_/new", + get(create::new_stack_page).post(create::create_stack), + ) + .route("/_/check", post(check::check_stack_file)) + .route( + "/:stack", + get(|Path(stack_name): Path| async move { + Redirect::permanent(format!("{}/", &stack_name).as_str()) + }), + ) + .route("/:stack/", get(read::get_one)) + .route("/:stack/start", post(stack_command!(StackCommand::Start))) + .route( + "/:stack/restart", + post(stack_command!(StackCommand::Restart)), + ) + .route("/:stack/stop", post(stack_command!(StackCommand::Stop))) + .route("/:stack/down", post(stack_command!(StackCommand::Down))) + .route("/:stack/edit", post(edit::edit_stack)) + .route( + "/:stack/delete", + get(delete::confirm_deletion_page).post(delete::delete_stack), + ) + //.route("/:stack/history", get(get_stack_history)) +} diff --git a/src/route/stack/read.rs b/src/route/stack/read.rs new file mode 100644 index 0000000..63c0a28 --- /dev/null +++ b/src/route/stack/read.rs @@ -0,0 +1,46 @@ +use askama::Template; +use axum::extract::{Path, State}; +use serde_json::json; + +use crate::{ + http::response::{reply, HandlerResponse}, + node::{ + container::ContainerInfo, + nix::parse_arion_compose, + stack::{get_compose, get_containers}, + }, + AppState, +}; + +#[derive(Template)] +#[template(path = "stack/get-one.html")] +struct GetOneTemplate { + stack_folder: String, + stack_name: String, + file_contents: String, + containers: Vec, +} + +pub(super) async fn get_one( + Path(stack_name): Path, + State(state): State, +) -> HandlerResponse { + let file_contents = get_compose(&state.stack_dir, &stack_name).await?; + let info = parse_arion_compose(&file_contents)?; + let containers = get_containers(&state.docker, &info.project).await?; + + reply( + json!({ + "folder": stack_name, + "name": info.project, + "file": file_contents, + "containers": containers, + }), + GetOneTemplate { + stack_folder: stack_name, + stack_name: info.project, + file_contents, + containers: containers.iter().map(|c| c.clone().into()).collect(), + }, + ) +} diff --git a/static/css/screen.css b/static/css/screen.css index 22cc3b8..f369f5a 100644 --- a/static/css/screen.css +++ b/static/css/screen.css @@ -138,4 +138,19 @@ textarea { pre { margin: 0; padding: 0; +} + +h2 { + display: flex; + align-items: center; + gap: 0.5ch; + + & form { + display: flex; + align-items: center; + + & button { + padding: 2px 6px; + } + } } \ No newline at end of file diff --git a/templates/container/get-one.html b/templates/container/get-one.html index b541edf..014d215 100644 --- a/templates/container/get-one.html +++ b/templates/container/get-one.html @@ -87,7 +87,6 @@ } h2 { - text-transform: uppercase; margin: 0; padding: 0; margin-bottom: 1rem; diff --git a/templates/home.html b/templates/home.html index be15733..5bbdf7e 100644 --- a/templates/home.html +++ b/templates/home.html @@ -73,7 +73,9 @@
-

Stacks
+

+ Stacks +

@@ -135,21 +137,6 @@