diff --git a/Cargo.lock b/Cargo.lock index 43c9459..c6123fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -451,6 +451,22 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "errno" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f258a7194e7f7c2a7837a8913aeab7fd8c383457034fa20ce4dd3dcb813e8eb8" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" + [[package]] name = "fnv" version = "1.0.7" @@ -806,6 +822,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "969488b55f8ac402214f3f5fd243ebb7206cf82de60d3172994707a4bcc2b829" + [[package]] name = "lock_api" version = "0.4.11" @@ -1108,6 +1130,19 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustix" +version = "0.38.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc99bc2d4f1fed22595588a013687477aedf3cdcfb26558c559edb67b4d9b22e" +dependencies = [ + "bitflags 2.4.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.14" @@ -1278,13 +1313,13 @@ dependencies = [ "serde", "serde_json", "sysinfo", + "tempfile", "thiserror", "time", "tokio", "tokio-stream", "tracing", "tracing-subscriber", - "xshell", ] [[package]] @@ -1325,6 +1360,19 @@ dependencies = [ "winapi", ] +[[package]] +name = "tempfile" +version = "3.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef1adac450ad7f4b3c28589471ade84f25f731a7a0fe30d71dfa9f60fd808e5" +dependencies = [ + "cfg-if", + "fastrand", + "redox_syscall", + "rustix", + "windows-sys", +] + [[package]] name = "text-size" version = "1.1.1" @@ -1782,18 +1830,3 @@ name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - -[[package]] -name = "xshell" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2107fe03e558353b4c71ad7626d58ed82efaf56c54134228608893c77023ad" -dependencies = [ - "xshell-macros", -] - -[[package]] -name = "xshell-macros" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e2c411759b501fb9501aac2b1b2d287a6e93e5bdcf13c25306b23e1b716dd0e" diff --git a/Cargo.toml b/Cargo.toml index d351319..5c07c03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,10 +19,10 @@ rnix = "0.11" serde = "1" serde_json = "1" sysinfo = "0.29" +tempfile = "3" thiserror = "1" time = { version = "0.3", features = ["serde"] } tokio = { version = "1", features = ["full"] } tokio-stream = "0.1" tracing = "0.1" tracing-subscriber = "0.3" -xshell = "0.2" diff --git a/src/http/error.rs b/src/http/error.rs index aba8bc5..5a55dbe 100644 --- a/src/http/error.rs +++ b/src/http/error.rs @@ -13,7 +13,7 @@ pub enum AppError { Client { status: StatusCode, code: &'static str, - message: &'static str, + message: String, }, #[error("docker error: {0}")] @@ -22,9 +22,6 @@ pub enum AppError { #[error("file error: {0}")] FileError(#[from] std::io::Error), - #[error("shell error: {0}")] - ShellError(#[from] xshell::Error), - #[error("unexpected internal error: {0}")] Internal(#[from] anyhow::Error), @@ -54,11 +51,6 @@ impl IntoResponse for AppError { code: "file-error".to_string(), message: err.to_string(), }, - AppError::ShellError(err) => ErrorInfo { - status: StatusCode::INTERNAL_SERVER_ERROR, - code: "shell-error".to_string(), - message: err.to_string(), - }, AppError::Template(err) => ErrorInfo { status: StatusCode::INTERNAL_SERVER_ERROR, code: "template-error".to_string(), diff --git a/src/node/error.rs b/src/node/error.rs index a5d9852..0a49292 100644 --- a/src/node/error.rs +++ b/src/node/error.rs @@ -14,7 +14,7 @@ impl From for AppError { StackError::NotFound => AppError::Client { status: StatusCode::NOT_FOUND, code: "stack-not-found", - message: "stack not found", + message: "stack not found".to_string(), }, } } diff --git a/src/node/git.rs b/src/node/git.rs index 1e3e42c..19e18be 100644 --- a/src/node/git.rs +++ b/src/node/git.rs @@ -71,12 +71,14 @@ impl ThreadSafeRepository { Repository::open(&self.path) } - pub fn commit_file(&self, path: &Path, message: &str) -> Result<()> { + pub fn commit_files(&self, paths: &[&Path], message: &str) -> Result<()> { let repository = self.repository()?; // Commit file let mut index = repository.index()?; - index.add_path(path)?; + for path in paths { + index.add_path(path)?; + } let oid = index.write_tree()?; let tree = repository.find_tree(oid)?; let head = repository.head()?; diff --git a/src/node/stack.rs b/src/node/stack.rs index 4c99978..f6bf662 100644 --- a/src/node/stack.rs +++ b/src/node/stack.rs @@ -2,17 +2,23 @@ use super::{ container::ContainerInfo, error::StackError, git::ThreadSafeRepository, nix::parse_arion_compose, }; -use crate::http::error::Result; +use crate::http::error::{AppError, Result}; +use axum::http::StatusCode; use bollard::{container::ListContainersOptions, service::ContainerSummary, Docker}; +use futures_util::future::try_join; use serde::Serialize; use std::{ collections::HashMap, path::{Path, PathBuf}, + process::Command, }; +use tempfile::tempdir; use tokio::fs; -use xshell::Shell; const COMPOSE_FILE: &str = "arion-compose.nix"; +const PACKAGE_FILE: &str = "arion-pkgs.nix"; +const PACKAGE_CONTENTS: &str = r#"import { system = "x86_64-linux"; } +"#; async fn is_stack(dir: &Path) -> Result { Ok(fs::try_exists(dir.join(COMPOSE_FILE)).await?) @@ -148,26 +154,108 @@ pub fn commit_compose( message: &str, ) -> Result<()> { let compose_path = format!("{}/{}", stack_name, COMPOSE_FILE); - repository.commit_file(&PathBuf::from(compose_path), message)?; + repository.commit_files(&[&PathBuf::from(compose_path)], message)?; Ok(()) } +pub async fn create_new( + repository: ThreadSafeRepository, + stack_name: &str, + source: &str, +) -> Result<()> { + // Calculate stack directory and create it + let stack_path = repository.path.join(stack_name); + fs::create_dir_all(&stack_path).await?; + + // Create package file and compose file + try_join( + fs::write(stack_path.join(PACKAGE_FILE), PACKAGE_CONTENTS), + fs::write(stack_path.join(COMPOSE_FILE), source), + ) + .await?; + + // Commit everything + repository.commit_files( + &[ + &PathBuf::from(format!("{}/{}", stack_name, PACKAGE_FILE)), + &PathBuf::from(format!("{}/{}", stack_name, COMPOSE_FILE)), + ], + format!("Created stack {}", stack_name).as_str(), + )?; + + Ok(()) +} + +pub async fn check_compose(arion_bin: &Path, source: &str) -> Result<()> { + // Check that it's a valid nix tree + rnix::Root::parse(source) + .ok() + .map_err(|_| AppError::Client { + status: StatusCode::NOT_ACCEPTABLE, + code: "failed-nix-parse", + message: "The provided source is not valid Nix".to_string(), + })?; + + // Create a temporary stack and check that it generates a YAML tree + let dir = tempdir()?; + let path = dir.path(); + + // Create package file and compose file + try_join( + fs::write(path.join(PACKAGE_FILE), PACKAGE_CONTENTS), + fs::write(path.join(COMPOSE_FILE), source), + ) + .await?; + + let cmd = arion(arion_bin, path, StackCommand::Test)?; + + dir.close()?; + + if let CommandStatus::Failure(_, err) = cmd { + Err(AppError::Client { + status: StatusCode::NOT_ACCEPTABLE, + code: "failed-arion-check", + message: err, + }) + } else { + Ok(()) + } +} + +pub enum CommandStatus { + Success(String, String), + Failure(String, String), +} + +fn arion(arion_bin: &Path, path: &Path, action: StackCommand) -> Result { + let output = Command::new(arion_bin) + .args(action.command()) + .current_dir(path) + .output()?; + + // Convert stdout and stderr to String + let stdout_str = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr_str = String::from_utf8_lossy(&output.stderr).to_string(); + + if output.status.success() { + Ok(CommandStatus::Success(stdout_str, stderr_str)) + } else { + Ok(CommandStatus::Failure(stdout_str, stderr_str)) + } +} + pub async fn command( base_dir: &Path, stack_name: &str, arion_bin: &Path, action: StackCommand, -) -> Result<()> { +) -> Result { let dir = base_dir.join(stack_name); if !is_stack(&dir).await? { return Err(StackError::NotFound.into()); } - let sh = Shell::new()?; - sh.change_dir(dir); - sh.cmd(arion_bin).args(action.command()).run()?; - - Ok(()) + arion(arion_bin, &dir, action) } pub enum StackCommand { @@ -175,6 +263,7 @@ pub enum StackCommand { Start, Stop, Restart, + Test, } impl StackCommand { @@ -184,6 +273,7 @@ impl StackCommand { StackCommand::Start => &["up", "-d"], StackCommand::Stop => &["stop"], StackCommand::Restart => &["restart"], + StackCommand::Test => &["config"], } } } diff --git a/src/route/stack.rs b/src/route/stack.rs index 538b98e..fc36157 100644 --- a/src/route/stack.rs +++ b/src/route/stack.rs @@ -6,7 +6,8 @@ use crate::{ node::{ container::ContainerInfo, stack::{ - command, commit_compose, get_compose, get_containers, write_compose, StackCommand, + check_compose, command, commit_compose, create_new, get_compose, get_containers, + write_compose, StackCommand, }, }, AppState, @@ -63,6 +64,21 @@ 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 { + create_new(state.repository, &form.name, &form.source).await?; + + Ok(Redirect::to(format!("/stack/{}/", form.name).as_str())) +} + #[derive(Deserialize)] struct EditStackForm { source: String, @@ -84,7 +100,7 @@ async fn edit_stack( return Err(AppError::Client { status: StatusCode::BAD_REQUEST, code: "invalid-source", - message: "provided stack source is empty", + message: "provided stack source is empty".to_string(), }); }; @@ -106,7 +122,15 @@ async fn edit_stack( ) .await?; - Ok(Redirect::to("./")) as Result + 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) } macro_rules! stack_command { @@ -120,7 +144,8 @@ macro_rules! stack_command { pub(super) fn router() -> Router { Router::new() - .route("/_/new", get(new_stack_page)) + .route("/_/new", get(new_stack_page).post(create_stack)) + .route("/_/check", post(check_stack_file)) .route( "/:stack", get(|Path(stack_name): Path| async move { diff --git a/static/js/ace.mjs b/static/js/ace.mjs index a191537..d8428e7 100644 --- a/static/js/ace.mjs +++ b/static/js/ace.mjs @@ -2,6 +2,8 @@ import "../vendor/ace/ace.js"; import { findNearestParent } from "./node-utils.mjs"; export default class Editor { + editor; + /** * Create a new editor * @param {string} elementID ID of element to replace with the editor diff --git a/templates/stack/get-one.html b/templates/stack/get-one.html index 49f0cf5..a66fc36 100644 --- a/templates/stack/get-one.html +++ b/templates/stack/get-one.html @@ -41,6 +41,7 @@
+
@@ -104,7 +105,27 @@ {% endblock %} \ No newline at end of file diff --git a/templates/stack/new-form.html b/templates/stack/new-form.html index 83cb918..a282d1a 100644 --- a/templates/stack/new-form.html +++ b/templates/stack/new-form.html @@ -8,11 +8,11 @@

New stack

-
+
- +
- +
@@ -32,7 +32,7 @@ {% endblock %} \ No newline at end of file