reorganize code

This commit is contained in:
Hamcha 2023-11-25 12:24:06 +01:00
parent e3fecbee95
commit d7dd7ca7be
16 changed files with 450 additions and 373 deletions

View file

@ -7,20 +7,23 @@ pub struct StackComposeInfo {
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> {
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()))
})

View file

@ -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>
},
),
)
}

View 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())
}

View 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>
},
),
)
}

View 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,
},
)
}

View file

@ -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
View 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
View 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
View 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
View 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
View 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
View 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(),
},
)
}

View file

@ -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;
}
}
}

View file

@ -87,7 +87,6 @@
}
h2 {
text-transform: uppercase;
margin: 0;
padding: 0;
margin-bottom: 1rem;

View file

@ -73,7 +73,9 @@
</article>
</section>
<section class="stack-list">
<h2>Stacks <form action="/stack/_/new"><button>+</button></form>
<h2>
Stacks
<form action="/stack/_/new"><button>+</button></form>
</h2>
<table class="table full-width">
<thead>
@ -135,21 +137,6 @@
</main>
<style scoped>
h2 {
display: flex;
align-items: center;
gap: 0.5ch;
& form {
display: flex;
align-items: center;
& button {
padding: 2px 6px;
}
}
}
.containers {
& .status {
text-align: center;

View file

@ -7,11 +7,31 @@
<header>
<h1>Stack details for <span class="stack-name">{{stack_name}}</span></h1>
<div class="actions">
<form action="./start" method="POST"><button type="submit">Start</button></form>
<form action="./restart" method="POST"><button type="submit">Restart</button></form>
<form action="./stop" method="POST"><button type="submit">Stop</button></form>
<form action="./down" method="POST"><button type="submit">Down</button></form>
<form action="./delete" method="GET"><button type="submit">Delete</button></form>
<form action="./start" method="POST">
<button title="Start all containers" type="submit">
Start
</button>
</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>
</header>
<section class="container-list">
@ -36,7 +56,10 @@
</table>
</section>
<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">
<div class="error"></div>
<textarea name="source" id="editor">{{file_contents}}</textarea>
@ -59,10 +82,6 @@
gap: 1rem;
}
h2 {
text-transform: uppercase;
}
.containers {
& .status {
text-align: center;