add check

This commit is contained in:
Hamcha 2023-11-24 00:17:03 +01:00
parent 21dd617a4b
commit 9e857e1d57
10 changed files with 212 additions and 47 deletions

65
Cargo.lock generated
View file

@ -451,6 +451,22 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" 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]] [[package]]
name = "fnv" name = "fnv"
version = "1.0.7" version = "1.0.7"
@ -806,6 +822,12 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "969488b55f8ac402214f3f5fd243ebb7206cf82de60d3172994707a4bcc2b829"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.11" version = "0.4.11"
@ -1108,6 +1130,19 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" 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]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.14" version = "1.0.14"
@ -1278,13 +1313,13 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sysinfo", "sysinfo",
"tempfile",
"thiserror", "thiserror",
"time", "time",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"xshell",
] ]
[[package]] [[package]]
@ -1325,6 +1360,19 @@ dependencies = [
"winapi", "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]] [[package]]
name = "text-size" name = "text-size"
version = "1.1.1" version = "1.1.1"
@ -1782,18 +1830,3 @@ name = "windows_x86_64_msvc"
version = "0.48.5" version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 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"

View file

@ -19,10 +19,10 @@ rnix = "0.11"
serde = "1" serde = "1"
serde_json = "1" serde_json = "1"
sysinfo = "0.29" sysinfo = "0.29"
tempfile = "3"
thiserror = "1" thiserror = "1"
time = { version = "0.3", features = ["serde"] } time = { version = "0.3", features = ["serde"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tokio-stream = "0.1" tokio-stream = "0.1"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = "0.3" tracing-subscriber = "0.3"
xshell = "0.2"

View file

@ -13,7 +13,7 @@ pub enum AppError {
Client { Client {
status: StatusCode, status: StatusCode,
code: &'static str, code: &'static str,
message: &'static str, message: String,
}, },
#[error("docker error: {0}")] #[error("docker error: {0}")]
@ -22,9 +22,6 @@ pub enum AppError {
#[error("file error: {0}")] #[error("file error: {0}")]
FileError(#[from] std::io::Error), FileError(#[from] std::io::Error),
#[error("shell error: {0}")]
ShellError(#[from] xshell::Error),
#[error("unexpected internal error: {0}")] #[error("unexpected internal error: {0}")]
Internal(#[from] anyhow::Error), Internal(#[from] anyhow::Error),
@ -54,11 +51,6 @@ impl IntoResponse for AppError {
code: "file-error".to_string(), code: "file-error".to_string(),
message: err.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 { AppError::Template(err) => ErrorInfo {
status: StatusCode::INTERNAL_SERVER_ERROR, status: StatusCode::INTERNAL_SERVER_ERROR,
code: "template-error".to_string(), code: "template-error".to_string(),

View file

@ -14,7 +14,7 @@ impl From<StackError> for AppError {
StackError::NotFound => AppError::Client { StackError::NotFound => AppError::Client {
status: StatusCode::NOT_FOUND, status: StatusCode::NOT_FOUND,
code: "stack-not-found", code: "stack-not-found",
message: "stack not found", message: "stack not found".to_string(),
}, },
} }
} }

View file

@ -71,12 +71,14 @@ impl ThreadSafeRepository {
Repository::open(&self.path) 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()?; let repository = self.repository()?;
// Commit file // Commit file
let mut index = repository.index()?; let mut index = repository.index()?;
index.add_path(path)?; for path in paths {
index.add_path(path)?;
}
let oid = index.write_tree()?; let oid = index.write_tree()?;
let tree = repository.find_tree(oid)?; let tree = repository.find_tree(oid)?;
let head = repository.head()?; let head = repository.head()?;

View file

@ -2,17 +2,23 @@ use super::{
container::ContainerInfo, error::StackError, git::ThreadSafeRepository, container::ContainerInfo, error::StackError, git::ThreadSafeRepository,
nix::parse_arion_compose, 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 bollard::{container::ListContainersOptions, service::ContainerSummary, Docker};
use futures_util::future::try_join;
use serde::Serialize; use serde::Serialize;
use std::{ use std::{
collections::HashMap, collections::HashMap,
path::{Path, PathBuf}, path::{Path, PathBuf},
process::Command,
}; };
use tempfile::tempdir;
use tokio::fs; use tokio::fs;
use xshell::Shell;
const COMPOSE_FILE: &str = "arion-compose.nix"; const COMPOSE_FILE: &str = "arion-compose.nix";
const PACKAGE_FILE: &str = "arion-pkgs.nix";
const PACKAGE_CONTENTS: &str = r#"import <nixpkgs> { system = "x86_64-linux"; }
"#;
async fn is_stack(dir: &Path) -> Result<bool> { async fn is_stack(dir: &Path) -> Result<bool> {
Ok(fs::try_exists(dir.join(COMPOSE_FILE)).await?) Ok(fs::try_exists(dir.join(COMPOSE_FILE)).await?)
@ -148,26 +154,108 @@ pub fn commit_compose(
message: &str, message: &str,
) -> Result<()> { ) -> Result<()> {
let compose_path = format!("{}/{}", stack_name, COMPOSE_FILE); 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(()) 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<CommandStatus> {
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( pub async fn command(
base_dir: &Path, base_dir: &Path,
stack_name: &str, stack_name: &str,
arion_bin: &Path, arion_bin: &Path,
action: StackCommand, action: StackCommand,
) -> Result<()> { ) -> Result<CommandStatus> {
let dir = base_dir.join(stack_name); let dir = base_dir.join(stack_name);
if !is_stack(&dir).await? { if !is_stack(&dir).await? {
return Err(StackError::NotFound.into()); return Err(StackError::NotFound.into());
} }
let sh = Shell::new()?; arion(arion_bin, &dir, action)
sh.change_dir(dir);
sh.cmd(arion_bin).args(action.command()).run()?;
Ok(())
} }
pub enum StackCommand { pub enum StackCommand {
@ -175,6 +263,7 @@ pub enum StackCommand {
Start, Start,
Stop, Stop,
Restart, Restart,
Test,
} }
impl StackCommand { impl StackCommand {
@ -184,6 +273,7 @@ impl StackCommand {
StackCommand::Start => &["up", "-d"], StackCommand::Start => &["up", "-d"],
StackCommand::Stop => &["stop"], StackCommand::Stop => &["stop"],
StackCommand::Restart => &["restart"], StackCommand::Restart => &["restart"],
StackCommand::Test => &["config"],
} }
} }
} }

View file

@ -6,7 +6,8 @@ use crate::{
node::{ node::{
container::ContainerInfo, container::ContainerInfo,
stack::{ 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, AppState,
@ -63,6 +64,21 @@ async fn new_stack_page() -> impl IntoResponse {
CreateTemplate {} CreateTemplate {}
} }
#[derive(Deserialize)]
struct CreateStackForm {
name: String,
source: String,
}
async fn create_stack(
State(state): State<AppState>,
Form(form): Form<CreateStackForm>,
) -> Result<Redirect, AppError> {
create_new(state.repository, &form.name, &form.source).await?;
Ok(Redirect::to(format!("/stack/{}/", form.name).as_str()))
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct EditStackForm { struct EditStackForm {
source: String, source: String,
@ -84,7 +100,7 @@ async fn edit_stack(
return Err(AppError::Client { return Err(AppError::Client {
status: StatusCode::BAD_REQUEST, status: StatusCode::BAD_REQUEST,
code: "invalid-source", 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?; .await?;
Ok(Redirect::to("./")) as Result<Redirect, AppError> 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)
} }
macro_rules! stack_command { macro_rules! stack_command {
@ -120,7 +144,8 @@ macro_rules! stack_command {
pub(super) fn router() -> Router<AppState> { pub(super) fn router() -> Router<AppState> {
Router::new() Router::new()
.route("/_/new", get(new_stack_page)) .route("/_/new", get(new_stack_page).post(create_stack))
.route("/_/check", post(check_stack_file))
.route( .route(
"/:stack", "/:stack",
get(|Path(stack_name): Path<String>| async move { get(|Path(stack_name): Path<String>| async move {

View file

@ -2,6 +2,8 @@ import "../vendor/ace/ace.js";
import { findNearestParent } from "./node-utils.mjs"; import { findNearestParent } from "./node-utils.mjs";
export default class Editor { export default class Editor {
editor;
/** /**
* Create a new editor * Create a new editor
* @param {string} elementID ID of element to replace with the editor * @param {string} elementID ID of element to replace with the editor

View file

@ -41,6 +41,7 @@
<div class="row"> <div class="row">
<input style="flex:1" name="commit_message" type="text" <input style="flex:1" name="commit_message" type="text"
placeholder="What did you change?" /> placeholder="What did you change?" />
<div id="editor-extra"></div>
<button class="wide" type="submit">Deploy</button> <button class="wide" type="submit">Deploy</button>
</div> </div>
</form> </form>
@ -104,7 +105,27 @@
<script type="module"> <script type="module">
import Editor from "/static/js/ace.mjs"; import Editor from "/static/js/ace.mjs";
new Editor("editor"); const editor = new Editor("editor");
/* Add extra buttons */
const extraContainer = document.getElementById("editor-extra");
/* Check button */
const checkButton = document.createElement("button");
checkButton.appendChild(document.createTextNode("Check"));
checkButton.type = "button";
checkButton.addEventListener("click", async () => {
const body = editor.editor.getValue();
const response = await fetch("/stack/_/check", {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": "text/plain",
},
body
});
})
extraContainer.appendChild(checkButton);
</script> </script>
{% endblock %} {% endblock %}

View file

@ -8,11 +8,11 @@
<h1>New stack</h1> <h1>New stack</h1>
</header> </header>
<section class="editor"> <section class="editor">
<form method="POST" action="./edit" id="editor-form"> <form method="POST" id="editor-form">
<div class="row"> <div class="row">
<input style="flex:1" name="name" type="text" placeholder="Stack name" /> <input style="flex:1" name="name" type="text" placeholder="Stack name" required />
</div> </div>
<textarea name="source" id="editor">{}</textarea> <textarea name="source" id="editor" required>{}</textarea>
<div class="row"> <div class="row">
<button type="submit">Create & Deploy</button> <button type="submit">Create & Deploy</button>
</div> </div>
@ -32,7 +32,7 @@
<script type="module"> <script type="module">
import Editor from "/static/js/ace.mjs"; import Editor from "/static/js/ace.mjs";
new Editor("editor"); const editor = new Editor("editor");
</script> </script>
{% endblock %} {% endblock %}