reorganize code
This commit is contained in:
parent
e3fecbee95
commit
d7dd7ca7be
16 changed files with 450 additions and 373 deletions
|
@ -7,20 +7,23 @@ pub struct StackComposeInfo {
|
||||||
pub services: Vec<String>,
|
pub services: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PROJECT_KEY: &str = "project\0name";
|
||||||
|
const SERVICES_PREFIX: &str = "services\0";
|
||||||
|
|
||||||
pub fn parse_arion_compose(file: &str) -> Result<StackComposeInfo> {
|
pub fn parse_arion_compose(file: &str) -> Result<StackComposeInfo> {
|
||||||
let ast = rnix::Root::parse(file).ok()?;
|
let ast = rnix::Root::parse(file).ok()?;
|
||||||
let expr = ast.expr().ok_or_else(|| anyhow!("invalid nix root"))?;
|
let expr = ast.expr().ok_or_else(|| anyhow!("invalid nix root"))?;
|
||||||
let flattened = flatten_set(expr)?;
|
let flattened = flatten_set(expr)?;
|
||||||
|
|
||||||
let project = flattened["project\0name"]
|
if !flattened.contains_key(PROJECT_KEY) {
|
||||||
.clone()
|
return Err(anyhow!("missing required key 'project.name'"));
|
||||||
.trim_matches('"')
|
}
|
||||||
.to_string();
|
let project = flattened[PROJECT_KEY].clone().trim_matches('"').to_string();
|
||||||
|
|
||||||
let services = flattened
|
let services = flattened
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|(key, _)| {
|
.filter_map(|(key, _)| {
|
||||||
key.strip_prefix("services\0")
|
key.strip_prefix(SERVICES_PREFIX)
|
||||||
.and_then(|key| key.split_once('\0'))
|
.and_then(|key| key.split_once('\0'))
|
||||||
.and_then(|k| Some(k.0.to_string()))
|
.and_then(|k| Some(k.0.to_string()))
|
||||||
})
|
})
|
||||||
|
|
|
@ -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<String>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> 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<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())
|
|
||||||
}
|
|
||||||
|
|
||||||
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, AppError>
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn router() -> Router<AppState> {
|
|
||||||
Router::new()
|
|
||||||
.route(
|
|
||||||
"/:container",
|
|
||||||
get(|Path(cont_name): Path<String>| 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<String>, State(state): State<AppState>| async move {
|
|
||||||
remove(&state.docker, &cont_name).await?;
|
|
||||||
Ok(Redirect::to("/")) as Result<Redirect, AppError>
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
73
src/route/container/log.rs
Normal file
73
src/route/container/log.rs
Normal file
|
@ -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<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())
|
||||||
|
}
|
48
src/route/container/mod.rs
Normal file
48
src/route/container/mod.rs
Normal file
|
@ -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<String>, State(state): State<AppState>| async move {
|
||||||
|
$cmd(&state.docker, &cont_name).await?;
|
||||||
|
Ok(Redirect::to("./")) as Result<Redirect, AppError>
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.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, AppError>
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
34
src/route/container/read.rs
Normal file
34
src/route/container/read.rs
Normal file
|
@ -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<String>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> HandlerResponse {
|
||||||
|
let info = get_info(&state.docker, &container_name).await?;
|
||||||
|
|
||||||
|
reply(
|
||||||
|
json!({
|
||||||
|
"name": container_name,
|
||||||
|
"info": info,
|
||||||
|
}),
|
||||||
|
GetOneTemplate {
|
||||||
|
container_name,
|
||||||
|
info,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
|
@ -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<ContainerInfo>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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<String>, State(state): State<AppState>) -> 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<AppState>,
|
|
||||||
Form(form): Form<CreateStackForm>,
|
|
||||||
) -> Result<Redirect, AppError> {
|
|
||||||
// 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<String>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Form(form): Form<EditStackForm>,
|
|
||||||
) -> Result<Redirect, AppError> {
|
|
||||||
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<AppState>,
|
|
||||||
body: String,
|
|
||||||
) -> Result<StatusCode, AppError> {
|
|
||||||
check_compose(&state.arion_bin, &body).await?;
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn confirm_deletion_page(Path(stack_name): Path<String>) -> impl IntoResponse {
|
|
||||||
ConfirmDeleteTemplate { stack_name }
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete_stack(
|
|
||||||
Path(stack_name): Path<String>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> Result<Redirect, AppError> {
|
|
||||||
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<String>, State(state): State<AppState>| async move {
|
|
||||||
command(&state.stack_dir, &stack_name, &state.arion_bin, $cmd).await?;
|
|
||||||
Ok(Redirect::to("./")) as Result<Redirect, AppError>
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn router() -> Router<AppState> {
|
|
||||||
Router::new()
|
|
||||||
.route("/_/new", get(new_stack_page).post(create_stack))
|
|
||||||
.route("/_/check", post(check_stack_file))
|
|
||||||
.route(
|
|
||||||
"/:stack",
|
|
||||||
get(|Path(stack_name): Path<String>| 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),
|
|
||||||
)
|
|
||||||
}
|
|
11
src/route/stack/check.rs
Normal file
11
src/route/stack/check.rs
Normal file
|
@ -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<AppState>,
|
||||||
|
body: String,
|
||||||
|
) -> Result<StatusCode, AppError> {
|
||||||
|
check_compose(&state.arion_bin, &body).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
35
src/route/stack/create.rs
Normal file
35
src/route/stack/create.rs
Normal file
|
@ -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<AppState>,
|
||||||
|
Form(form): Form<CreateStackForm>,
|
||||||
|
) -> Result<Redirect, AppError> {
|
||||||
|
// 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 {}
|
||||||
|
}
|
32
src/route/stack/delete.rs
Normal file
32
src/route/stack/delete.rs
Normal file
|
@ -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<String>) -> impl IntoResponse {
|
||||||
|
ConfirmDeleteTemplate { stack_name }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn delete_stack(
|
||||||
|
Path(stack_name): Path<String>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Redirect, AppError> {
|
||||||
|
remove(
|
||||||
|
&state.stack_dir,
|
||||||
|
&state.arion_bin,
|
||||||
|
state.repository,
|
||||||
|
&stack_name,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Redirect::to("/"))
|
||||||
|
}
|
62
src/route/stack/edit.rs
Normal file
62
src/route/stack/edit.rs
Normal file
|
@ -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<String>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Form(form): Form<EditStackForm>,
|
||||||
|
) -> Result<Redirect, AppError> {
|
||||||
|
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("./"))
|
||||||
|
}
|
54
src/route/stack/mod.rs
Normal file
54
src/route/stack/mod.rs
Normal file
|
@ -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<String>, State(state): State<AppState>| async move {
|
||||||
|
command(&state.stack_dir, &stack_name, &state.arion_bin, $cmd).await?;
|
||||||
|
Ok(Redirect::to("./")) as Result<Redirect, AppError>
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn router() -> Router<AppState> {
|
||||||
|
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<String>| 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))
|
||||||
|
}
|
46
src/route/stack/read.rs
Normal file
46
src/route/stack/read.rs
Normal file
|
@ -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<ContainerInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn get_one(
|
||||||
|
Path(stack_name): Path<String>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> 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(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
|
@ -138,4 +138,19 @@ textarea {
|
||||||
pre {
|
pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5ch;
|
||||||
|
|
||||||
|
& form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
& button {
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -87,7 +87,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
text-transform: uppercase;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
|
|
@ -73,7 +73,9 @@
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
<section class="stack-list">
|
<section class="stack-list">
|
||||||
<h2>Stacks <form action="/stack/_/new"><button>+</button></form>
|
<h2>
|
||||||
|
Stacks
|
||||||
|
<form action="/stack/_/new"><button>+</button></form>
|
||||||
</h2>
|
</h2>
|
||||||
<table class="table full-width">
|
<table class="table full-width">
|
||||||
<thead>
|
<thead>
|
||||||
|
@ -135,21 +137,6 @@
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
h2 {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5ch;
|
|
||||||
|
|
||||||
& form {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
& button {
|
|
||||||
padding: 2px 6px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.containers {
|
.containers {
|
||||||
& .status {
|
& .status {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
|
@ -7,11 +7,31 @@
|
||||||
<header>
|
<header>
|
||||||
<h1>Stack details for <span class="stack-name">{{stack_name}}</span></h1>
|
<h1>Stack details for <span class="stack-name">{{stack_name}}</span></h1>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<form action="./start" method="POST"><button type="submit">Start</button></form>
|
<form action="./start" method="POST">
|
||||||
<form action="./restart" method="POST"><button type="submit">Restart</button></form>
|
<button title="Start all containers" type="submit">
|
||||||
<form action="./stop" method="POST"><button type="submit">Stop</button></form>
|
Start
|
||||||
<form action="./down" method="POST"><button type="submit">Down</button></form>
|
</button>
|
||||||
<form action="./delete" method="GET"><button type="submit">Delete</button></form>
|
</form>
|
||||||
|
<form action="./restart" method="POST">
|
||||||
|
<button title="Restart containers" type="submit">
|
||||||
|
Restart
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form action="./stop" method="POST">
|
||||||
|
<button title="Stop all containers" type="submit">
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form action="./down" method="POST">
|
||||||
|
<button title="Stop and remove all resources" type="submit">
|
||||||
|
Down
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form action="./delete" method="GET">
|
||||||
|
<button title="Stop and delete everything" type="submit">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<section class="container-list">
|
<section class="container-list">
|
||||||
|
@ -36,7 +56,10 @@
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
<section class="editor">
|
<section class="editor">
|
||||||
<h2>Editor</h2>
|
<h2>
|
||||||
|
Editor
|
||||||
|
<form action="./history"><button title="View past versions">History</button></form>
|
||||||
|
</h2>
|
||||||
<form method="POST" action="./edit" id="editor-form">
|
<form method="POST" action="./edit" id="editor-form">
|
||||||
<div class="error"></div>
|
<div class="error"></div>
|
||||||
<textarea name="source" id="editor">{{file_contents}}</textarea>
|
<textarea name="source" id="editor">{{file_contents}}</textarea>
|
||||||
|
@ -59,10 +82,6 @@
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.containers {
|
.containers {
|
||||||
& .status {
|
& .status {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
Reference in a new issue